从 Active Revision 构造并验证 OIDC、BFF Session、Introspection 与 SSF Runtime,在数据库激活成功后原子替换内存引用。请求链路使用不可变快照,失败保留当前运行时,本地管理登录不受影响。\n\n验证:go test ./apps/api/...;go vet ./apps/api/...
392 lines
16 KiB
Go
392 lines
16 KiB
Go
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 Secret;Token 加密存入服务端 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 || isSafeHTTPMethod(r.Method) || 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) {
|
||
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 strings.TrimSpace(r.Header.Get("Authorization")) != "" ||
|
||
strings.TrimSpace(r.Header.Get("x-comfy-api-key")) != "" ||
|
||
strings.TrimSpace(r.Header.Get("x-goog-api-key")) != "" ||
|
||
strings.TrimSpace(r.URL.Query().Get("key")) != ""
|
||
}
|