fix: logout ends cookie-carried sessions, clears the session cookies and keeps the token revoked for its refresh window
POST /logout read the token from the Authorization header only, while the jwt guard also accepts the token and auth_token cookies. A session carried by a cookie (golem15.sociallogin's token_cookie sets an httpOnly one that the browser cannot turn into a bearer header) was answered 401, nothing was revoked and the cookie kept authenticating jwt.auth routes until the access token expired (golem15.user.jwt.ttl, 60 minutes). Logout never cleared a cookie either, and it and EndRequestSession kept the jwt_blacklist row only until the access token's exp, so once the sweep removed the row POST /refresh revived the logged-out token for the rest of its refresh window. classes.EndSession, which Logout now runs, revokes every session token the request carries (the bearer header, the token and auth_token cookies) that verifies, an expired one too while it could still be refreshed, until the later of exp and iat plus refresh_ttl, plus a minute, as bouncer.Refresh and the admin logout do. The answer, a 401 included, expires the auth.cookie token cookie and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. EndRequestSession is its best-effort form. Response bodies are unchanged; an expired but still refreshable token now logs out with 200 instead of 401.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -77,23 +78,77 @@ func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*mode
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// EndRequestSession signs the request's user out the way Logout does and
|
||||
// forgets the token cookie. When r carries a valid session JWT its jti is
|
||||
// added to the published bouncer.BlacklistStore until the token expires;
|
||||
// the response always gets the forget cookie of
|
||||
// bouncer.LoadCookieConfig(app.Config), so its Domain and Path match the
|
||||
// cookie the login wrote. It is best effort, as in the PHP provider: a
|
||||
// blacklist failure is logged and never returned, and the function writes
|
||||
// no status or body.
|
||||
func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error {
|
||||
if r != nil {
|
||||
if raw := sessionToken(r); raw != "" {
|
||||
revokeSessionToken(ctx, app, raw)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if w == nil {
|
||||
return nil
|
||||
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)
|
||||
@@ -104,48 +159,83 @@ func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWr
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, cfg.ForgetTokenCookie())
|
||||
return nil
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// revokeSessionToken blacklists raw's jti until its expiry (best effort).
|
||||
func revokeSessionToken(ctx context.Context, app *backpack.App, raw string) {
|
||||
if app == nil {
|
||||
return
|
||||
}
|
||||
secret, err := JWTSecret(app)
|
||||
// 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 {
|
||||
return
|
||||
sub, iat, exp, jti, err = bouncer.VerifyClaims(raw, secret)
|
||||
}
|
||||
_, _, exp, jti, err := bouncer.VerifyClaims(raw, secret)
|
||||
if err != nil || jti == "" {
|
||||
return
|
||||
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
|
||||
}
|
||||
bl, ok := app.Lookup[bouncer.BlacklistStore]()
|
||||
if !ok || bl == nil {
|
||||
return
|
||||
}
|
||||
if err := bl.Add(ctx, jti, exp, time.Now()); err != nil {
|
||||
pluginLog.Warn("golem15.user: end session: blacklist token", "err", err)
|
||||
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 extracts the session JWT as the "jwt" guard does: the
|
||||
// Authorization bearer header, then each session cookie in order.
|
||||
func sessionToken(r *http.Request) string {
|
||||
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
if token = strings.TrimSpace(token); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
for _, name := range sessionCookieNames {
|
||||
c, err := r.Cookie(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v := strings.TrimSpace(c.Value); v != "" {
|
||||
return v
|
||||
}
|
||||
if tokens := sessionTokens(r); len(tokens) > 0 {
|
||||
return tokens[0]
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
add(token)
|
||||
}
|
||||
for _, name := range sessionCookieNames {
|
||||
if c, err := r.Cookie(name); err == nil {
|
||||
add(c.Value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user