Files
sm-user-plugin/classes/session_user.go
Jakub Zych 846087db45 fix: a /refresh carried by the auth.cookie session cookie rewrites that cookie, so an HttpOnly session survives the rotation
8e4b863 answered a cookie-carried refresh in the body only and blacklisted the old jti, so an HttpOnly cookie (golem15.sociallogin token_cookie) kept a dead token. Now the cookie named auth.cookie.name gets the rotated token and the body is {}; bearer and auth_token refreshes are unchanged, and refusals write no cookie.
2026-10-22 23:39:18 +02:00

276 lines
9.5 KiB
Go

package classes
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
// sessionCookieNames are the cookies the plugin's "jwt" guard reads after
// the Authorization header, in the same order (plugin.go Boot).
var sessionCookieNames = []string{"token", "auth_token"}
// SessionCookieNames returns a copy of the cookie names the plugin's "jwt"
// guard (plugin.go Boot) reads after the Authorization header, in order.
// A handler that builds its own guard passes them to bouncer.NewJWTGuard so
// it reads the same token as SessionToken and jwt.auth.
func SessionCookieNames() []string {
return slices.Clone(sessionCookieNames)
}
// RequestUser returns the signed-in user of r for handlers that run without
// the jwt.auth middleware and must also serve anonymous requests (an OAuth
// provider's authorize page, for example). The token is read and checked
// exactly as the plugin's "jwt" guard does: the Authorization bearer header
// first, then the token and auth_token cookies; the plugin's JWT secret;
// the published bouncer.BlacklistStore when there is one; and the user's
// tokens_valid_after cutoff. A soft-deleted user is not returned.
//
// An anonymous request, or a token that is invalid, expired, blacklisted or
// names no user, returns (nil, nil). Only an infrastructure failure (no
// database handle, a failing query or blacklist lookup) returns an error.
func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*models.User, error) {
if r == nil {
return nil, nil
}
raw := SessionToken(r)
if raw == "" {
return nil, nil
}
secret, err := JWTSecret(app)
if err != nil {
return nil, err
}
sub, iat, _, jti, err := bouncer.VerifyClaims(raw, secret)
if err != nil {
return nil, nil
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
return nil, nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
}
var user models.User
err = gdb.WithContext(ctx).Where("id = ?", uint(id)).Take(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil {
blocked, err := bl.IsBlacklisted(ctx, jti)
if err != nil {
return nil, err
}
if blocked {
return nil, nil
}
}
if user.TokensValidAfter != nil && iat.Before(*user.TokensValidAfter) {
return nil, nil
}
return &user, nil
}
// EndSession signs the request out; POST /logout runs it. Every session JWT
// r carries (the Authorization bearer header and the token and auth_token
// cookies the "jwt" guard reads) that verifies is added to the published
// bouncer.BlacklistStore, so the guard, RequestUser and POST /refresh refuse
// it from then on. A token verifies while it is unexpired, and a token with
// the user audience also after its access lifetime while its refresh window
// is open (it could still be refreshed). The row is kept until the later of
// the token's exp and its iat plus the refresh window
// (golem15.user.jwt.refresh_ttl), plus one minute, as bouncer.Refresh keeps
// a rotated jti: a row that ended at exp would let /refresh revive the
// logged-out token for the rest of its window.
//
// When w is not nil the response gets the forget cookie of
// bouncer.LoadCookieConfig(app.Config), with the name, path, domain,
// SameSite and Secure of the token cookie a login writes, and one with the
// same attributes for each other session cookie r carries. It writes no
// status or body.
//
// It returns the subject of the token the guard would read (the bearer
// header first, then the cookies in order), or "" when r carries none or it
// does not verify. Only a failing blacklist write is an error; the cookies
// are forgotten and the other tokens revoked in any case. Without a
// published blacklist nothing is revoked.
func EndSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) (string, error) {
if w != nil {
forgetSessionCookies(app, w, r)
}
if r == nil || app == nil {
return "", nil
}
secret, err := JWTSecret(app)
if err != nil {
return "", nil
}
bl, _ := app.Lookup[bouncer.BlacklistStore]()
window := refreshWindow(app)
subject := ""
var firstErr error
for i, raw := range sessionTokens(r) {
sub, iat, exp, jti, ok := verifySessionToken(raw, secret, window)
if !ok {
continue
}
if i == 0 {
subject = sub
}
if bl == nil {
continue
}
if err := bl.Add(ctx, jti, revokedUntil(iat, exp, window), time.Now()); err != nil && firstErr == nil {
firstErr = err
}
}
return subject, firstErr
}
// EndRequestSession is the best-effort EndSession, as in the PHP provider:
// it revokes the session JWTs r carries and forgets the session cookies the
// same way, logs a blacklist failure instead of returning it, and always
// returns nil. It writes no status or body.
func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error {
if _, err := EndSession(ctx, app, w, r); err != nil {
pluginLog.Warn("golem15.user: end session: blacklist token", "err", err)
}
return nil
}
// forgetSessionCookies expires the token cookie of
// bouncer.LoadCookieConfig(app.Config) and, with the same path, domain,
// SameSite and Secure, each other session cookie r carries.
func forgetSessionCookies(app *backpack.App, w http.ResponseWriter, r *http.Request) {
cfg := bouncer.CookieConfig{Name: bouncer.DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode}
if app != nil && app.Config != nil {
loaded, err := bouncer.LoadCookieConfig(app.Config)
if err != nil {
pluginLog.Warn("golem15.user: end session: cookie config", "err", err)
} else {
cfg = loaded
}
}
http.SetCookie(w, cfg.ForgetTokenCookie())
if r == nil {
return
}
for _, name := range sessionCookieNames {
if name == cfg.Name {
continue
}
if _, err := r.Cookie(name); err != nil {
continue
}
other := cfg
other.Name = name
http.SetCookie(w, other.ForgetTokenCookie())
}
}
// verifySessionToken accepts raw when it is a token the "jwt" guard accepts
// or POST /refresh still rotates: a user-audience token whose refresh window
// is open (its exp may have passed), or an unexpired token, which also
// covers one without an audience (issued by the PHP backend). A token
// without a jti cannot be revoked and is refused.
func verifySessionToken(raw, secret string, window time.Duration) (sub string, iat, exp time.Time, jti string, ok bool) {
sub, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, secret, bouncer.AudienceUser, window)
if err != nil {
sub, iat, exp, jti, err = bouncer.VerifyClaims(raw, secret)
}
return sub, iat, exp, jti, err == nil && jti != ""
}
// revokedUntil is the storage expiry of a revoked session jti: the later of
// exp and iat plus the refresh window, plus one minute (PHP jwt-auth's
// Blacklist::getMinutesUntilExpired, which bouncer.Refresh follows).
func revokedUntil(iat, exp time.Time, window time.Duration) time.Time {
until := iat.Add(window)
if exp.After(until) {
until = exp
}
return until.Add(time.Minute)
}
// refreshWindow is golem15.user.jwt.refresh_ttl minutes, default 20160: the
// window POST /refresh accepts (controllers.RefreshTTL reads the same key).
func refreshWindow(app *backpack.App) time.Duration {
mins := 20160
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 {
mins = app.Config.Int("golem15.user.jwt.refresh_ttl")
}
return time.Duration(mins) * time.Minute
}
// SessionToken returns the session JWT the plugin's "jwt" guard reads from r:
// the Authorization bearer header, then each cookie of SessionCookieNames in
// order, trimmed, empty values skipped. It returns "" when r carries none.
// A controller that builds its own guard passes SessionCookieNames() to
// bouncer.NewJWTGuard, so the guard and SessionToken read the same token.
func SessionToken(r *http.Request) string {
if tokens := sessionTokens(r); len(tokens) > 0 {
return tokens[0]
}
return ""
}
// SessionTokenCookie names the session cookie that supplied the token
// SessionToken returns, for a handler that writes that cookie back (POST
// /refresh rewrites it with the rotated token). It is "" when the bearer
// header supplied the token or the request carries none.
func SessionTokenCookie(r *http.Request) string {
if r == nil || bearerToken(r) != "" {
return ""
}
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil && strings.TrimSpace(c.Value) != "" {
return name
}
}
return ""
}
// bearerToken returns the trimmed token of r's Authorization bearer header,
// "" when there is none.
func bearerToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(token)
}
return ""
}
// sessionTokens returns every distinct session JWT r carries, in the order
// the "jwt" guard tries them: the Authorization bearer header, then each
// session cookie. Empty values are skipped.
func sessionTokens(r *http.Request) []string {
var out []string
add := func(v string) {
if v = strings.TrimSpace(v); v != "" && !slices.Contains(out, v) {
out = append(out, v)
}
}
add(bearerToken(r))
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil {
add(c.Value)
}
}
return out
}