- RequestUser resolves the signed-in user exactly like the jwt guard (bearer, token and auth_token cookies, blacklist, tokens_valid_after) and returns nil for anonymous or invalid requests - EndRequestSession blacklists the session JWT and forgets the token cookie with the configured cookie attributes, best effort
153 lines
4.5 KiB
Go
153 lines
4.5 KiB
Go
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 ""
|
|
}
|