2026-04-01 00:58:42 +08:00
|
|
|
package auth
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-18 17:16:57 +08:00
|
|
|
"errors"
|
2026-04-01 00:58:42 +08:00
|
|
|
"strings"
|
2026-04-18 17:16:57 +08:00
|
|
|
"time"
|
2026-04-01 00:58:42 +08:00
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
2026-04-18 17:16:57 +08:00
|
|
|
"github.com/golang-jwt/jwt/v5"
|
2026-04-01 00:58:42 +08:00
|
|
|
|
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-18 17:16:57 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 00:58:42 +08:00
|
|
|
func Middleware(jwtMgr *Manager, sessions *SessionStore) gin.HandlerFunc {
|
|
|
|
|
return func(c *gin.Context) {
|
2026-04-18 17:16:57 +08:00
|
|
|
header := c.GetHeader("Authorization")
|
|
|
|
|
updateRequestLogContext(c, func(meta *RequestLogContext) {
|
|
|
|
|
meta.HeaderPresent = strings.TrimSpace(header) != ""
|
|
|
|
|
meta.Scheme = authorizationScheme(header)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
raw, err := bearerToken(header)
|
2026-04-01 00:58:42 +08:00
|
|
|
if err != nil {
|
2026-04-18 17:16:57 +08:00
|
|
|
updateRequestLogContext(c, func(meta *RequestLogContext) {
|
|
|
|
|
meta.FailureStage = "read_bearer_token"
|
|
|
|
|
meta.FailureReason = bearerHeaderFailureReason(header)
|
|
|
|
|
})
|
2026-04-01 00:58:42 +08:00
|
|
|
response.Error(c, response.ErrUnauthorized(40101, "missing_bearer_token", "Authorization header is required"))
|
|
|
|
|
c.Abort()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-18 17:16:57 +08:00
|
|
|
updateRequestLogContext(c, func(meta *RequestLogContext) {
|
|
|
|
|
meta.TokenFingerprint = tokenFingerprint(raw)
|
|
|
|
|
})
|
2026-04-01 00:58:42 +08:00
|
|
|
|
|
|
|
|
claims, err := jwtMgr.Parse(raw)
|
2026-04-18 17:16:57 +08:00
|
|
|
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"
|
|
|
|
|
})
|
2026-04-01 00:58:42 +08:00
|
|
|
response.Error(c, response.ErrUnauthorized(40102, "invalid_access_token", "token is invalid or expired"))
|
|
|
|
|
c.Abort()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
revoked, _ := sessions.IsBlacklisted(c.Request.Context(), claims.ID)
|
|
|
|
|
if revoked {
|
2026-04-18 17:16:57 +08:00
|
|
|
updateRequestLogContext(c, func(meta *RequestLogContext) {
|
|
|
|
|
meta.FailureStage = "check_blacklist"
|
|
|
|
|
meta.FailureReason = "blacklisted"
|
|
|
|
|
})
|
2026-04-01 00:58:42 +08:00
|
|
|
response.Error(c, response.ErrUnauthorized(40103, "token_revoked", "token has been logged out"))
|
|
|
|
|
c.Abort()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
actor := Actor{
|
2026-04-19 14:18:20 +08:00
|
|
|
UserID: claims.UserID,
|
|
|
|
|
TenantID: claims.TenantID,
|
|
|
|
|
PrimaryWorkspaceID: claims.PrimaryWorkspaceID,
|
|
|
|
|
Role: claims.Role,
|
2026-04-01 00:58:42 +08:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-04-18 17:16:57 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|