easyai-ai-gateway/apps/api/internal/httpapi/oidc_session.go
chengcheng a312ad880d 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。
2026-07-17 18:31:12 +08:00

400 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
errorCodeOIDCBrowserSessionDisabled = "OIDC_BROWSER_SESSION_DISABLED"
errorCodeOIDCSessionInvalid = "OIDC_SESSION_INVALID"
errorCodeOIDCSessionExpired = "OIDC_SESSION_EXPIRED"
errorCodeOIDCSessionStoreUnavailable = "OIDC_SESSION_STORE_UNAVAILABLE"
errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED"
errorCodeOIDCLoginInvalid = "OIDC_LOGIN_INVALID"
errorCodeOIDCTokenExchangeFailed = "OIDC_TOKEN_EXCHANGE_FAILED"
oidcLoginFailureCookieMissing = "COOKIE_MISSING"
oidcLoginFailureTransactionInvalid = "TRANSACTION_INVALID"
oidcLoginFailureStateMismatch = "STATE_MISMATCH"
oidcLoginFailureAuthorizationResponseMissing = "AUTHORIZATION_RESPONSE_MISSING"
)
// startOIDCLogin godoc
// @Summary 开始 OIDC 公共客户端登录
// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
// @Tags auth
// @Param returnTo query string false "登录后返回的站内相对路径"
// @Success 303
// @Failure 400 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/login [get]
func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
runtime := s.currentIdentityRuntime()
if !oidcRuntimeReady(runtime) {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return
}
returnTo := strings.TrimSpace(r.URL.Query().Get("returnTo"))
if returnTo == "" {
returnTo = "/"
}
transaction, err := oidcsession.NewLoginTransaction(returnTo, time.Now())
if err != nil {
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
return
}
encoded, err := runtime.SessionCipher.EncodeLoginTransaction(transaction)
if err != nil {
s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
return
}
authorizationURL, err := runtime.PublicClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier)
if err != nil {
s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE")
return
}
http.SetCookie(w, &http.Cookie{
Name: oidcsession.LoginTransactionCookieName, Value: encoded,
Path: s.oidcCallbackCookiePath(), MaxAge: 600, Expires: time.Now().Add(10 * time.Minute),
HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, authorizationURL, http.StatusSeeOther)
}
// completeOIDCLogin godoc
// @Summary 完成 OIDC 公共客户端登录
// @Description Gateway 使用 client_id、授权码和 PKCE verifier 换取 Token不发送 Client SecretToken 加密存入服务端 Session。
// @Tags auth
// @Success 303
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/callback [get]
func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
runtime := s.currentIdentityRuntime()
if !oidcRuntimeReady(runtime) {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return
}
s.clearOIDCLoginCookie(w)
cookie, err := r.Cookie(oidcsession.LoginTransactionCookieName)
if err != nil {
s.writeOIDCLoginTransactionError(w, r, "登录事务无效或已过期", oidcLoginFailureCookieMissing)
return
}
transaction, err := runtime.SessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
if err != nil {
s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureTransactionInvalid)
return
}
if r.URL.Query().Get("state") == "" || r.URL.Query().Get("state") != transaction.State {
s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureStateMismatch)
return
}
if r.URL.Query().Get("code") == "" {
s.writeOIDCLoginTransactionError(w, r, "认证中心回调缺少必要参数,请重新登录", oidcLoginFailureAuthorizationResponseMissing)
return
}
tokens, err := runtime.PublicClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier)
if err != nil || tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.IDToken == "" {
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心登录结果无效,请重新登录", errorCodeOIDCTokenExchangeFailed)
return
}
identity, err := runtime.Verifier.Verify(r.Context(), tokens.AccessToken)
if err != nil || identity == nil {
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
return
}
idSubject, err := runtime.PublicClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce)
if err != nil || idSubject != identity.ID {
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
return
}
projection, err := s.resolveOIDCUserProjectionForRevision(r.Context(), r, identity, runtime.Revision)
if err != nil {
s.writeOIDCCallbackProjectionError(w, r, err)
return
}
if projection.User == nil || projection.User.GatewayUserID == "" {
s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user"))
return
}
rawSession, err := runtime.Sessions.Create(r.Context(), oidcsession.TokenBundle{
AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, IDToken: tokens.IDToken,
}, projection.User)
if err != nil {
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "登录会话保存失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
return
}
now := time.Now()
http.SetCookie(w, &http.Cookie{
Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/",
MaxAge: runtime.Revision.SessionAbsoluteSeconds, Expires: now.Add(time.Duration(runtime.Revision.SessionAbsoluteSeconds) * time.Second),
HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteStrictMode,
})
w.Header().Set("Cache-Control", "no-store")
s.recordOIDCSessionAudit(r, projection.User)
http.Redirect(w, r, oidcReturnLocation(runtime.Revision.WebBaseURL, transaction.ReturnTo), http.StatusSeeOther)
}
// logoutOIDCSession godoc
// @Summary 注销 OIDC 登录会话
// @Description 删除 Gateway Session、撤销公共 Client Refresh Token并跳转认证中心退出地址。
// @Tags auth
// @Success 303
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/logout [post]
func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) {
runtime := s.currentIdentityRuntime()
if !oidcRuntimeReady(runtime) {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return
}
var bundle oidcsession.TokenBundle
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
bundle, err = runtime.Sessions.Delete(r.Context(), cookie.Value)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
return
}
}
s.clearOIDCSessionCookie(w)
if bundle.RefreshToken != "" {
if err := runtime.PublicClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil {
s.logger.WarnContext(r.Context(), "revoke OIDC refresh token failed", "error", err)
}
}
// Do not put the encrypted-at-rest ID Token into a browser-visible redirect URL.
location, err := runtime.PublicClient.EndSessionURL(r.Context(), "")
if err != nil {
location = runtime.Revision.WebBaseURL + "/"
}
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, location, http.StatusSeeOther)
}
// deleteOIDCBrowserSession godoc
// @Summary 删除本地 OIDC 浏览器会话
// @Tags auth
// @Success 204
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/session [delete]
func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request) {
runtime := s.currentIdentityRuntime()
if runtime != nil && runtime.Sessions != nil {
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
if _, err := runtime.Sessions.Delete(r.Context(), cookie.Value); err != nil {
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
return
}
}
}
s.clearOIDCSessionCookie(w)
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) oidcBrowserSessionReady() bool {
return oidcRuntimeReady(s.currentIdentityRuntime())
}
func oidcRuntimeReady(runtime *identityRequestRuntime) bool {
return runtime != nil && runtime.BrowserEnabled && runtime.Verifier != nil && runtime.PublicClient != nil && runtime.Sessions != nil && runtime.SessionCipher != nil
}
func (s *Server) clearOIDCLoginCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(),
Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) oidcCallbackCookiePath() string {
return "/api/v1/auth/oidc/callback"
}
func (s *Server) clearOIDCSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: auth.OIDCSessionCookieName, Value: "", Path: "/",
Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) oidcReturnLocation(returnTo string) string {
if runtime := s.currentIdentityRuntime(); runtime != nil {
return oidcReturnLocation(runtime.Revision.WebBaseURL, returnTo)
}
return returnTo
}
func oidcReturnLocation(webBaseURL, returnTo string) string {
if base := strings.TrimRight(strings.TrimSpace(webBaseURL), "/"); base != "" {
return base + returnTo
}
return returnTo
}
func (s *Server) writeOIDCCallbackError(w http.ResponseWriter, r *http.Request, status int, message, code string) {
s.writeOIDCCallbackErrorWithDiagnostics(w, r, status, message, code, "", "")
}
func (s *Server) writeOIDCLoginTransactionError(w http.ResponseWriter, r *http.Request, message, reason string) {
diagnosticID := newOIDCDiagnosticID()
if s.logger != nil {
s.logger.WarnContext(r.Context(), "OIDC login transaction rejected",
"event", "oidc_login_transaction_rejected",
"reason", reason,
"diagnosticId", diagnosticID,
)
}
s.writeOIDCCallbackErrorWithDiagnostics(w, r, http.StatusBadRequest, message, errorCodeOIDCLoginInvalid, reason, diagnosticID)
}
func (s *Server) writeOIDCCallbackErrorWithDiagnostics(w http.ResponseWriter, r *http.Request, status int, message, code, reason, diagnosticID string) {
base := ""
if runtime := s.currentIdentityRuntime(); runtime != nil {
base = strings.TrimRight(strings.TrimSpace(runtime.Revision.WebBaseURL), "/")
}
if parsed, err := url.Parse(base); base != "" && err == nil && parsed.Host != "" && (parsed.Scheme == "https" || parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")) {
query := parsed.Query()
query.Set("oidcError", code)
if reason != "" {
query.Set("oidcErrorReason", reason)
}
if diagnosticID != "" {
query.Set("oidcDiagnosticId", diagnosticID)
}
parsed.RawQuery = query.Encode()
http.Redirect(w, r, parsed.String(), http.StatusSeeOther)
return
}
if reason != "" || diagnosticID != "" {
writeErrorWithDetails(w, status, message, map[string]any{
"reason": reason, "diagnosticId": diagnosticID,
}, code)
return
}
writeError(w, status, message, code)
}
func newOIDCDiagnosticID() string {
var value [12]byte
if _, err := rand.Read(value[:]); err == nil {
return base64.RawURLEncoding.EncodeToString(value[:])
}
return base64.RawURLEncoding.EncodeToString([]byte(time.Now().UTC().Format(time.RFC3339Nano)))
}
func (s *Server) writeOIDCCallbackProjectionError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, store.ErrOIDCUserNotProvisioned):
s.writeOIDCCallbackError(w, r, http.StatusForbidden, "该账号尚未开通 EasyAI Gateway", errorCodeGatewayUserNotProvisioned)
case errors.Is(err, store.ErrOIDCUserDisabled):
s.writeOIDCCallbackError(w, r, http.StatusForbidden, "该 Gateway 账号已停用,请联系管理员", errorCodeGatewayUserDisabled)
case errors.Is(err, store.ErrOIDCTenantUnavailable):
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "Gateway 租户尚未就绪,请联系管理员", errorCodeGatewayTenantUnavailable)
default:
s.logger.ErrorContext(r.Context(), "resolve OIDC gateway user during callback failed", "error", err)
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "Gateway 账号初始化失败,请稍后重试", errorCodeGatewayProvisioningFailed)
}
}
func (s *Server) recordOIDCSessionAudit(r *http.Request, user *auth.User) {
if s.store == nil || user == nil {
return
}
audit, err := s.store.RecordAuditLog(r.Context(), store.AuditLogInput{
Category: "identity", Action: "identity.oidc_session.created",
ActorGatewayUserID: user.GatewayUserID, ActorUsername: user.Username, ActorSource: "oidc", ActorRoles: user.Roles,
TargetType: "gateway_user", TargetID: user.GatewayUserID,
TargetGatewayUserID: user.GatewayUserID, TargetGatewayTenantID: user.GatewayTenantID,
RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512),
Metadata: map[string]any{"sessionMode": "public-client-pkce-bff"},
})
if err != nil {
s.logger.WarnContext(r.Context(), "record OIDC session audit failed", "error", err)
return
}
s.logger.InfoContext(r.Context(), "OIDC server session created", "gatewayUserId", user.GatewayUserID, "auditId", audit.ID)
}
func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runtime := s.currentIdentityRuntime()
if runtime == nil || runtime.Sessions == nil {
continue
}
if _, err := runtime.Sessions.Cleanup(ctx); err != nil {
s.logger.WarnContext(ctx, "cleanup expired OIDC sessions failed", "error", err)
}
}
}
}()
}
func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
runtime := s.currentIdentityRuntime()
if runtime == nil || !runtime.BrowserEnabled || hasExplicitCredential(r) {
next.ServeHTTP(w, r)
return
}
if _, err := r.Cookie(auth.OIDCSessionCookieName); err != nil {
next.ServeHTTP(w, r)
return
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin != "" && !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) || origin == "" && !isSafeHTTPMethod(r.Method) {
writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF)
return
}
next.ServeHTTP(w, r)
})
}
func isSafeHTTPMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
default:
return false
}
}
func hasExplicitCredential(r *http.Request) bool {
return extractBearerCredential(r.Header.Get("Authorization")) != "" ||
strings.TrimSpace(r.Header.Get("x-comfy-api-key")) != "" ||
strings.TrimSpace(r.Header.Get("x-goog-api-key")) != "" ||
strings.HasPrefix(strings.TrimSpace(r.URL.Query().Get("key")), "sk-")
}
func extractBearerCredential(value string) string {
fields := strings.Fields(value)
if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") {
return fields[1]
}
return ""
}