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