feat: opt-in golem15.user.jwt.token_cookie sets the HttpOnly session cookie on /login, /register and /activate-by-code instead of returning the token
Default off, so body-token clients are unchanged. When on, the cookie carries the auth.cookie attributes via bouncer.LoadCookieConfig and the body keeps user (and message); SameSite stays Lax, with no CSRF token; personal API tokens are untouched.
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
237
token_cookie_test.go
Normal file
237
token_cookie_test.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user