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"} // 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 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 { 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 }