feat: 接入 OIDC 公共客户端登录链路
由 Gateway 服务端完成 PKCE 换码、ID Token 校验、JIT 用户解析和 Session 建立。新增登录、回调、退出与本地会话删除接口,并移除旧的 Access Token Cookie 桥接接口。
This commit is contained in:
@@ -1,90 +1,291 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
errorCodeOIDCSessionTooLarge = "OIDC_SESSION_TOKEN_TOO_LARGE"
|
||||
errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED"
|
||||
maxOIDCSessionCookieTokenBytes = 3800
|
||||
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"
|
||||
)
|
||||
|
||||
// createOIDCBrowserSession godoc
|
||||
// @Summary 建立 OIDC 浏览器会话
|
||||
// @Description 验证 Auth Center Access Token 后写入 HttpOnly 会话 Cookie;不会签发 Gateway JWT。
|
||||
// startOIDCLogin godoc
|
||||
// @Summary 开始 OIDC 公共客户端登录
|
||||
// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
|
||||
// @Tags auth
|
||||
// @Success 204
|
||||
// @Param returnTo query string false "登录后返回的站内相对路径"
|
||||
// @Success 303
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/oidc/session [post]
|
||||
func (s *Server) createOIDCBrowserSession(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.OIDCEnabled || !s.cfg.OIDCBrowserSessionEnabled || s.auth == nil || s.auth.OIDCVerifier == nil {
|
||||
// @Router /api/v1/auth/oidc/login [get]
|
||||
func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.oidcBrowserSessionReady() {
|
||||
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
|
||||
return
|
||||
}
|
||||
raw := bearerToken(r.Header.Get("Authorization"))
|
||||
if raw == "" {
|
||||
writeError(w, http.StatusUnauthorized, "valid OIDC access token is required", errorCodeOIDCSessionInvalid)
|
||||
returnTo := strings.TrimSpace(r.URL.Query().Get("returnTo"))
|
||||
if returnTo == "" {
|
||||
returnTo = "/"
|
||||
}
|
||||
transaction, challenge, err := oidcsession.NewLoginTransaction(returnTo, time.Now())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
|
||||
return
|
||||
}
|
||||
if len(raw) > maxOIDCSessionCookieTokenBytes {
|
||||
writeError(w, http.StatusBadRequest, "OIDC access token is too large for browser session", errorCodeOIDCSessionTooLarge)
|
||||
encoded, err := s.oidcSessionCipher.EncodeLoginTransaction(transaction)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err)
|
||||
writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
|
||||
return
|
||||
}
|
||||
user, err := s.auth.AuthenticateOIDCAccessToken(r.Context(), raw)
|
||||
if err != nil || user == nil || !strings.EqualFold(strings.TrimSpace(user.Source), "oidc") {
|
||||
writeError(w, http.StatusUnauthorized, "valid OIDC access token is required", errorCodeOIDCSessionInvalid)
|
||||
authorizationURL, err := s.oidcClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, challenge)
|
||||
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: s.cfg.OIDCSessionCookieSecure, 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) {
|
||||
if !s.oidcBrowserSessionReady() {
|
||||
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
|
||||
return
|
||||
}
|
||||
s.clearOIDCLoginCookie(w)
|
||||
cookie, err := r.Cookie(oidcsession.LoginTransactionCookieName)
|
||||
if err != nil {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusBadRequest, "登录事务无效或已过期", errorCodeOIDCLoginInvalid)
|
||||
return
|
||||
}
|
||||
transaction, err := s.oidcSessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
|
||||
if err != nil || r.URL.Query().Get("state") == "" || r.URL.Query().Get("state") != transaction.State || r.URL.Query().Get("code") == "" {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusBadRequest, "登录事务校验失败,请重新登录", errorCodeOIDCLoginInvalid)
|
||||
return
|
||||
}
|
||||
tokens, err := s.oidcClient.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 := s.auth.AuthenticateOIDCAccessToken(r.Context(), tokens.AccessToken)
|
||||
if err != nil || identity == nil {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||
return
|
||||
}
|
||||
idSubject, err := s.auth.OIDCVerifier.VerifyIDToken(r.Context(), tokens.IDToken, s.cfg.OIDCClientID, transaction.Nonce)
|
||||
if err != nil || idSubject != identity.ID {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||
return
|
||||
}
|
||||
projection, err := s.resolveOIDCUserProjection(r.Context(), r, identity)
|
||||
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 := s.oidcSessions.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()
|
||||
if user.TokenExpiresAt.IsZero() || !user.TokenExpiresAt.After(now) {
|
||||
writeError(w, http.StatusUnauthorized, "OIDC access token has expired", errorCodeOIDCSessionInvalid)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/",
|
||||
MaxAge: s.cfg.OIDCSessionAbsoluteTTLSeconds, Expires: now.Add(time.Duration(s.cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second),
|
||||
HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
s.recordOIDCSessionAudit(r, projection.User)
|
||||
http.Redirect(w, r, s.oidcReturnLocation(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) {
|
||||
if !s.oidcBrowserSessionReady() {
|
||||
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
|
||||
return
|
||||
}
|
||||
maxAge := int(time.Until(user.TokenExpiresAt).Seconds())
|
||||
if maxAge < 1 {
|
||||
maxAge = 1
|
||||
var bundle oidcsession.TokenBundle
|
||||
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
|
||||
bundle, err = s.oidcSessions.Delete(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.OIDCSessionCookieName,
|
||||
Value: raw,
|
||||
Path: "/",
|
||||
Expires: user.TokenExpiresAt,
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.OIDCSessionCookieSecure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
s.clearOIDCSessionCookie(w)
|
||||
if bundle.RefreshToken != "" {
|
||||
if err := s.oidcClient.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 := s.oidcClient.EndSessionURL(r.Context(), "")
|
||||
if err != nil {
|
||||
location = s.cfg.OIDCPostLogoutRedirectURI
|
||||
}
|
||||
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) {
|
||||
if s.oidcSessions != nil {
|
||||
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
|
||||
if _, err := s.oidcSessions.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)
|
||||
}
|
||||
|
||||
// deleteOIDCBrowserSession godoc
|
||||
// @Summary 注销 OIDC 浏览器会话
|
||||
// @Tags auth
|
||||
// @Success 204
|
||||
// @Router /api/v1/auth/oidc/session [delete]
|
||||
func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, _ *http.Request) {
|
||||
func (s *Server) oidcBrowserSessionReady() bool {
|
||||
return s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled && s.auth != nil && s.auth.OIDCVerifier != nil &&
|
||||
s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil
|
||||
}
|
||||
|
||||
func (s *Server) clearOIDCLoginCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.OIDCSessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(1, 0),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.OIDCSessionCookieSecure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Name: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(),
|
||||
Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) oidcCallbackCookiePath() string {
|
||||
if parsed, err := url.Parse(strings.TrimSpace(s.cfg.OIDCRedirectURI)); err == nil && strings.HasPrefix(parsed.Path, "/") {
|
||||
return parsed.Path
|
||||
}
|
||||
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.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) oidcReturnLocation(returnTo string) string {
|
||||
if base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/"); base != "" {
|
||||
return base + returnTo
|
||||
}
|
||||
return returnTo
|
||||
}
|
||||
|
||||
func (s *Server) writeOIDCCallbackError(w http.ResponseWriter, r *http.Request, status int, message, code string) {
|
||||
base := strings.TrimRight(strings.TrimSpace(s.cfg.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)
|
||||
parsed.RawQuery = query.Encode()
|
||||
http.Redirect(w, r, parsed.String(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
writeError(w, status, message, code)
|
||||
}
|
||||
|
||||
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) {
|
||||
if s.oidcSessions == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := s.oidcSessions.Cleanup(ctx); err != nil {
|
||||
s.logger.WarnContext(ctx, "cleanup expired OIDC sessions failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
|
||||
@@ -106,14 +307,6 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func bearerToken(value string) string {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") {
|
||||
return fields[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isSafeHTTPMethod(method string) bool {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
|
||||
Reference in New Issue
Block a user