fix: /fetch, /refresh, /update and /change-password accept the session cookies the jwt guard reads

The four routes (and the other signed-in routes through authenticate) read the bearer header, then the token and auth_token cookies, through classes.SessionToken and one cookie-aware guard.
A bearer header still wins and no credentials stays 401 with the old bodies.
A cookie-carried /refresh answers the rotated token in the body and writes no cookie.
This commit is contained in:
Jakub Zych
2026-10-22 21:45:52 +02:00
parent 15ff9793ba
commit 8e4b863a3f
5 changed files with 330 additions and 47 deletions

View File

@@ -186,16 +186,21 @@ func Fetch(app *backpack.App) http.HandlerFunc {
}
}
// Refresh slides a still-refreshable bearer token of a user who may still
// sign in: deactivated and banned users and tokens issued before the user's
// tokens_valid_after are refused with the token-failure 401 body.
// Refresh slides a still-refreshable session token of a user who may still
// sign in. The token is read as the plugin's jwt guard reads it: the bearer
// header, then the token and auth_token cookies. The rotated token is
// answered in the body only; no cookie is written. The old token is
// blacklisted after golem15.user.jwt.blacklist_grace, so a caller that keeps
// the session in a cookie stores the new token there. Deactivated and banned
// users and tokens issued before the user's tokens_valid_after are refused
// with the token-failure 401 body.
func Refresh(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
raw := bearerToken(r)
raw := classes.SessionToken(r)
if raw == "" {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Token not found"})
return
@@ -583,24 +588,29 @@ func Update(app *backpack.App) http.HandlerFunc {
}
// ChangePassword replaces a self-set password and invalidates older tokens.
// The token that made this call stays valid.
// The token is read as the plugin's jwt guard reads it (the bearer header,
// then the token and auth_token cookies), and the token that made this call,
// header or cookie, stays valid.
func ChangePassword(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
raw := bearerToken(r)
raw := classes.SessionToken(r)
sub, iat, _, _, err := bouncer.VerifyClaims(raw, secret)
if err != nil {
writeUnauthorized(w)
return
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
principal, err := sessionGuard(app, secret).Authenticate(r)
if err != nil || principal == nil {
writeUnauthorized(w)
return
}
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
// The guard and SessionToken read the same token; a differing
// subject fails closed.
if strconv.FormatUint(uint64(principal.ID), 10) != sub {
writeUnauthorized(w)
return
}
@@ -877,38 +887,41 @@ func appConfig(app *backpack.App) *compass.Config {
return app.Config
}
// authenticate resolves the signed-in user of r for the session routes that
// run without jwt.auth: /fetch, /update, /activate, /marketing-consent,
// /avatar and /avatar/remove. It reads the token as the plugin's jwt guard
// does (the bearer header, then the token and auth_token cookies), applies
// the blacklist and the user's tokens_valid_after cutoff, and answers 401
// otherwise.
func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return nil, false
}
raw := bearerToken(r)
sub, _, _, _, err := bouncer.VerifyClaims(raw, secret)
if err != nil {
writeUnauthorized(w)
return nil, false
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
writeUnauthorized(w)
return nil, false
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
principal, err := sessionGuard(app, secret).Authenticate(r)
if err != nil || principal == nil || principal.ID == 0 {
writeUnauthorized(w)
return nil, false
}
var user models.User
if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil {
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
writeUnauthorized(w)
return nil, false
}
return &user, true
}
// sessionGuard is the plugin's jwt guard as plugin.go Boot registers it: the
// bearer header, then the cookies of classes.SessionCookieNames, checked
// against the published bouncer.BlacklistStore when there is one.
func sessionGuard(app *backpack.App, secret string) bouncer.Guard {
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
return bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, classes.SessionCookieNames()...)
}
func sessionDeps(w http.ResponseWriter, app *backpack.App) (*gorm.DB, string, bool) {
if app == nil {
writeOpaque500(w)
@@ -1135,16 +1148,6 @@ func jsonNumber(v any) string {
}
}
func bearerToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
token, ok := strings.CutPrefix(h, "Bearer ")
token = strings.TrimSpace(token)
if !ok || token == "" {
return ""
}
return token
}
func requestURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {