fix(auth): make OIDC discovery resilient

This commit is contained in:
dushixiang committed 2026-08-23 21:33:28 +08:00
1 parent 7603906c36
commit 5210f91eef
6 files changed
+438 -60

No files matched your search

+32
View File
@@ -1,5 +1,10 @@
package config
import (
"fmt"
"strings"
)
// AppConfig 应用配置
type AppConfig struct {
JWT JWTConfig `json:"JWT"`
@@ -31,6 +36,33 @@ type OIDCConfig struct {
RedirectURL string `json:"RedirectURL"` // 回调URL
}
// Validate 校验启用 OIDC 所需的静态配置。
// Provider 是否可访问属于运行时状态,不应在应用启动阶段参与配置有效性判断。
func (c *OIDCConfig) Validate() error {
if c == nil || !c.Enabled {
return nil
}
missing := make([]string, 0, 4)
if strings.TrimSpace(c.Issuer) == "" {
missing = append(missing, "Issuer")
}
if strings.TrimSpace(c.ClientID) == "" {
missing = append(missing, "ClientID")
}
if strings.TrimSpace(c.ClientSecret) == "" {
missing = append(missing, "ClientSecret")
}
if strings.TrimSpace(c.RedirectURL) == "" {
missing = append(missing, "RedirectURL")
}
if len(missing) > 0 {
return fmt.Errorf("缺少必要配置: %s", strings.Join(missing, ", "))
}
return nil
}
// GitHubOAuthConfig GitHub OAuth认证配置
type GitHubOAuthConfig struct {
Enabled bool `json:"Enabled"` // 是否启用GitHub登录
+62
View File
@@ -0,0 +1,62 @@
package config
import (
"strings"
"testing"
)
func TestOIDCConfigValidate(t *testing.T) {
tests := []struct {
name string
config *OIDCConfig
wantMissing []string
}{
{
name: "nil config",
config: nil,
},
{
name: "disabled config",
config: &OIDCConfig{},
},
{
name: "complete config",
config: &OIDCConfig{
Enabled: true,
Issuer: "https://accounts.example.com",
ClientID: "client-id",
ClientSecret: "client-secret",
RedirectURL: "https://pika.example.com/admin/oidc/callback",
},
},
{
name: "reports every missing field",
config: &OIDCConfig{
Enabled: true,
Issuer: " ",
},
wantMissing: []string{"Issuer", "ClientID", "ClientSecret", "RedirectURL"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.config.Validate()
if len(test.wantMissing) == 0 {
if err != nil {
t.Fatalf("Validate() returned unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatal("Validate() returned nil, want missing field error")
}
for _, field := range test.wantMissing {
if !strings.Contains(err.Error(), field) {
t.Errorf("Validate() error %q does not mention %s", err, field)
}
}
})
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ func (r AccountHandler) GetAuthConfig(c *echo.Context) error {
// GetOIDCAuthURL 获取 OIDC 认证 URL
func (r AccountHandler) GetOIDCAuthURL(c *echo.Context) error {
authURL, err := r.accountService.GetOIDCAuthURL()
authURL, err := r.accountService.GetOIDCAuthURL(c.Request().Context())
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
+2 -2
View File
@@ -188,12 +188,12 @@ type OIDCAuthURL struct {
}
// GetOIDCAuthURL 获取 OIDC 认证 URL
func (s *AccountService) GetOIDCAuthURL() (*OIDCAuthURL, error) {
func (s *AccountService) GetOIDCAuthURL(ctx context.Context) (*OIDCAuthURL, error) {
if !s.oidcService.IsEnabled() {
return nil, errors.New("OIDC 未启用")
}
authURL, state, err := s.oidcService.GenerateAuthURL()
authURL, state, err := s.oidcService.GenerateAuthURL(ctx)
if err != nil {
return nil, err
}
+144 -57
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
@@ -14,13 +15,27 @@ import (
"golang.org/x/oauth2"
)
const (
oidcProviderInitTimeout = 10 * time.Second
oidcProviderRetryDelay = 5 * time.Second
)
type oidcProviderInitAttempt struct {
done chan struct{}
err error
}
// OIDCService OIDC 认证服务
type OIDCService struct {
logger *zap.Logger
config *config.OIDCConfig
provider *oidc.Provider
providerMu sync.Mutex
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
initAttempt *oidcProviderInitAttempt
lastInitErr error
retryAfter time.Time
stateMu sync.Mutex
stateStore map[string]time.Time // 简单的 state 存储(生产环境应使用 Redis 等)
}
@@ -29,55 +44,27 @@ func NewOIDCService(logger *zap.Logger, appConfig *config.AppConfig) *OIDCServic
if appConfig.OIDC == nil || !appConfig.OIDC.Enabled {
logger.Info("OIDC 认证未启用")
return &OIDCService{
logger: logger,
config: nil,
logger: logger,
stateStore: make(map[string]time.Time),
}
}
oidcConfig := appConfig.OIDC
// 验证配置
if oidcConfig.Issuer == "" || oidcConfig.ClientID == "" || oidcConfig.ClientSecret == "" {
logger.Error("OIDC 配置不完整,OIDC 认证将被禁用")
if err := appConfig.OIDC.Validate(); err != nil {
logger.Error("OIDC 配置不完整,OIDC 认证将被禁用", zap.Error(err))
return &OIDCService{
logger: logger,
config: nil,
logger: logger,
stateStore: make(map[string]time.Time),
}
}
ctx := context.Background()
// 初始化 OIDC Provider
provider, err := oidc.NewProvider(ctx, oidcConfig.Issuer)
if err != nil {
logger.Error("初始化 OIDC Provider 失败,OIDC 认证将被禁用", zap.Error(err))
return &OIDCService{
logger: logger,
config: nil,
}
}
// 配置 OAuth2
oauth2Config := oauth2.Config{
ClientID: oidcConfig.ClientID,
ClientSecret: oidcConfig.ClientSecret,
RedirectURL: oidcConfig.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
// 创建 ID Token 验证器
verifier := provider.Verifier(&oidc.Config{ClientID: oidcConfig.ClientID})
logger.Info("OIDC 服务初始化成功", zap.String("issuer", oidcConfig.Issuer))
// 配置在应用生命周期内只读,复制一份以避免调用方意外修改。
oidcConfig := *appConfig.OIDC
logger.Info("OIDC 配置已加载,Provider 将在首次登录时初始化", zap.String("issuer", oidcConfig.Issuer))
return &OIDCService{
logger: logger,
config: oidcConfig,
provider: provider,
oauth2Config: oauth2Config,
verifier: verifier,
stateStore: make(map[string]time.Time),
logger: logger,
config: &oidcConfig,
stateStore: make(map[string]time.Time),
}
}
@@ -87,11 +74,16 @@ func (s *OIDCService) IsEnabled() bool {
}
// GenerateAuthURL 生成认证 URL
func (s *OIDCService) GenerateAuthURL() (string, string, error) {
func (s *OIDCService) GenerateAuthURL(ctx context.Context) (string, string, error) {
if !s.IsEnabled() {
return "", "", errors.New("OIDC 未启用")
}
oauth2Config, _, err := s.runtimeConfig(ctx)
if err != nil {
return "", "", err
}
// 生成随机 state
state, err := s.generateState()
if err != nil {
@@ -99,12 +91,12 @@ func (s *OIDCService) GenerateAuthURL() (string, string, error) {
}
// 存储 state(有效期 10 分钟)
s.stateMu.Lock()
s.stateStore[state] = time.Now().Add(10 * time.Minute)
s.cleanExpiredStatesLocked()
s.stateMu.Unlock()
// 清理过期的 state
s.cleanExpiredStates()
authURL := s.oauth2Config.AuthCodeURL(state)
authURL := oauth2Config.AuthCodeURL(state)
return authURL, state, nil
}
@@ -114,16 +106,18 @@ func (s *OIDCService) ExchangeCode(ctx context.Context, code, state string) (str
return "", "", errors.New("OIDC 未启用")
}
// 验证 state
if !s.validateState(state) {
oauth2Config, verifier, err := s.runtimeConfig(ctx)
if err != nil {
return "", "", err
}
// state 仅允许消费一次,校验和删除必须是原子操作。
if !s.consumeState(state) {
return "", "", errors.New("无效的 state")
}
// 删除已使用的 state
delete(s.stateStore, state)
// 交换授权码
oauth2Token, err := s.oauth2Config.Exchange(ctx, code)
oauth2Token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
return "", "", fmt.Errorf("交换授权码失败: %w", err)
}
@@ -135,7 +129,7 @@ func (s *OIDCService) ExchangeCode(ctx context.Context, code, state string) (str
}
// 验证 ID Token
idToken, err := s.verifier.Verify(ctx, rawIDToken)
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return "", "", fmt.Errorf("验证 ID Token 失败: %w", err)
}
@@ -174,6 +168,95 @@ func (s *OIDCService) ExchangeCode(ctx context.Context, code, state string) (str
return username, nickname, nil
}
// runtimeConfig 返回可用于当前请求的 OIDC 运行时配置。
// 并发请求共享同一次 Discovery,等待方可以随请求上下文取消;失败后经过短暂退避再重试。
func (s *OIDCService) runtimeConfig(ctx context.Context) (oauth2.Config, *oidc.IDTokenVerifier, error) {
for {
if err := ctx.Err(); err != nil {
return oauth2.Config{}, nil, err
}
s.providerMu.Lock()
if s.verifier != nil {
oauth2Config, verifier := s.oauth2Config, s.verifier
s.providerMu.Unlock()
return oauth2Config, verifier, nil
}
if s.initAttempt == nil && s.lastInitErr != nil && time.Now().Before(s.retryAfter) {
err := s.lastInitErr
s.providerMu.Unlock()
return oauth2.Config{}, nil, err
}
attempt := s.initAttempt
if attempt == nil {
attempt = &oidcProviderInitAttempt{done: make(chan struct{})}
s.initAttempt = attempt
go s.initializeProvider(attempt)
}
s.providerMu.Unlock()
select {
case <-attempt.done:
if attempt.err != nil {
// 即使等待方被延迟调度到退避期之后,也共享它实际等待的这次失败结果。
return oauth2.Config{}, nil, attempt.err
}
// 初始化结果已发布,重新读取成功的运行时配置。
case <-ctx.Done():
return oauth2.Config{}, nil, ctx.Err()
}
}
}
// initializeProvider 在独立、受限时长的上下文中执行 Discovery,避免首个调用方取消时影响其他等待者。
func (s *OIDCService) initializeProvider(attempt *oidcProviderInitAttempt) {
discoveryCtx, cancel := context.WithTimeout(context.Background(), oidcProviderInitTimeout)
defer cancel()
provider, err := oidc.NewProvider(discoveryCtx, s.config.Issuer)
if err != nil {
err = fmt.Errorf("初始化 OIDC Provider 失败: %w", err)
}
var oauth2Config oauth2.Config
var verifier *oidc.IDTokenVerifier
if err == nil {
oauth2Config = oauth2.Config{
ClientID: s.config.ClientID,
ClientSecret: s.config.ClientSecret,
RedirectURL: s.config.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
verifier = provider.Verifier(&oidc.Config{ClientID: s.config.ClientID})
}
s.providerMu.Lock()
if err != nil {
s.lastInitErr = err
s.retryAfter = time.Now().Add(oidcProviderRetryDelay)
} else {
s.oauth2Config = oauth2Config
s.verifier = verifier
s.lastInitErr = nil
s.retryAfter = time.Time{}
}
attempt.err = err
s.initAttempt = nil
close(attempt.done)
s.providerMu.Unlock()
if err != nil {
s.logger.Warn("初始化 OIDC Provider 失败,退避后允许重试",
zap.Duration("retryAfter", oidcProviderRetryDelay),
zap.Error(err))
return
}
s.logger.Info("OIDC Provider 初始化成功", zap.String("issuer", s.config.Issuer))
}
// generateState 生成随机 state
func (s *OIDCService) generateState() (string, error) {
b := make([]byte, 32)
@@ -183,17 +266,21 @@ func (s *OIDCService) generateState() (string, error) {
return base64.URLEncoding.EncodeToString(b), nil
}
// validateState 验证 state
func (s *OIDCService) validateState(state string) bool {
// consumeState 验证并消费 state。
func (s *OIDCService) consumeState(state string) bool {
s.stateMu.Lock()
defer s.stateMu.Unlock()
expiresAt, exists := s.stateStore[state]
if !exists {
return false
}
delete(s.stateStore, state)
return time.Now().Before(expiresAt)
}
// cleanExpiredStates 清理过期的 state
func (s *OIDCService) cleanExpiredStates() {
// cleanExpiredStatesLocked 清理过期的 state,调用方必须持有 stateMu。
func (s *OIDCService) cleanExpiredStatesLocked() {
now := time.Now()
for state, expiresAt := range s.stateStore {
if now.After(expiresAt) {
+197
View File
@@ -0,0 +1,197 @@
package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/pika-monitor/pika/internal/config"
"go.uber.org/zap"
)
func TestOIDCProviderDiscoveryRetriesAfterFailure(t *testing.T) {
var attempts atomic.Int32
var provider *httptest.Server
provider = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/openid-configuration" {
http.NotFound(w, r)
return
}
if attempts.Add(1) == 1 {
http.Error(w, "provider is starting", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"issuer": provider.URL,
"authorization_endpoint": provider.URL + "/authorize",
"token_endpoint": provider.URL + "/token",
"jwks_uri": provider.URL + "/keys",
}); err != nil {
t.Errorf("encode discovery response: %v", err)
}
}))
t.Cleanup(provider.Close)
service := NewOIDCService(zap.NewNop(), &config.AppConfig{
OIDC: &config.OIDCConfig{
Enabled: true,
Issuer: provider.URL,
ClientID: "client-id",
ClientSecret: "client-secret",
RedirectURL: "https://pika.example.com/admin/oidc/callback",
},
})
if !service.IsEnabled() {
t.Fatal("OIDC should be shown as enabled before Provider Discovery")
}
if got := attempts.Load(); got != 0 {
t.Fatalf("NewOIDCService performed %d discovery requests, want 0", got)
}
if _, _, err := service.GenerateAuthURL(context.Background()); err == nil {
t.Fatal("first GenerateAuthURL() succeeded while provider returned an error")
}
if !service.IsEnabled() {
t.Fatal("transient Provider Discovery failure permanently disabled OIDC")
}
// 退避期内直接复用最近一次错误,不应再次请求 Provider。
if _, _, err := service.GenerateAuthURL(context.Background()); err == nil {
t.Fatal("GenerateAuthURL() succeeded during discovery failure backoff")
}
if got, want := attempts.Load(), int32(1); got != want {
t.Fatalf("discovery attempts during backoff = %d, want %d", got, want)
}
// 无需真实等待退避时间;过期后下一次请求应重新执行 Discovery。
service.providerMu.Lock()
service.retryAfter = time.Now().Add(-time.Second)
service.providerMu.Unlock()
authURL, state, err := service.GenerateAuthURL(context.Background())
if err != nil {
t.Fatalf("GenerateAuthURL() did not recover on retry: %v", err)
}
parsedURL, err := url.Parse(authURL)
if err != nil {
t.Fatalf("parse generated auth URL: %v", err)
}
if got, want := parsedURL.Path, "/authorize"; got != want {
t.Errorf("auth URL path = %q, want %q", got, want)
}
if got := parsedURL.Query().Get("state"); got != state {
t.Errorf("auth URL state = %q, want %q", got, state)
}
if got, want := attempts.Load(), int32(2); got != want {
t.Fatalf("discovery attempts = %d, want %d", got, want)
}
if _, _, err := service.GenerateAuthURL(context.Background()); err != nil {
t.Fatalf("GenerateAuthURL() with cached Provider failed: %v", err)
}
if got, want := attempts.Load(), int32(2); got != want {
t.Errorf("cached Provider caused another discovery request: got %d attempts, want %d", got, want)
}
}
func TestOIDCProviderDiscoverySharesFailureAndAllowsCanceledWaiters(t *testing.T) {
var attempts atomic.Int32
discoveryStarted := make(chan struct{})
releaseDiscovery := make(chan struct{})
var releaseOnce sync.Once
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
select {
case <-discoveryStarted:
default:
close(discoveryStarted)
}
<-releaseDiscovery
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
}))
t.Cleanup(provider.Close)
// 后注册以保证清理时先解除 Handler 阻塞,再关闭测试服务器。
t.Cleanup(func() { releaseOnce.Do(func() { close(releaseDiscovery) }) })
service := NewOIDCService(zap.NewNop(), &config.AppConfig{
OIDC: &config.OIDCConfig{
Enabled: true,
Issuer: provider.URL,
ClientID: "client-id",
ClientSecret: "client-secret",
RedirectURL: "https://pika.example.com/admin/oidc/callback",
},
})
const callers = 20
results := make(chan error, callers)
for range callers {
go func() {
_, _, err := service.GenerateAuthURL(context.Background())
results <- err
}()
}
select {
case <-discoveryStarted:
case <-time.After(2 * time.Second):
t.Fatal("Provider Discovery did not start")
}
// 等待中的调用方必须能随请求上下文取消,不能被互斥锁留住。
canceledResult := make(chan error, 1)
waiterCtx, cancelWaiter := context.WithCancel(context.Background())
go func() {
_, _, err := service.GenerateAuthURL(waiterCtx)
canceledResult <- err
}()
cancelWaiter()
select {
case err := <-canceledResult:
if !errors.Is(err, context.Canceled) {
t.Fatalf("canceled waiter error = %v, want context.Canceled", err)
}
case <-time.After(time.Second):
t.Fatal("canceled waiter remained blocked by Provider Discovery")
}
releaseOnce.Do(func() { close(releaseDiscovery) })
for range callers {
select {
case err := <-results:
if err == nil {
t.Error("GenerateAuthURL() succeeded while Provider Discovery failed")
}
case <-time.After(2 * time.Second):
t.Fatal("concurrent caller did not receive shared discovery result")
}
}
if got, want := attempts.Load(), int32(1); got != want {
t.Fatalf("concurrent discovery attempts = %d, want %d", got, want)
}
}
func TestOIDCServiceRejectsIncompleteConfig(t *testing.T) {
service := NewOIDCService(zap.NewNop(), &config.AppConfig{
OIDC: &config.OIDCConfig{
Enabled: true,
Issuer: "https://accounts.example.com",
},
})
if service.IsEnabled() {
t.Fatal("OIDC with incomplete static config should not be enabled")
}
}