package classes import ( "context" "errors" "fmt" "log/slog" "net/http" "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 } // 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) } } if w == nil { return nil } 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 { slog.Default().Warn("golem15.user: end session: cookie config", "err", err) } else { cfg = loaded } } http.SetCookie(w, cfg.ForgetTokenCookie()) return nil } // 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) if err != nil { return } _, _, exp, jti, err := bouncer.VerifyClaims(raw, secret) if err != nil || jti == "" { return } bl, ok := app.Lookup[bouncer.BlacklistStore]() if !ok || bl == nil { return } if err := bl.Add(ctx, jti, exp, time.Now()); err != nil { slog.Default().Warn("golem15.user: end session: blacklist token", "err", err) } } // 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 } } return "" }