Files
new-api/service/auth_token.go
T
Calcium-Ion 31d70fca39 refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
2026-07-20 16:48:43 +08:00

217 lines
7.3 KiB
Go

package service
import (
"crypto/hmac"
"crypto/sha256"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
AccessTokenTTL = 15 * time.Minute
SecurityProofTTL = 5 * time.Minute
LoginSessionTTL = 30 * 24 * time.Hour
RefreshReplayWindow = 30 * time.Second
accessTokenUse = "access"
securityProofTokenUse = "security_proof"
authTokenIssuer = "new-api"
authTokenAudience = "new-api-dashboard"
)
var (
ErrAuthTokenInvalid = errors.New("authentication token is invalid")
ErrAuthTokenExpired = errors.New("authentication token has expired")
ErrProofScope = errors.New("security proof scope mismatch")
ErrProofMethod = errors.New("security proof method mismatch")
)
// AuthIdentity is the server-validated identity attached to dashboard requests.
// Role, status and group are deliberately loaded from the user cache instead of JWT claims.
type AuthIdentity struct {
UserID int
SessionID string
UserAuthVersion int64
SessionVersion int64
}
type authClaims struct {
TokenUse string `json:"token_use"`
SessionID string `json:"sid"`
UserAuthVersion int64 `json:"uv"`
SessionVersion int64 `json:"sv"`
Method string `json:"method,omitempty"`
Scopes []string `json:"scopes,omitempty"`
jwt.RegisteredClaims
}
func authSigningKey(purpose string) []byte {
mac := hmac.New(sha256.New, []byte(common.SessionSecret))
_, _ = mac.Write([]byte("new-api/auth/" + purpose + "/v1"))
return mac.Sum(nil)
}
func IssueAccessToken(identity AuthIdentity) (string, int64, error) {
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 {
return "", 0, ErrAuthTokenInvalid
}
now := time.Now()
expiresAt := now.Add(AccessTokenTTL)
claims := authClaims{
TokenUse: accessTokenUse,
SessionID: identity.SessionID,
UserAuthVersion: identity.UserAuthVersion,
SessionVersion: identity.SessionVersion,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: authTokenIssuer,
Subject: strconv.Itoa(identity.UserID),
Audience: jwt.ClaimStrings{authTokenAudience},
ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
ID: uuid.NewString(),
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(accessTokenUse))
return signed, expiresAt.Unix(), err
}
func ParseAccessToken(raw string) (AuthIdentity, error) {
claims, err := parseAuthClaims(raw, accessTokenUse, authSigningKey(accessTokenUse))
if err != nil {
return AuthIdentity{}, err
}
userID, err := strconv.Atoi(claims.Subject)
if err != nil || userID <= 0 || claims.SessionID == "" || claims.UserAuthVersion <= 0 || claims.SessionVersion <= 0 {
return AuthIdentity{}, ErrAuthTokenInvalid
}
return AuthIdentity{
UserID: userID,
SessionID: claims.SessionID,
UserAuthVersion: claims.UserAuthVersion,
SessionVersion: claims.SessionVersion,
}, nil
}
// ParseDashboardAccessToken distinguishes new-api dashboard JWTs from opaque
// credentials. A token carrying the dashboard issuer, audience and a known
// token use is always treated as internal, even when its signature, lifetime
// or requested purpose is invalid, so it can never fall through to PAT or
// relay-token authentication.
func ParseDashboardAccessToken(raw string) (identity AuthIdentity, internal bool, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return AuthIdentity{}, false, nil
}
claims := &authClaims{}
parsed, _, parseErr := jwt.NewParser().ParseUnverified(raw, claims)
if parseErr != nil || parsed == nil {
return AuthIdentity{}, false, nil
}
audienceMatches := false
for _, audience := range claims.Audience {
if audience == authTokenAudience {
audienceMatches = true
break
}
}
knownTokenUse := claims.TokenUse == accessTokenUse || claims.TokenUse == securityProofTokenUse
if claims.Issuer != authTokenIssuer || !audienceMatches || !knownTokenUse {
return AuthIdentity{}, false, nil
}
identity, err = ParseAccessToken(raw)
return identity, true, err
}
func IssueSecurityProof(identity AuthIdentity, method string, scopes []string) (string, int64, error) {
method = strings.TrimSpace(method)
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 || method == "" || len(scopes) == 0 {
return "", 0, ErrAuthTokenInvalid
}
now := time.Now()
expiresAt := now.Add(SecurityProofTTL)
claims := authClaims{
TokenUse: securityProofTokenUse,
SessionID: identity.SessionID,
UserAuthVersion: identity.UserAuthVersion,
SessionVersion: identity.SessionVersion,
Method: method,
Scopes: append([]string(nil), scopes...),
RegisteredClaims: jwt.RegisteredClaims{
Issuer: authTokenIssuer,
Subject: strconv.Itoa(identity.UserID),
Audience: jwt.ClaimStrings{authTokenAudience},
ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
ID: uuid.NewString(),
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(securityProofTokenUse))
return signed, expiresAt.Unix(), err
}
func VerifySecurityProof(raw string, identity AuthIdentity, requiredScope string, allowedMethods []string) (string, error) {
claims, err := parseAuthClaims(raw, securityProofTokenUse, authSigningKey(securityProofTokenUse))
if err != nil {
return "", err
}
userID, err := strconv.Atoi(claims.Subject)
if err != nil || userID != identity.UserID || claims.SessionID != identity.SessionID || claims.UserAuthVersion != identity.UserAuthVersion || claims.SessionVersion != identity.SessionVersion {
return "", ErrAuthTokenInvalid
}
methodAllowed := len(allowedMethods) == 0
for _, method := range allowedMethods {
if hmac.Equal([]byte(claims.Method), []byte(method)) {
methodAllowed = true
break
}
}
if !methodAllowed {
return "", ErrProofMethod
}
if requiredScope != "" {
found := false
for _, scope := range claims.Scopes {
if hmac.Equal([]byte(scope), []byte(requiredScope)) {
found = true
break
}
}
if !found {
return "", ErrProofScope
}
}
return claims.Method, nil
}
func parseAuthClaims(raw, expectedUse string, key []byte) (*authClaims, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, ErrAuthTokenInvalid
}
claims := &authClaims{}
parsed, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("%w: unexpected signing method", ErrAuthTokenInvalid)
}
return key, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(authTokenIssuer), jwt.WithAudience(authTokenAudience), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithLeeway(5*time.Second))
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrAuthTokenExpired
}
return nil, fmt.Errorf("%w: %v", ErrAuthTokenInvalid, err)
}
if !parsed.Valid || claims.TokenUse != expectedUse || claims.ID == "" || claims.IssuedAt == nil || claims.NotBefore == nil {
return nil, ErrAuthTokenInvalid
}
return claims, nil
}