diff --git a/README.md b/README.md index 5c8c62a..897dca6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap ## Features - **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore. -- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (`golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes. +- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin's `golem15.user.jwt.token_cookie`, `golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. With `golem15.user.jwt.token_cookie` on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set that HttpOnly session cookie (`bouncer.LoadCookieConfig` attributes, Max-Age `refresh_ttl`) and leave `token` out of the body; `user`, and `message` on activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie. With the key on, a broken `auth.cookie` block answers those three routes with the opaque 500 before they sign in, create or activate anything. Personal API tokens (`POST /tokens`) are not sessions and are unaffected. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes. - **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. - **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it. - **Login throttling.** Failed logins are counted per user and client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter. @@ -210,6 +210,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any | `golem15.user.jwt.blacklist_grace` | `0` | Grace period in seconds for a blacklisted token. | | `golem15.user.jwt.leeway` | `0` | Clock leeway in seconds (declared for parity, not read yet). | | `golem15.user.jwt.blacklist_sweep_interval` | `10m` | How often expired blacklist rows are deleted (a Go duration). | +| `golem15.user.jwt.token_cookie` | `false` | When on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set the HttpOnly session cookie with the `auth.cookie.*` attributes (`bouncer.LoadCookieConfig`; Max-Age `refresh_ttl`) and leave `token` out of the body (`user`, and `message` on activate-by-code, stay). When off, the token is in the body and no cookie is set. The twin of `golem15.sociallogin.token_cookie`. | | `golem15.user.activation.require_activation` | `true` | Whether an account must be activated before login. When `true`, `POST /login` refuses a not-activated account (the opaque invalid-login answer, counted as a failed attempt) and `/register` in `admin` mode returns no token; when `false`, both sign such an account in. | | `golem15.user.activation.activate_mode` | `auto` | `auto`, `user` (mailed code) or `admin`. | | `golem15.user.activation.reset_ttl_minutes` | `60` | Password reset code lifetime. | @@ -271,6 +272,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D `TestRefreshRewritesAuthCookie` covers the cookie-carried `POST /refresh`: the `token` cookie rewritten with the rotated token and the `LoadCookieConfig` attributes (the old token refused, the new one served by `/fetch` and `jwt.auth`), `auth_token` and bearer refreshes left body-only (the bearer wins over a cookie), a configured cookie name and refresh window, refusals that set no cookie, and a broken `auth.cookie` block that answers the opaque 500 and rotates nothing. `TestSessionTokenCookie` (in `-short`) is the carrier table of `classes.SessionTokenCookie`. +`TestTokenCookie` covers `golem15.user.jwt.token_cookie`: off by default (body tokens, no cookie); on for `POST /login`, `POST /register` and `POST /activate-by-code`, with the session cookie serving `/fetch` and rotating through a cookie refresh and no `token` in the body; `POST /tokens` unaffected; a configured cookie name and refresh window; and a broken `auth.cookie` block that answers the opaque 500 and signs in, creates and activates nothing. + `TestAdminOrganisationMembershipClearsRole` checks that the members manager's unlink and link and a Users form save that changes the organisation clear `organisation_role`, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns. The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`. diff --git a/config/config.yaml b/config/config.yaml index d2f627d..a994735 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -5,6 +5,11 @@ jwt: blacklist_grace: 0 leeway: 0 blacklist_sweep_interval: 10m + # On: POST /login, /register (when it signs the account in) and + # /activate-by-code set the HttpOnly session cookie from auth.cookie.* and + # leave token out of the body. A POST /refresh carried by that cookie + # rewrites it either way. Keep auth.cookie.name token or auth_token. + token_cookie: false activation: require_activation: true activate_mode: auto diff --git a/controllers/api_controller.go b/controllers/api_controller.go index e808ecb..b1800ff 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -56,13 +56,21 @@ const invalidLoginBody = "Invalid email or password" // stays deactivated and gets no mail. With the key off such an account signs // in as before. Activation gates login, deactivation does not. The throttle // counts failed attempts per user and client address, which is -// surf.ClientIP with http.trusted_proxies, as for /register. +// surf.ClientIP with http.trusted_proxies, as for /register. With +// golem15.user.jwt.token_cookie on, the token goes into the auth.cookie +// session cookie instead of the body. func Login(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) if !ok { return } + cc, err := sessionCookie(app) + if err != nil { + pluginLog.Error("golem15.user: token cookie config", "err", err) + writeOpaque500(w) + return + } fields, err := readFields(r) if err != nil { writeOpaque500(w) @@ -136,7 +144,7 @@ func Login(app *backpack.App) http.HandlerFunc { writeOpaque500(w) return } - writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload}) + writeSession(w, app, cc, token, map[string]any{"user": payload}) } } @@ -489,12 +497,20 @@ func Activate(app *backpack.App) http.HandlerFunc { } // ActivateByCode activates from a public "{id}!{code}" value and mints a token. +// With golem15.user.jwt.token_cookie on, the token goes into the auth.cookie +// session cookie instead of the body. func ActivateByCode(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) if !ok { return } + cc, err := sessionCookie(app) + if err != nil { + pluginLog.Error("golem15.user: token cookie config", "err", err) + writeOpaque500(w) + return + } fields, err := readFields(r) if err != nil { writeOpaque500(w) @@ -540,11 +556,7 @@ func ActivateByCode(app *backpack.App) http.HandlerFunc { writeOpaque500(w) return } - writeJSON(w, http.StatusOK, map[string]any{ - "message": "Account activated", - "token": token, - "user": payload, - }) + writeSession(w, app, cc, token, map[string]any{"message": "Account activated", "user": payload}) } } @@ -760,12 +772,20 @@ func MarketingConsent(app *backpack.App) http.HandlerFunc { } // Register creates an account and follows activate_mode: auto, user, or admin. +// With golem15.user.jwt.token_cookie on, the token of an account it signs in +// goes into the auth.cookie session cookie instead of the body. func Register(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, _, ok := sessionDeps(w, app) if !ok { return } + cc, err := sessionCookie(app) + if err != nil { + pluginLog.Error("golem15.user: token cookie config", "err", err) + writeOpaque500(w) + return + } allow, throttle, mode, require := registrationSettings(app) ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app))) if !allow { @@ -829,7 +849,7 @@ func Register(app *backpack.App) http.HandlerFunc { writeOpaque500(w) return } - writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload}) + writeSession(w, app, cc, token, map[string]any{"user": payload}) return } writeJSON(w, http.StatusOK, map[string]any{}) @@ -1212,6 +1232,22 @@ func tokenCookieConfig(app *backpack.App) (bouncer.CookieConfig, error) { return bouncer.LoadCookieConfig(app.Config) } +// sessionCookie is the golem15.sociallogin token_cookie twin: with +// golem15.user.jwt.token_cookie on it returns the auth.cookie config the +// session routes write their token cookie with, or the error that block +// gives. A nil config means the token goes into the body; auth.cookie is +// then not read. +func sessionCookie(app *backpack.App) (*bouncer.CookieConfig, error) { + if app == nil || app.Config == nil || !app.Config.Bool("golem15.user.jwt.token_cookie") { + return nil, nil + } + cc, err := tokenCookieConfig(app) + if err != nil { + return nil, err + } + return &cc, nil +} + // writeSession answers 200 with a session token, the user plugin's twin of // golem15.sociallogin's writeSession. With cc nil the token goes into // body["token"], the key set the session routes have always answered. With diff --git a/token_cookie_test.go b/token_cookie_test.go new file mode 100644 index 0000000..28137df --- /dev/null +++ b/token_cookie_test.go @@ -0,0 +1,237 @@ +package user + +import ( + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/compass" +) + +const ( + userLoginPath = "/_user/api/v1/login" + userRegisterPath = "/_user/api/v1/register" + userActivateByCodePath = "/_user/api/v1/activate-by-code" +) + +// TestTokenCookie pins golem15.user.jwt.token_cookie: off keeps the body +// token and sets no cookie; on puts the session token of /login, /register +// and /activate-by-code into the HttpOnly auth.cookie cookie and leaves it +// out of the body; a broken auth.cookie block answers the opaque 500 before +// anything is signed in, created or activated. +func TestTokenCookie(t *testing.T) { + t.Run("off by default", func(t *testing.T) { + e := newAdminEnv(t, tokenCookieSettings(t, nil)) + user := e.seedUser(t, "token-cookie-off", nil) + rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"}) + if rec.Code != http.StatusOK { + t.Fatalf("login = %d %s", rec.Code, rec.Body.String()) + } + assertBodyKeys(t, rec, "token", "user") + assertNoCookie(t, rec) + + rec = e.call(t, http.MethodPost, userRegisterPath, "", registerBody("token-cookie-off-register")) + if rec.Code != http.StatusOK { + t.Fatalf("register = %d %s", rec.Code, rec.Body.String()) + } + assertBodyKeys(t, rec, "token", "user") + assertNoCookie(t, rec) + + rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": e.activationCode(t, e.inactiveUser(t, "token-cookie-off-activate"))}) + if rec.Code != http.StatusOK { + t.Fatalf("activate-by-code = %d %s", rec.Code, rec.Body.String()) + } + assertBodyKeys(t, rec, "message", "token", "user") + assertNoCookie(t, rec) + }) + + t.Run("on", func(t *testing.T) { + e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{"golem15.user.jwt.token_cookie": true})) + cfg := e.cookieConfig(t) + user := e.seedUser(t, "token-cookie-on", nil) + rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"}) + if rec.Code != http.StatusOK { + t.Fatalf("login = %d %s", rec.Code, rec.Body.String()) + } + body := assertBodyKeys(t, rec, "user") + if got, _ := body["user"].(map[string]any)["email"].(string); got != user.Email { + t.Fatalf("login answered user %q, want %q", got, user.Email) + } + session := assertSessionCookie(t, rec, cfg, 20160*60) + e.assertCookieServes(t, cfg.Name, session, userSub(user)) + + rec = e.cookieSend(t, http.MethodPost, userRefreshPath, cfg.Name, session, nil) + if rec.Code != http.StatusOK { + t.Fatalf("cookie refresh = %d %s", rec.Code, rec.Body.String()) + } + assertBodyKeys(t, rec) + rotated := assertSessionCookie(t, rec, cfg, 20160*60) + if rotated == session { + t.Fatal("the refreshed cookie carries the old token") + } + e.assertCookieServes(t, cfg.Name, rotated, userSub(user)) + + rec = e.cookieSend(t, http.MethodPost, userTokensPath, cfg.Name, rotated, map[string]string{"name": "cli"}) + if rec.Code != http.StatusCreated { + t.Fatalf("POST /tokens = %d %s", rec.Code, rec.Body.String()) + } + if secret, _ := assertBodyKeys(t, rec, "token", "meta")["token"].(string); secret == "" { + t.Fatalf("POST /tokens answered %s", rec.Body.String()) + } + assertNoCookie(t, rec) + + rec = e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "wrong-horse"}) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody { + t.Fatalf("login with a wrong password = %d %s", rec.Code, rec.Body.String()) + } + assertNoCookie(t, rec) + + rec = e.call(t, http.MethodPost, userRegisterPath, "", registerBody("token-cookie-on-register")) + if rec.Code != http.StatusOK { + t.Fatalf("register = %d %s", rec.Code, rec.Body.String()) + } + body = assertBodyKeys(t, rec, "user") + registered := assertSessionCookie(t, rec, cfg, 20160*60) + id, _ := body["user"].(map[string]any)["id"].(float64) + e.assertCookieServes(t, cfg.Name, registered, userSub(&models.User{ID: uint(id)})) + + inactive := e.inactiveUser(t, "token-cookie-on-activate") + rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": e.activationCode(t, inactive)}) + if rec.Code != http.StatusOK { + t.Fatalf("activate-by-code = %d %s", rec.Code, rec.Body.String()) + } + body = assertBodyKeys(t, rec, "message", "user") + if body["message"] != "Account activated" || body["user"].(map[string]any)["is_activated"] != true { + t.Fatalf("activate-by-code answered %s", rec.Body.String()) + } + activated := assertSessionCookie(t, rec, cfg, 20160*60) + e.assertCookieServes(t, cfg.Name, activated, userSub(inactive)) + }) + + t.Run("on with configured auth.cookie", func(t *testing.T) { + e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{ + "golem15.user.jwt.token_cookie": true, + "auth.cookie.name": "auth_token", + "golem15.user.jwt.refresh_ttl": 60, + })) + cfg := e.cookieConfig(t) + if cfg.Name != "auth_token" { + t.Fatalf("config not applied: %+v", cfg) + } + user := e.seedUser(t, "token-cookie-custom", nil) + rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"}) + if rec.Code != http.StatusOK { + t.Fatalf("login = %d %s", rec.Code, rec.Body.String()) + } + assertBodyKeys(t, rec, "user") + session := assertSessionCookie(t, rec, cfg, 3600) + e.assertCookieServes(t, "auth_token", session, userSub(user)) + }) + + t.Run("on with a broken auth.cookie", func(t *testing.T) { + e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{ + "golem15.user.jwt.token_cookie": true, + "auth.cookie.same_site": "sideways", + })) + user := e.seedUser(t, "token-cookie-broken", nil) + rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"}) + assertOpaque500(t, rec) + if got := e.reload(t, user.ID); got.LastSeen != nil { + t.Fatalf("last_seen after a refused login = %v", got.LastSeen) + } + if got := e.hangers(t, user).Throttle; got != 0 { + t.Fatalf("user_throttle rows after a refused login = %d", got) + } + + register := registerBody("token-cookie-broken-register") + rec = e.call(t, http.MethodPost, userRegisterPath, "", register) + assertOpaque500(t, rec) + var count int64 + if err := e.gdb.Unscoped().Model(&models.User{}).Where("email = ?", register["email"]).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("register with a broken auth.cookie stored %d users", count) + } + + inactive := e.inactiveUser(t, "token-cookie-broken-activate") + code := e.activationCode(t, inactive) + rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": code}) + assertOpaque500(t, rec) + if e.reload(t, inactive.ID).IsActivated { + t.Fatal("activate-by-code with a broken auth.cookie activated the user") + } + if err := e.app.Config.Set("auth.cookie.same_site", "lax"); err != nil { + t.Fatal(err) + } + rec = e.call(t, http.MethodPost, userActivateByCodePath, "", map[string]string{"code": code}) + if rec.Code != http.StatusOK { + t.Fatalf("activate-by-code after fixing auth.cookie = %d %s", rec.Code, rec.Body.String()) + } + assertBodyKeys(t, rec, "message", "user") + assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60) + }) +} + +// tokenCookieSettings turns the registration throttle off and sets keys. +func tokenCookieSettings(t *testing.T, keys map[string]any) func(*compass.Config) { + return func(c *compass.Config) { + if err := c.Set("golem15.user.registration.use_register_throttle", false); err != nil { + t.Fatal(err) + } + for key, value := range keys { + if err := c.Set(key, value); err != nil { + t.Fatal(err) + } + } + } +} + +// registerBody is a /register body with a unique email. +func registerBody(label string) map[string]string { + return map[string]string{ + "email": label + "-" + adminStamp() + "@example.test", + "password": "correct-horse", + "name": label, + } +} + +// inactiveUser seeds a not-activated frontend user. +func (e *adminEnv) inactiveUser(t *testing.T, label string) *models.User { + t.Helper() + return e.seedUser(t, label, func(u *models.User) { u.IsActivated = false }) +} + +// activationCode issues an activation code for user, packed as id!code. +func (e *adminEnv) activationCode(t *testing.T, user *models.User) string { + t.Helper() + code, err := classes.IssueActivationCode(t.Context(), e.gdb, user) + if err != nil { + t.Fatal(err) + } + return userSub(user) + "!" + code +} + +// assertCookieServes: GET /fetch with only the named cookie set to token +// serves the user sub. +func (e *adminEnv) assertCookieServes(t *testing.T, name, token, sub string) { + t.Helper() + rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("fetch with the %s cookie = %d %s", name, rec.Code, rec.Body.String()) + } + if got := fetchedID(t, rec); got != sub { + t.Fatalf("fetch with the %s cookie served user %q, want %q", name, got, sub) + } +} + +// assertOpaque500 requires the plugin's opaque 500 and no cookie. +func assertOpaque500(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + if rec.Code != http.StatusInternalServerError || rec.Body.String() != opaque500Body { + t.Fatalf("want the opaque 500, got %d %s", rec.Code, rec.Body.String()) + } + assertNoCookie(t, rec) +}