Files
root e045e00fbf
Frontend CI / Frontend (push) Successful in 3m8s
Backend CI / Backend (push) Successful in 14m48s
feat(auth,prompt-rule): add password change with session revocation and scope prompt rules by brand
Add self-service password change that re-hashes the credential and bumps a
per-user session version so all existing access/refresh tokens are rejected.
Session version is tracked in Redis with an in-memory fallback and enforced in
middleware and refresh.

Scope prompt rules and rule groups to a brand: new brand_id column (migrated to
a "未归属" fallback brand for existing rows), brand-filtered queries/indexes, and
brand-aware admin-web tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:09:53 +08:00

214 lines
5.5 KiB
Go

package auth
import (
"errors"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const authRequestLogContextKey = "auth_request_log_context"
type RequestLogContext struct {
HeaderPresent bool
Scheme string
TokenFingerprint string
FailureStage string
FailureReason string
ParseError string
TokenSubject string
TokenExpiresAt string
}
func Middleware(jwtMgr *Manager, sessions *SessionStore) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
updateRequestLogContext(c, func(meta *RequestLogContext) {
meta.HeaderPresent = strings.TrimSpace(header) != ""
meta.Scheme = authorizationScheme(header)
})
raw, err := bearerToken(header)
if err != nil {
updateRequestLogContext(c, func(meta *RequestLogContext) {
meta.FailureStage = "read_bearer_token"
meta.FailureReason = bearerHeaderFailureReason(header)
})
response.Error(c, response.ErrUnauthorized(40101, "missing_bearer_token", "Authorization header is required"))
c.Abort()
return
}
updateRequestLogContext(c, func(meta *RequestLogContext) {
meta.TokenFingerprint = tokenFingerprint(raw)
})
claims, err := jwtMgr.Parse(raw)
if err != nil {
updateRequestLogContext(c, func(meta *RequestLogContext) {
meta.FailureStage = "parse_access_token"
meta.FailureReason = accessTokenFailureReason(err)
meta.ParseError = err.Error()
})
response.Error(c, response.ErrUnauthorized(40102, "invalid_access_token", "token is invalid or expired"))
c.Abort()
return
}
updateRequestLogContext(c, func(meta *RequestLogContext) {
meta.TokenSubject = strings.TrimSpace(claims.Subject)
if claims.ExpiresAt != nil {
meta.TokenExpiresAt = claims.ExpiresAt.Time.UTC().Format(time.RFC3339)
}
})
if claims.Subject != "access" {
updateRequestLogContext(c, func(meta *RequestLogContext) {
meta.FailureStage = "validate_access_subject"
meta.FailureReason = "wrong_subject"
})
response.Error(c, response.ErrUnauthorized(40102, "invalid_access_token", "token is invalid or expired"))
c.Abort()
return
}
currentVersion, _ := sessions.SessionVersion(c.Request.Context(), claims.UserID)
if claims.SessionVersion < currentVersion {
updateRequestLogContext(c, func(meta *RequestLogContext) {
meta.FailureStage = "check_session_version"
meta.FailureReason = "session_version_stale"
})
response.Error(c, response.ErrUnauthorized(40103, "token_revoked", "token has been revoked"))
c.Abort()
return
}
revoked, _ := sessions.IsBlacklisted(c.Request.Context(), claims.ID)
if revoked {
updateRequestLogContext(c, func(meta *RequestLogContext) {
meta.FailureStage = "check_blacklist"
meta.FailureReason = "blacklisted"
})
response.Error(c, response.ErrUnauthorized(40103, "token_revoked", "token has been logged out"))
c.Abort()
return
}
actor := Actor{
UserID: claims.UserID,
TenantID: claims.TenantID,
PrimaryWorkspaceID: claims.PrimaryWorkspaceID,
Role: claims.Role,
}
c.Request = c.Request.WithContext(WithActor(c.Request.Context(), actor))
c.Next()
}
}
func bearerToken(header string) (string, error) {
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return "", response.ErrUnauthorized(40101, "missing_bearer_token", "Authorization header is required")
}
return parts[1], nil
}
func RequestLogContextFromGin(c *gin.Context) (RequestLogContext, bool) {
if c == nil {
return RequestLogContext{}, false
}
value, ok := c.Get(authRequestLogContextKey)
if !ok || value == nil {
return RequestLogContext{}, false
}
meta, ok := value.(*RequestLogContext)
if !ok || meta == nil {
return RequestLogContext{}, false
}
return *meta, true
}
func updateRequestLogContext(c *gin.Context, apply func(*RequestLogContext)) {
if c == nil || apply == nil {
return
}
value, ok := c.Get(authRequestLogContextKey)
if ok {
if meta, ok := value.(*RequestLogContext); ok && meta != nil {
apply(meta)
return
}
}
meta := &RequestLogContext{}
apply(meta)
c.Set(authRequestLogContextKey, meta)
}
func authorizationScheme(header string) string {
parts := strings.Fields(strings.TrimSpace(header))
if len(parts) == 0 {
return ""
}
return parts[0]
}
func bearerHeaderFailureReason(header string) string {
trimmed := strings.TrimSpace(header)
if trimmed == "" {
return "missing_authorization_header"
}
parts := strings.Fields(trimmed)
if len(parts) == 0 {
return "missing_authorization_header"
}
if !strings.EqualFold(parts[0], "bearer") {
return "invalid_authorization_scheme"
}
if len(parts) == 1 {
return "missing_token_after_bearer"
}
return "malformed_authorization_header"
}
func accessTokenFailureReason(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, jwt.ErrTokenExpired):
return "expired"
case errors.Is(err, jwt.ErrTokenNotValidYet):
return "not_valid_yet"
case errors.Is(err, jwt.ErrTokenMalformed):
return "malformed"
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
return "bad_signature"
case errors.Is(err, jwt.ErrTokenUnverifiable):
return "unverifiable"
case errors.Is(err, jwt.ErrTokenInvalidClaims):
return "invalid_claims"
default:
return "parse_failed"
}
}
func tokenFingerprint(raw string) string {
if strings.TrimSpace(raw) == "" {
return ""
}
hashed := HashToken(raw)
if len(hashed) > 16 {
return hashed[:16]
}
return hashed
}