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:
Jakub Zych
2026-10-22 23:44:44 +02:00
parent 846087db45
commit 8bdf2c7d1a
4 changed files with 290 additions and 9 deletions

View File

@@ -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