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.
238 lines
9.2 KiB
Go
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)
|
|
}
|