fix(identity): 完善统一认证配对恢复与安全退役

修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。

验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
This commit is contained in:
2026-07-17 18:31:12 +08:00
parent cdfca61304
commit a312ad880d
55 changed files with 9225 additions and 419 deletions
+48 -13
View File
@@ -22,7 +22,9 @@ import (
type Permission string
const (
OIDCSessionCookieName = "easyai_gateway_oidc_session"
OIDCSessionCookieName = "easyai_gateway_oidc_session"
localBreakGlassTokenPurpose = "local_break_glass_manager"
legacyAccessTokenPurpose = "legacy_access"
PermissionPublic Permission = "public"
PermissionBasic Permission = "basic"
@@ -52,6 +54,7 @@ type User struct {
TokenExpiresAt time.Time `json:"-"`
TokenIssuedAt time.Time `json:"-"`
Issuer string `json:"-"`
TokenPurpose string `json:"-"`
}
type contextKey string
@@ -142,15 +145,24 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H
}
func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
token := extractBearer(r.Header.Get("Authorization"))
if token == "" {
token = strings.TrimSpace(r.Header.Get("x-comfy-api-key"))
}
if token == "" {
token = strings.TrimSpace(r.Header.Get("x-goog-api-key"))
}
if token == "" {
token = strings.TrimSpace(r.URL.Query().Get("key"))
var token string
if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" {
token = extractBearer(authorization)
if token == "" {
return nil, ErrUnauthorized
}
} else if value := strings.TrimSpace(r.Header.Get("x-comfy-api-key")); value != "" {
token = value
} else if value := strings.TrimSpace(r.Header.Get("x-goog-api-key")); value != "" {
token = value
} else if value := strings.TrimSpace(r.URL.Query().Get("key")); value != "" {
// Query credentials are retained only for API compatibility with
// providers that use `?key=sk-*`. Bearer/OIDC tokens in URLs would leak
// through browser history, reverse-proxy logs, and diagnostics.
if !strings.HasPrefix(value, "sk-") {
return nil, ErrUnauthorized
}
token = value
}
if token == "" {
if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil {
@@ -175,10 +187,14 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
if algorithm == "RS256" || algorithm == "ES256" {
return a.AuthenticateOIDCAccessToken(r.Context(), token)
}
if !a.legacyJWTEnabled() {
return nil, ErrUnauthorized
user, err := a.verifyJWT(token)
if err != nil {
return nil, err
}
return a.verifyJWT(token)
if a.legacyJWTEnabled() || isLocalBreakGlassManager(user) {
return user, nil
}
return nil, ErrUnauthorized
}
func (a *Authenticator) AuthenticateOIDCAccessToken(ctx context.Context, token string) (*User, error) {
@@ -233,6 +249,7 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
APIKeyName: stringClaim(claims, "apiKeyName"),
APIKeyPrefix: stringClaim(claims, "apiKeyPrefix"),
APIKeyScopes: stringSliceClaim(claims, "apiKeyScopes"),
TokenPurpose: stringClaim(claims, "tokenPurpose"),
}
if user.Source == "" {
user.Source = "gateway"
@@ -248,6 +265,10 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
ttl = time.Hour
}
now := time.Now()
tokenPurpose := legacyAccessTokenPurpose
if user.Source == "gateway" && hasManagerRole(user.Roles) {
tokenPurpose = localBreakGlassTokenPurpose
}
claims := jwt.MapClaims{
"sub": user.ID,
"username": user.Username,
@@ -264,6 +285,7 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
"apiKeyName": user.APIKeyName,
"apiKeyPrefix": user.APIKeyPrefix,
"apiKeyScopes": user.APIKeyScopes,
"tokenPurpose": tokenPurpose,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
}
@@ -271,6 +293,19 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
return token.SignedString([]byte(a.JWTSecret))
}
func isLocalBreakGlassManager(user *User) bool {
return user != nil && user.Source == "gateway" && user.TokenPurpose == localBreakGlassTokenPurpose && hasManagerRole(user.Roles)
}
func hasManagerRole(roles []string) bool {
for _, role := range roles {
if role == "manager" || role == "admin" {
return true
}
}
return false
}
func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) {
if a.LocalAPIKeyVerifier != nil {
user, err := a.LocalAPIKeyVerifier(ctx, apiKey)