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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user