fix: v6 code review P0 auth/IDOR fixes + frontend regression patches
Backend fixes: - auth_handler: P0 认证逻辑修复 - ratelimit: 限速中间件增强 + 新增单元测试 - auth_service: 认证服务逻辑完善 + 新增测试 - server: server 配置增强 + 新增测试 - handler_test: 新增 handler 层集成测试 - auth_bootstrap_test: bootstrap 路径测试 Frontend patches: - LoginPage/RegisterPage: CSRF + 表单交互修复 - BootstrapAdminPage: 引导流程修复 - DevicesPage: 设备管理页修复 - auth/social-accounts/users/webhooks services: 类型修正 - csrf.ts: CSRF token 处理修正 - E2E 脚本: CDP smoke + auth e2e 增强 Docs: - FULL_CODE_REVIEW_REPORT_2026-04-20 - report-v6 执行计划 - REAL_PROJECT_STATUS 更新 - .gitignore: 新增 .gocache-*/config.yaml 排除 验证: go build/vet 0错误, go test 42/42 PASS, 0 FAIL
This commit is contained in:
@@ -2,10 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -19,11 +22,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
userInfoCachePrefix = "auth_user_info:"
|
||||
tokenBlacklistPrefix = "auth_token_blacklist:"
|
||||
defaultUserCacheTTL = 15 * time.Minute
|
||||
defaultBlacklistTTL = time.Hour
|
||||
defaultPasswordMinLen = 8
|
||||
userInfoCachePrefix = "auth_user_info:"
|
||||
tokenBlacklistPrefix = "auth_token_blacklist:"
|
||||
totpChallengePrefix = "auth_totp_challenge:"
|
||||
defaultUserCacheTTL = 15 * time.Minute
|
||||
defaultBlacklistTTL = time.Hour
|
||||
defaultTOTPChallengeTTL = 5 * time.Minute
|
||||
defaultPasswordMinLen = 8
|
||||
refreshTokenRetryGrace = 10 * time.Second
|
||||
)
|
||||
|
||||
type userRepositoryInterface interface {
|
||||
@@ -122,13 +128,18 @@ type LoginResponse struct {
|
||||
ExpiresIn int64 `json:"expires_in,omitempty"`
|
||||
User *UserInfo `json:"user,omitempty"`
|
||||
// RequiresTOTP 指示登录需要额外的TOTP验证(当设备未信任时)
|
||||
RequiresTOTP bool `json:"requires_totp,omitempty"`
|
||||
RequiresTOTP bool `json:"requires_totp,omitempty"`
|
||||
// TempToken 临时令牌,用于TOTP验证阶段(短生命周期,不可用于常规API)
|
||||
TempToken string `json:"temp_token,omitempty"`
|
||||
// UserID 当RequiresTOTP为true时返回,用于后续TOTP验证
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type totpLoginChallenge struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
type LogoutRequest struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
@@ -432,6 +443,38 @@ func (s *AuthService) blacklistTokenClaims(ctx context.Context, token string, va
|
||||
return s.cache.Set(ctx, tokenBlacklistPrefix+claims.JTI, true, ttl, ttl)
|
||||
}
|
||||
|
||||
func (s *AuthService) getTokenBlacklistValue(ctx context.Context, jti string) (interface{}, bool) {
|
||||
if s == nil || s.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
jti = strings.TrimSpace(jti)
|
||||
if jti == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return s.cache.Get(ctx, tokenBlacklistPrefix+jti)
|
||||
}
|
||||
|
||||
func tokenBlacklistRevokedAt(value interface{}) (time.Time, bool) {
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
return time.Unix(0, v), true
|
||||
case int:
|
||||
return time.Unix(0, int64(v)), true
|
||||
case float64:
|
||||
return time.Unix(0, int64(v)), true
|
||||
case string:
|
||||
timestamp, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(0, timestamp), true
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) recordLoginAnomaly(ctx context.Context, userID *int64, ip, location, deviceFingerprint string, success bool) {
|
||||
if s == nil || s.anomalyDetector == nil || userID == nil {
|
||||
return
|
||||
@@ -601,6 +644,93 @@ func userInfoFromCacheValue(value interface{}) (*UserInfo, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func generateTemporaryLoginToken() (string, error) {
|
||||
payload := make([]byte, 32)
|
||||
if _, err := cryptorand.Read(payload); err != nil {
|
||||
return "", fmt.Errorf("generate temporary login token failed: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
func totpLoginChallengeFromCacheValue(value interface{}) (*totpLoginChallenge, bool) {
|
||||
switch typed := value.(type) {
|
||||
case *totpLoginChallenge:
|
||||
return typed, true
|
||||
case totpLoginChallenge:
|
||||
challenge := typed
|
||||
return &challenge, true
|
||||
case map[string]interface{}:
|
||||
payload, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var challenge totpLoginChallenge
|
||||
if err := json.Unmarshal(payload, &challenge); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &challenge, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTOTPLoginChallenge(ctx context.Context, user *domain.User, deviceID string) (string, error) {
|
||||
if s == nil || s.cache == nil {
|
||||
return "", errors.New("temporary login token storage is unavailable")
|
||||
}
|
||||
if user == nil {
|
||||
return "", errors.New("temporary login token requires a user")
|
||||
}
|
||||
|
||||
tempToken, err := generateTemporaryLoginToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
challenge := &totpLoginChallenge{
|
||||
UserID: user.ID,
|
||||
DeviceID: strings.TrimSpace(deviceID),
|
||||
}
|
||||
if err := s.cache.Set(
|
||||
ctx,
|
||||
totpChallengePrefix+tempToken,
|
||||
challenge,
|
||||
defaultTOTPChallengeTTL,
|
||||
defaultTOTPChallengeTTL,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("temporary login token storage failed: %w", err)
|
||||
}
|
||||
|
||||
return tempToken, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) validateTOTPLoginChallenge(ctx context.Context, userID int64, deviceID, tempToken string) error {
|
||||
if s == nil || s.cache == nil {
|
||||
return errors.New("temporary login token storage is unavailable")
|
||||
}
|
||||
|
||||
normalizedToken := strings.TrimSpace(tempToken)
|
||||
if normalizedToken == "" {
|
||||
return errors.New("temporary login token is required")
|
||||
}
|
||||
|
||||
value, ok := s.cache.Get(ctx, totpChallengePrefix+normalizedToken)
|
||||
if !ok {
|
||||
return errors.New("temporary login token is invalid or expired")
|
||||
}
|
||||
|
||||
challenge, ok := totpLoginChallengeFromCacheValue(value)
|
||||
if !ok || challenge == nil {
|
||||
return errors.New("temporary login token is invalid or expired")
|
||||
}
|
||||
|
||||
if challenge.UserID != userID || strings.TrimSpace(challenge.DeviceID) != strings.TrimSpace(deviceID) {
|
||||
return errors.New("temporary login token does not match the requested login flow")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*UserInfo, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("注册请求不能为空")
|
||||
@@ -628,6 +758,9 @@ func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*User
|
||||
if err := s.verifyPhoneRegistration(ctx, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.emailActivationSvc != nil && req.Email != "" {
|
||||
return s.RegisterWithActivation(ctx, req)
|
||||
}
|
||||
|
||||
exists, err := s.userRepo.ExistsByUsername(ctx, req.Username)
|
||||
if err != nil {
|
||||
@@ -759,11 +892,17 @@ func (s *AuthService) Login(ctx context.Context, req *LoginRequest, ip string) (
|
||||
|
||||
// P0-07 安全修复:检查是否需要TOTP验证(用户启用了TOTP且设备未信任)
|
||||
if s.isTOTPRequiredForLogin(ctx, user, req.DeviceID) {
|
||||
tempToken, err := s.issueTOTPLoginChallenge(ctx, user, req.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 返回RequiresTOTP指示前端需要完成TOTP验证
|
||||
// 前端应调用 /auth/login/totp-verify 接口完成验证
|
||||
return &LoginResponse{
|
||||
RequiresTOTP: true,
|
||||
UserID: user.ID,
|
||||
TempToken: tempToken,
|
||||
UserID: user.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -808,10 +947,13 @@ func (s *AuthService) isTOTPRequiredForLogin(ctx context.Context, user *domain.U
|
||||
// VerifyTOTPAfterPasswordLogin 完成密码登录后的TOTP验证
|
||||
// 当用户启用了TOTP但设备未信任时,密码登录会返回RequiresTOTP=true
|
||||
// 前端需要调用此接口完成TOTP验证以获取令牌
|
||||
func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID int64, totpCode, deviceID string) (*LoginResponse, error) {
|
||||
func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID int64, totpCode, deviceID, tempToken string) (*LoginResponse, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("auth service is not initialized")
|
||||
}
|
||||
if err := s.validateTOTPLoginChallenge(ctx, userID, deviceID, tempToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -827,6 +969,10 @@ func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.cache.Delete(ctx, totpChallengePrefix+strings.TrimSpace(tempToken)); err != nil {
|
||||
return nil, fmt.Errorf("temporary login token cleanup failed: %w", err)
|
||||
}
|
||||
|
||||
// TOTP验证成功,返回完整登录响应
|
||||
return s.generateLoginResponseWithoutRemember(ctx, user)
|
||||
}
|
||||
@@ -841,8 +987,11 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*L
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.IsTokenBlacklisted(ctx, claims.JTI) {
|
||||
return nil, errors.New("refresh token has been revoked")
|
||||
if blacklistValue, blacklisted := s.getTokenBlacklistValue(ctx, claims.JTI); blacklisted {
|
||||
revokedAt, hasRevocationTimestamp := tokenBlacklistRevokedAt(blacklistValue)
|
||||
if !hasRevocationTimestamp || time.Since(revokedAt) > refreshTokenRetryGrace {
|
||||
return nil, errors.New("refresh token has been revoked")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||
@@ -861,7 +1010,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*L
|
||||
if claims.ExpiresAt != nil {
|
||||
remaining := time.Until(claims.ExpiresAt.Time)
|
||||
if remaining > 0 {
|
||||
if err := s.cache.Set(ctx, blacklistKey, "1", 5*time.Minute, remaining); err != nil {
|
||||
if err := s.cache.Set(ctx, blacklistKey, time.Now().UnixNano(), 5*time.Minute, remaining); err != nil {
|
||||
return nil, fmt.Errorf("token revocation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user