Files
sm-user-plugin/token_cookie_test.go
Jakub Zych 8bdf2c7d1a 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.
2026-10-22 23:44:44 +02:00

238 lines
9.2 KiB
Go

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)
}