mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
Adds the backend for the operations console: platform_admin roles (super_admin / operator / viewer), an admin_audit_log table, and user.disabled_at so disabling an account is a stored state instead of a hard-coded denylist. The in-process disabled set is now refreshed from the database every 30 seconds, and the old emergency denylist entries are migrated into it. /api/admin exposes overview counts, user and workspace listings with detail, disable/enable, token revocation, the audit log, and platform role management. Every write requires a reason and is recorded with the actor, IP and user agent. PLATFORM_ADMIN_EMAILS bootstraps the first super_admin. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01FrVjAFWeckYV69HS51xUhy
256 lines
10 KiB
Go
256 lines
10 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// Sliding session renewal (MUL-7436).
|
|
//
|
|
// A UI session token used to have its lifetime fixed at login: `exp` was
|
|
// stamped once and never moved, so every continuously active user was logged
|
|
// out on a schedule — 30 days after login, always while they were mid-task.
|
|
// Renewal turns that into an IDLE bound instead: a token inside its renewal
|
|
// window is re-signed with a fresh `exp` on the next authenticated request.
|
|
//
|
|
// The model is deliberately stateless. Renewal copies the identity claims of
|
|
// a token we ourselves signed and moves `exp`; there is no session row, no
|
|
// refresh token, and no DB read on the hot path. What that buys in
|
|
// simplicity it gives up in revocation — which the JWT path never had
|
|
// anyway (logout clears the cookie; the token itself stays valid until
|
|
// `exp`). Real revocation needs server-side session records and is tracked
|
|
// separately.
|
|
//
|
|
// The idle bound is approximate, and the approximation is one-sided: a
|
|
// session is renewed only once it drops below half its TTL, so the guarantee
|
|
// is "at least TTL/2 of idleness is survivable", not "exactly TTL after the
|
|
// last request". A token signed on day 0 and last used on day 14 was not yet
|
|
// eligible, so it still expires on day 30 — 16 idle days, not 30. Sessions
|
|
// used at any point inside the second half of their lifetime get the full
|
|
// TTL from that moment.
|
|
|
|
const (
|
|
// sessionIDClaim carries a per-login identifier that is STABLE across
|
|
// renewals and changes only when the user authenticates again. It is
|
|
// what the CSRF token is bound to, so re-signing the auth cookie does
|
|
// not invalidate CSRF tokens other tabs are already holding.
|
|
sessionIDClaim = "sid"
|
|
|
|
// sessionRenewCheckDivisor derives the client-side re-check cadence
|
|
// from the configured TTL: a client that checks every TTL/10 gets
|
|
// roughly five attempts inside the renewal window, so several
|
|
// consecutive failures still leave the session recoverable. At the
|
|
// default 30-day TTL this is 3 days, matching the daemon's PAT
|
|
// renewal cadence (DefaultTokenRenewalInterval).
|
|
sessionRenewCheckDivisor = 10
|
|
|
|
// minSessionRenewCheckInterval is a pure anti-busy-loop floor, not a
|
|
// policy. With MinAuthTokenTTL enforced the smallest cadence this can
|
|
// ever produce is six seconds, so this floor never actually binds; it
|
|
// stays as a backstop, and the window cap below outranks it either way,
|
|
// because an interval that cannot fit inside the renewal window is not a
|
|
// cadence, it is a guaranteed logout.
|
|
//
|
|
// Every client floor must be at or below this value, or the client would
|
|
// silently override a cadence the server chose for correctness. See
|
|
// packages/core/platform/session-renewal.ts and
|
|
// apps/mobile/data/session-renewal.ts.
|
|
minSessionRenewCheckInterval = 5 * time.Second
|
|
|
|
// maxSessionRenewCheckInterval bounds the other end: a client that has
|
|
// not asked in a week is far enough from its own state that checking is
|
|
// cheap insurance, whatever the TTL says.
|
|
maxSessionRenewCheckInterval = 7 * 24 * time.Hour
|
|
)
|
|
|
|
// ErrNotSessionToken is returned by RenewSessionToken when handed something
|
|
// that is not one of our own UI session JWTs. PATs (mul_), agent task tokens
|
|
// (mat_) and cloud-node PATs (mcn_) authenticate fine at the middleware but
|
|
// must never be exchanged for an interactive session.
|
|
var ErrNotSessionToken = errors.New("not an interactive session token")
|
|
|
|
// AuthRenewThreshold is the remaining lifetime at or below which a session
|
|
// token becomes eligible for renewal. Half the TTL is the largest threshold
|
|
// that still leaves renewal rare (one re-sign per TTL/2 of continuous use)
|
|
// while giving an intermittently used client a window measured in days
|
|
// rather than minutes to land a successful renewal.
|
|
func AuthRenewThreshold() time.Duration { return AuthTokenTTL() / 2 }
|
|
|
|
// SessionRenewCheckInterval is how long a token-mode client should wait
|
|
// before asking whether its session is renewable again. Derived from the
|
|
// configured TTL so a deployment that shortens AUTH_TOKEN_TTL automatically
|
|
// gets proportionally more frequent checks — clients must never hardcode a
|
|
// cadence tuned for the 30-day default.
|
|
//
|
|
// The cadence has one hard requirement: it must fit inside the renewal
|
|
// window, or a client that follows it can miss the window entirely and be
|
|
// logged out while actively using the app. Capping at half the threshold
|
|
// guarantees at least two attempts inside it, so a single failed check is
|
|
// never fatal. That cap is applied last, after both clamps, because it is
|
|
// the correctness constraint and they are only guard rails.
|
|
func SessionRenewCheckInterval() time.Duration {
|
|
interval := AuthTokenTTL() / sessionRenewCheckDivisor
|
|
if interval < minSessionRenewCheckInterval {
|
|
interval = minSessionRenewCheckInterval
|
|
}
|
|
if interval > maxSessionRenewCheckInterval {
|
|
interval = maxSessionRenewCheckInterval
|
|
}
|
|
if cap := AuthRenewThreshold() / 2; interval > cap {
|
|
interval = cap
|
|
}
|
|
return interval
|
|
}
|
|
|
|
// ShouldRenewSession reports whether a token expiring at expiresAt is inside
|
|
// the renewal window at now.
|
|
//
|
|
// An already-expired token is never renewable: `exp` in the past means the
|
|
// credential is dead, and renewal must not be a way to resurrect one. The
|
|
// middleware rejects those before this is ever reached; this is the second
|
|
// lock on the same door.
|
|
func ShouldRenewSession(now, expiresAt time.Time) bool {
|
|
if expiresAt.IsZero() {
|
|
return false
|
|
}
|
|
remaining := expiresAt.Sub(now)
|
|
if remaining <= 0 {
|
|
return false
|
|
}
|
|
return remaining <= AuthRenewThreshold()
|
|
}
|
|
|
|
// NewSessionID mints the per-login identifier stamped into the `sid` claim.
|
|
func NewSessionID() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("generate session id: %w", err)
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
// SessionExpiry reads the `exp` claim as a time. Zero when absent or
|
|
// malformed — callers treat that as "not renewable" rather than "never
|
|
// expires", because a session token we signed always has one.
|
|
func SessionExpiry(claims jwt.MapClaims) time.Time {
|
|
exp, err := claims.GetExpirationTime()
|
|
if err != nil || exp == nil {
|
|
return time.Time{}
|
|
}
|
|
return exp.Time
|
|
}
|
|
|
|
func parseSignedToken(tokenString string, opts ...jwt.ParserOption) (jwt.MapClaims, error) {
|
|
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, jwt.ErrSignatureInvalid
|
|
}
|
|
return JWTSecret(), nil
|
|
}, opts...)
|
|
if err != nil || !token.Valid {
|
|
return nil, ErrNotSessionToken
|
|
}
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return nil, ErrNotSessionToken
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// ParseSessionToken verifies a raw token string as one of our UI session
|
|
// JWTs and returns its claims. Anything that is not a validly signed,
|
|
// unexpired HS256 JWT — including every opaque token prefix — comes back as
|
|
// ErrNotSessionToken.
|
|
//
|
|
// This is the AUTHENTICATION parser: use it wherever the answer decides
|
|
// whether the caller is logged in or whether a session may be extended.
|
|
func ParseSessionToken(tokenString string) (jwt.MapClaims, error) {
|
|
return parseSignedToken(tokenString)
|
|
}
|
|
|
|
// SessionIDFromToken returns the `sid` claim of a token we signed, or "" for
|
|
// anything else — an opaque PAT, a forged string, or one of the pre-MUL-7436
|
|
// JWTs minted before the claim existed. "" is the signal to fall back to the
|
|
// legacy CSRF binding; see ValidateCSRF.
|
|
//
|
|
// Expiry is deliberately NOT enforced here, and that distinction is
|
|
// load-bearing. This function answers "which session does this cookie
|
|
// belong to", not "may this cookie authenticate" — the signature alone
|
|
// settles the first question, and the auth middleware settles the second a
|
|
// few lines later. Enforcing expiry here instead meant an EXPIRED cookie
|
|
// lost its `sid`, so its perfectly valid CSRF token failed to verify and the
|
|
// user's first action after expiry was answered with 403 "CSRF validation
|
|
// failed" rather than the 401 that ends the session and sends them to the
|
|
// login page. A rejected CSRF token must never be how a user learns their
|
|
// session ended.
|
|
func SessionIDFromToken(tokenString string) string {
|
|
claims, err := parseSignedToken(tokenString, jwt.WithoutClaimsValidation())
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
sid, _ := claims[sessionIDClaim].(string)
|
|
return sid
|
|
}
|
|
|
|
// RenewSessionToken re-signs the identity carried by claims with a fresh
|
|
// lifetime, returning the new token and its expiry.
|
|
//
|
|
// Only `exp` and `iat` move. `sub`, `email`, `name` and `sid` are copied
|
|
// forward verbatim, which is what makes renewal free of any database read:
|
|
// the claims were signed by this server, so they are already authoritative
|
|
// for exactly the fields the auth middleware reads back out.
|
|
//
|
|
// A pre-MUL-7436 token carries no `sid`. Renewal mints one, which is the
|
|
// migration path for sessions that predate this code — and the one moment
|
|
// where a CSRF token already read by another tab stops matching. It happens
|
|
// once per legacy session, on a safe-method request; clients retry a CSRF
|
|
// rejection once (packages/core/api/client.ts).
|
|
func RenewSessionToken(claims jwt.MapClaims) (string, time.Time, error) {
|
|
sub, _ := claims["sub"].(string)
|
|
if sub == "" {
|
|
return "", time.Time{}, ErrNotSessionToken
|
|
}
|
|
email, _ := claims["email"].(string)
|
|
|
|
// A user disabled since their token was signed must not have that token
|
|
// extended — renewal is the one place a live session's lifetime is
|
|
// decided after login, so the check belongs here as much as at issuance.
|
|
if IsDisabledUser(sub, email) {
|
|
return "", time.Time{}, ErrDisabledUser
|
|
}
|
|
|
|
sid, _ := claims[sessionIDClaim].(string)
|
|
if sid == "" {
|
|
var err error
|
|
if sid, err = NewSessionID(); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
expiresAt := now.Add(AuthTokenTTL())
|
|
next := jwt.MapClaims{
|
|
"sub": sub,
|
|
"exp": expiresAt.Unix(),
|
|
"iat": now.Unix(),
|
|
sessionIDClaim: sid,
|
|
}
|
|
if email != "" {
|
|
next["email"] = email
|
|
}
|
|
if name, ok := claims["name"].(string); ok && name != "" {
|
|
next["name"] = name
|
|
}
|
|
|
|
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, next).SignedString(JWTSecret())
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
return signed, expiresAt, nil
|
|
}
|