fix: 为失效 OIDC 事务提供安全重试

细分回调事务、状态和授权响应错误,提供脱敏诊断编号与显式重新登录入口,避免失效事务形成重试循环。
This commit is contained in:
2026-07-13 22:50:09 +08:00
parent 6e0a5fe397
commit 8ca68eb3cd
8 changed files with 314 additions and 15 deletions
+54 -3
View File
@@ -2,6 +2,8 @@ package httpapi
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"net/http"
"net/url"
@@ -21,6 +23,11 @@ const (
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
@@ -84,12 +91,20 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.clearOIDCLoginCookie(w)
cookie, err := r.Cookie(oidcsession.LoginTransactionCookieName)
if err != nil {
s.writeOIDCCallbackError(w, r, http.StatusBadRequest, "登录事务无效或已过期", errorCodeOIDCLoginInvalid)
s.writeOIDCLoginTransactionError(w, r, "登录事务无效或已过期", oidcLoginFailureCookieMissing)
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)
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 := s.oidcClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier)
@@ -224,17 +239,53 @@ func (s *Server) oidcReturnLocation(returnTo string) string {
}
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 := 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)
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):