feat: 接入 OIDC 公共客户端登录链路
由 Gateway 服务端完成 PKCE 换码、ID Token 校验、JIT 用户解析和 Session 建立。新增登录、回调、退出与本地会话删除接口,并移除旧的 Access Token Cookie 桥接接口。
This commit is contained in:
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
@@ -20,11 +22,29 @@ type Server struct {
|
||||
store *store.Store
|
||||
oidcUserResolver oidcUserResolver
|
||||
auth *auth.Authenticator
|
||||
oidcClient oidcPublicClient
|
||||
oidcSessions oidcSessionManager
|
||||
oidcSessionCipher *oidcsession.Cipher
|
||||
runner *runner.Service
|
||||
logger *slog.Logger
|
||||
geminiUploadSessions sync.Map
|
||||
}
|
||||
|
||||
type oidcPublicClient interface {
|
||||
AuthorizationURL(context.Context, string, string, string) (string, error)
|
||||
ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error)
|
||||
Refresh(context.Context, string) (auth.OIDCTokenResponse, error)
|
||||
RevokeRefreshToken(context.Context, string) error
|
||||
EndSessionURL(context.Context, string) (string, error)
|
||||
}
|
||||
|
||||
type oidcSessionManager interface {
|
||||
Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error)
|
||||
Resolve(context.Context, string) (*auth.User, error)
|
||||
Delete(context.Context, string) (oidcsession.TokenBundle, error)
|
||||
Cleanup(context.Context) (int64, error)
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Handler {
|
||||
return NewServerWithContext(context.Background(), cfg, db, logger)
|
||||
}
|
||||
@@ -55,10 +75,44 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
panic("invalid OIDC configuration: " + err.Error())
|
||||
}
|
||||
server.auth.OIDCVerifier = verifier
|
||||
if cfg.OIDCBrowserSessionEnabled {
|
||||
key, err := cfg.OIDCSessionEncryptionKeyBytes()
|
||||
if err != nil {
|
||||
panic("invalid OIDC session configuration: " + err.Error())
|
||||
}
|
||||
cipher, err := oidcsession.NewCipher(key)
|
||||
if err != nil {
|
||||
panic("invalid OIDC session configuration: " + err.Error())
|
||||
}
|
||||
client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{
|
||||
Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, RedirectURI: cfg.OIDCRedirectURI,
|
||||
PostLogoutRedirectURI: cfg.OIDCPostLogoutRedirectURI,
|
||||
Scopes: append([]string{"openid", "profile"}, cfg.OIDCRequiredScopes...),
|
||||
})
|
||||
if err != nil {
|
||||
panic("invalid OIDC public client configuration: " + err.Error())
|
||||
}
|
||||
sessions, err := oidcsession.NewService(db, cipher, verifier, client, oidcsession.Config{
|
||||
IdleTTL: time.Duration(cfg.OIDCSessionIdleTTLSeconds) * time.Second,
|
||||
AbsoluteTTL: time.Duration(cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second,
|
||||
RefreshBefore: time.Duration(cfg.OIDCSessionRefreshBeforeSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
panic("invalid OIDC session configuration: " + err.Error())
|
||||
}
|
||||
server.oidcClient = client
|
||||
server.oidcSessions = sessions
|
||||
server.oidcSessionCipher = cipher
|
||||
server.auth.OIDCSessionResolver = func(ctx context.Context, sessionID string) (*auth.User, error) {
|
||||
user, resolveErr := sessions.Resolve(ctx, sessionID)
|
||||
return user, oidcSessionRequestError(resolveErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
|
||||
server.runner.StartAsyncQueueWorker(ctx)
|
||||
server.startLocalTempAssetCleanup(ctx)
|
||||
server.startOIDCSessionCleanup(ctx)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", server.health)
|
||||
@@ -69,7 +123,9 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
|
||||
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
|
||||
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
|
||||
mux.HandleFunc("POST /api/v1/auth/oidc/session", server.createOIDCBrowserSession)
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/login", server.startOIDCLogin)
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
|
||||
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
|
||||
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
|
||||
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
|
||||
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
|
||||
@@ -217,6 +273,23 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
return server.recover(server.cors(server.protectOIDCSessionCookie(mux)))
|
||||
}
|
||||
|
||||
func oidcSessionRequestError(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, oidcsession.ErrSessionExpired):
|
||||
return auth.NewRequestAuthError(http.StatusUnauthorized, "OIDC_SESSION_EXPIRED", "登录会话已过期,请重新登录")
|
||||
case errors.Is(err, oidcsession.ErrSessionRefreshUnavailable):
|
||||
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_REFRESH_UNAVAILABLE", "认证中心暂时不可用,请稍后重试")
|
||||
case errors.Is(err, oidcsession.ErrSessionStoreUnavailable):
|
||||
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_STORE_UNAVAILABLE", "登录会话存储暂时不可用")
|
||||
case errors.Is(err, oidcsession.ErrGatewayUserDisabled):
|
||||
return auth.NewRequestAuthError(http.StatusForbidden, "GATEWAY_USER_DISABLED", "该 Gateway 账号已停用,请联系管理员")
|
||||
default:
|
||||
return auth.NewRequestAuthError(http.StatusUnauthorized, "OIDC_SESSION_INVALID", "登录会话无效,请重新登录")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) http.Handler {
|
||||
return s.requireUser(permission, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
|
||||
Reference in New Issue
Block a user