公开 OAuth 登录/授权入口按会话限制 10 分钟内最多 20 个 state,堵住未授权 Redis 洪水。metric 持平 8。

Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":95,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126}
This commit is contained in:
ryan
2026-08-25 23:05:27 +08:00
parent cdc7474d7e
commit c9fc9c0eea
5 changed files with 52 additions and 4 deletions
+2 -1
View File
@@ -24,8 +24,9 @@ const (
const (
OAuthStateCacheKeyFormat = "oauth:state:%s"
OAuthStateCacheKeyExpiration = 10 * time.Minute
oauthStateLimitKeyFormat = "oauth:state-limit:%s"
oauthStateLimitMax = 20
)
// OAuth 授权用途常量
const (
OAuthPurposeLogin = "login"
+1
View File
@@ -19,4 +19,5 @@ const (
errAuthSourceDisabled = "认证源未启用"
errInvalidExternalAccountBindingID = "绑定记录 ID 无效"
ErrTokenAuthNotAllowed = "该端点不允许使用访问令牌进行身份验证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errOAuthStateRateLimited = "登录请求过于频繁,请稍后再试"
)
+29 -3
View File
@@ -5,6 +5,7 @@ package oauth
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
@@ -58,6 +59,10 @@ func GetLoginURL(c *gin.Context) {
userID := GetUserIDFromSession(session)
sessionHash := hashSessionToken(token)
if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
state := uuid.NewString()
payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{
@@ -70,11 +75,10 @@ func GetLoginURL(c *gin.Context) {
response.AbortInternal(c, err.Error())
return
}
if err := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
if err := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
response.AbortInternal(c, err.Error())
return
}
authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state)
if err != nil {
response.AbortBadRequest(c, err.Error())
@@ -98,6 +102,24 @@ func buildAuthorizeURL(ctx context.Context, source *model.AuthSource, state stri
return authConfig.AuthCodeURL(state), nil
}
func reserveOAuthStateSlot(ctx context.Context, sessionHash string) error {
if db.Redis == nil || sessionHash == "" {
return nil
}
key := db.PrefixedKey(fmt.Sprintf(oauthStateLimitKeyFormat, sessionHash))
n, err := db.Redis.Incr(ctx, key).Result()
if err != nil {
return err
}
if n == 1 {
_ = db.Redis.Expire(ctx, key, OAuthStateCacheKeyExpiration).Err()
}
if n > oauthStateLimitMax {
return errors.New(errOAuthStateRateLimited)
}
return nil
}
// Authorize 发起指定认证源授权
// @Summary 发起指定认证源授权
// @Description 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。
@@ -147,6 +169,10 @@ func Authorize(c *gin.Context) {
}
sessionHash := hashSessionToken(token)
if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
state := uuid.NewString()
payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{
@@ -159,7 +185,7 @@ func Authorize(c *gin.Context) {
response.AbortInternal(c, err.Error())
return
}
if err := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
if err := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
response.AbortInternal(c, err.Error())
return
}
+19
View File
@@ -94,6 +94,25 @@ func (m *mockRedisClient) Del(ctx context.Context, keys ...string) *redis.IntCmd
return cmd
}
func (m *mockRedisClient) Incr(ctx context.Context, key string) *redis.IntCmd {
cmd := redis.NewIntCmd(ctx)
n := int64(1)
if raw, ok := m.store[key]; ok {
fmt.Sscan(raw, &n)
n++
}
m.store[key] = fmt.Sprintf("%d", n)
cmd.SetVal(n)
return cmd
}
func (m *mockRedisClient) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd {
cmd := redis.NewBoolCmd(ctx)
_, ok := m.store[key]
cmd.SetVal(ok)
return cmd
}
func (m *mockRedisClient) Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd {
cmd := redis.NewScanCmd(ctx, nil, cursor, match, count)
var keys []string