package service import ( "context" "crypto/rand" "crypto/sha256" "encoding/binary" "encoding/hex" "errors" "fmt" "net/mail" "strconv" "strings" "time" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/authidentity" "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) var ( ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password") ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active") ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved") ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired") ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token") ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired") ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused") ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed") ErrEmailDomainRegistrationLimit = infraerrors.BadRequest( "EMAIL_DOMAIN_REGISTRATION_LIMIT", "this email domain cannot register another account; use a mainstream email or contact support to add the enterprise domain", ) ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required") ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code") ErrOAuthInvitationRequired = infraerrors.Forbidden("OAUTH_INVITATION_REQUIRED", "invitation code required to complete oauth registration") ErrCaptchaProviderConflict = infraerrors.ServiceUnavailable("CAPTCHA_PROVIDER_CONFLICT", "multiple captcha providers are enabled") ) // maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。 const maxTokenLength = 8192 // refreshTokenPrefix is the prefix for refresh tokens to distinguish them from access tokens. const refreshTokenPrefix = "rt_" // JWTClaims JWT载荷数据 type JWTClaims struct { UserID int64 `json:"user_id"` Email string `json:"email"` Role string `json:"role"` TokenVersion int64 `json:"token_version"` // Used to invalidate tokens on password change // SessionID 会话 ID(与 refresh token family 对应),用于单会话撤销与 step-up 授权绑定。 SessionID string `json:"sid,omitempty"` // BindingHash 会话指纹哈希(IP+UA),会话绑定开启时校验;空值表示旧 token(平滑升级)。 BindingHash string `json:"bnd,omitempty"` jwt.RegisteredClaims } // AuthService 认证服务 type AuthService struct { entClient *dbent.Client userRepo UserRepository redeemRepo RedeemCodeRepository refreshTokenCache RefreshTokenCache cfg *config.Config settingService *SettingService emailService *EmailService turnstileService *TurnstileService tencentCaptchaService *TencentCaptchaService aliyunCaptchaService *AliyunCaptchaService emailQueueService *EmailQueueService promoService *PromoService affiliateService *AffiliateService defaultSubAssigner DefaultSubscriptionAssigner userPlatformQuotaRepo UserPlatformQuotaRepository } type CaptchaProof struct { // TurnstileToken 承载 Cloudflare Turnstile token;阿里云验证码复用该字段承载 captchaVerifyParam TurnstileToken string TencentTicket string TencentRandstr string } type DefaultSubscriptionAssigner interface { AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) } type signupGrantPlan struct { Balance float64 Concurrency int Subscriptions []DefaultSubscriptionSetting PlatformQuotas map[string]*DefaultPlatformQuotaSetting } // NewAuthService 创建认证服务实例 func NewAuthService( entClient *dbent.Client, userRepo UserRepository, redeemRepo RedeemCodeRepository, refreshTokenCache RefreshTokenCache, cfg *config.Config, settingService *SettingService, emailService *EmailService, turnstileService *TurnstileService, emailQueueService *EmailQueueService, promoService *PromoService, defaultSubAssigner DefaultSubscriptionAssigner, affiliateService *AffiliateService, userPlatformQuotaRepo UserPlatformQuotaRepository, ) *AuthService { return &AuthService{ entClient: entClient, userRepo: userRepo, redeemRepo: redeemRepo, refreshTokenCache: refreshTokenCache, cfg: cfg, settingService: settingService, emailService: emailService, turnstileService: turnstileService, emailQueueService: emailQueueService, promoService: promoService, affiliateService: affiliateService, defaultSubAssigner: defaultSubAssigner, userPlatformQuotaRepo: userPlatformQuotaRepo, } } func (s *AuthService) EntClient() *dbent.Client { if s == nil { return nil } return s.entClient } func (s *AuthService) SetTencentCaptchaService(tencentCaptchaService *TencentCaptchaService) { s.tencentCaptchaService = tencentCaptchaService } func (s *AuthService) SetAliyunCaptchaService(aliyunCaptchaService *AliyunCaptchaService) { s.aliyunCaptchaService = aliyunCaptchaService } // Register 用户注册,返回token和用户 func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) { return s.RegisterWithVerification(ctx, email, password, "", "", "", "") } // RegisterWithVerification 用户注册(支持邮件验证、优惠码、邀请码和邀请返利码),返回token和用户。 func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode, affiliateCode string) (string, *User, error) { // 检查是否开放注册(默认关闭:settingService 未配置时不允许注册) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return "", nil, ErrRegDisabled } // 防止用户注册 LinuxDo OAuth 合成邮箱,避免第三方登录与本地账号发生碰撞。 if isReservedEmail(email) { return "", nil, ErrEmailReserved } // 检查是否需要邀请码 var invitationRedeemCode *RedeemCode if s.settingService != nil && s.settingService.IsInvitationCodeEnabled(ctx) { if invitationCode == "" { return "", nil, ErrInvitationCodeRequired } // 验证邀请码 redeemCode, err := s.redeemRepo.GetByCode(ctx, invitationCode) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Invalid invitation code: %s, error: %v", invitationCode, err) return "", nil, ErrInvitationCodeInvalid } // 检查类型和状态 if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() { logger.LegacyPrintf("service.auth", "[Auth] Invitation code invalid: type=%s, status=%s", redeemCode.Type, redeemCode.Status) return "", nil, ErrInvitationCodeInvalid } invitationRedeemCode = redeemCode } // 检查是否需要邮件验证 if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) { // 如果邮件验证已开启但邮件服务未配置,拒绝注册 // 这是一个配置错误,不应该允许绕过验证 if s.emailService == nil { logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verification enabled but email service not configured, rejecting registration") return "", nil, ErrServiceUnavailable } if verifyCode == "" { return "", nil, ErrEmailVerifyRequired } // 验证邮箱验证码 if err := s.emailService.VerifyCode(ctx, email, verifyCode); err != nil { return "", nil, fmt.Errorf("verify code: %w", err) } } // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化,防止单个收件箱批量派生注册) existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err) return "", nil, ErrServiceUnavailable } if existsEmail { return "", nil, ErrEmailExists } if err := s.validateRegistrationEmailQuota(ctx, email); err != nil { return "", nil, err } // 密码哈希 hashedPassword, err := s.HashPassword(password) if err != nil { return "", nil, fmt.Errorf("hash password: %w", err) } grantPlan := s.resolveSignupGrantPlan(ctx, "email") // 新用户默认 RPM(0 = 不限制)。注册时写入,后续作为用户级兜底。 var defaultRPMLimit int if s.settingService != nil { defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx) } // 创建用户 user := &User{ Email: email, PasswordHash: hashedPassword, Role: RoleUser, Balance: grantPlan.Balance, Concurrency: grantPlan.Concurrency, RPMLimit: defaultRPMLimit, Status: StatusActive, } if err := s.createUserAndClaimInvitation(ctx, user, invitationRedeemCode); err != nil { // 优先检查邮箱冲突错误(竞态条件下可能发生) switch { case errors.Is(err, ErrEmailExists): return "", nil, ErrEmailExists case errors.Is(err, ErrEmailDomainRegistrationLimit): return "", nil, ErrEmailDomainRegistrationLimit case errors.Is(err, ErrInvitationCodeInvalid): return "", nil, ErrInvitationCodeInvalid default: logger.LegacyPrintf("service.auth", "[Auth] Database error creating user: %v", err) return "", nil, ErrServiceUnavailable } } s.postAuthUserBootstrap(ctx, user, "email", true) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") // snapshot user × platform quota(fail-open) _ = s.snapshotPlatformQuotaDefaults(ctx, user.ID, &grantPlan) if s.affiliateService != nil { if _, err := s.affiliateService.EnsureUserAffiliate(ctx, user.ID); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to initialize affiliate profile for user %d: %v", user.ID, err) } if code := strings.TrimSpace(affiliateCode); code != "" { if err := s.affiliateService.BindInviterByCode(ctx, user.ID, code); err != nil { // 邀请返利码绑定失败不影响注册,只记录日志 logger.LegacyPrintf("service.auth", "[Auth] Failed to bind affiliate inviter for user %d: %v", user.ID, err) } } } // 邀请码占用已由 createUserAndClaimInvitation 在“用户创建 + 邀请码占用”的 // 同一个数据库事务内原子完成(一次性约束,见函数注释),此处不再单独标记。 // 应用优惠码(如果提供且功能已启用) if promoCode != "" && s.promoService != nil && s.settingService != nil && s.settingService.IsPromoCodeEnabled(ctx) { if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil { // 优惠码应用失败不影响注册,只记录日志 logger.LegacyPrintf("service.auth", "[Auth] Failed to apply promo code for user %d: %v", user.ID, err) } else { // 重新获取用户信息以获取更新后的余额 if updatedUser, err := s.userRepo.GetByID(ctx, user.ID); err == nil { user = updatedUser } } } // 生成token token, err := s.GenerateToken(ctx, user) if err != nil { return "", nil, fmt.Errorf("generate token: %w", err) } return token, user, nil } // SendVerifyCodeResult 发送验证码返回结果 type SendVerifyCodeResult struct { Countdown int `json:"countdown"` // 倒计时秒数 } // SendVerifyCode 发送邮箱验证码(同步方式) func (s *AuthService) SendVerifyCode(ctx context.Context, email string, locale ...string) error { // 检查是否开放注册(默认关闭) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return ErrRegDisabled } if isReservedEmail(email) { return ErrEmailReserved } // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化,防止单个收件箱批量派生注册) existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err) return ErrServiceUnavailable } if existsEmail { return ErrEmailExists } if err := s.validateRegistrationEmailQuota(ctx, email); err != nil { return err } // 发送验证码 if s.emailService == nil { return errors.New("email service not configured") } // 获取网站名称 siteName := "Sub2API" if s.settingService != nil { siteName = s.settingService.GetSiteName(ctx) } return s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale)) } // SendVerifyCodeAsync 异步发送邮箱验证码并返回倒计时 func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) { logger.LegacyPrintf("service.auth", "[Auth] SendVerifyCodeAsync called for email: %s", email) // 检查是否开放注册(默认关闭) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { logger.LegacyPrintf("service.auth", "%s", "[Auth] Registration is disabled") return nil, ErrRegDisabled } if isReservedEmail(email) { return nil, ErrEmailReserved } // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化;在发信前拦截,避免批量脚本消耗发信配额) existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err) return nil, ErrServiceUnavailable } if existsEmail { logger.LegacyPrintf("service.auth", "[Auth] Email already exists: %s", email) return nil, ErrEmailExists } if err := s.validateRegistrationEmailQuota(ctx, email); err != nil { return nil, err } // 检查邮件队列服务是否配置 if s.emailQueueService == nil { logger.LegacyPrintf("service.auth", "%s", "[Auth] Email queue service not configured") return nil, errors.New("email queue service not configured") } // 获取网站名称 siteName := "Sub2API" if s.settingService != nil { siteName = s.settingService.GetSiteName(ctx) } // 异步发送 logger.LegacyPrintf("service.auth", "[Auth] Enqueueing verify code for: %s", email) if err := s.emailQueueService.EnqueueVerifyCode(email, siteName, firstEmailLocale(locale)); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue: %v", err) return nil, fmt.Errorf("enqueue verify code: %w", err) } logger.LegacyPrintf("service.auth", "[Auth] Verify code enqueued successfully for: %s", email) return &SendVerifyCodeResult{ Countdown: 60, // 60秒倒计时 }, nil } // VerifyCaptchaForRegister 在注册场景下验证当前启用的验证码。 // 当邮箱验证开启且已提交验证码时,说明验证码发送阶段已完成验证码校验, // 此处跳过二次校验,避免一次性 token 在注册提交时重复使用导致误报失败。 func (s *AuthService) VerifyCaptchaForRegister(ctx context.Context, proof CaptchaProof, remoteIP, verifyCode string) error { if s.IsEmailVerifyEnabled(ctx) && strings.TrimSpace(verifyCode) != "" { logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verify flow detected, skip duplicate captcha check on register") return nil } return s.VerifyCaptcha(ctx, proof, remoteIP) } func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, remoteIP string) error { required := s.cfg != nil && s.cfg.Server.Mode == "release" && s.cfg.Turnstile.Required if s.settingService == nil { if required { return ErrTurnstileNotConfigured } return nil } providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx) if err != nil { logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings") return ErrServiceUnavailable } turnstileEnabled := providerConfig.TurnstileEnabled tencentEnabled := providerConfig.Tencent.Enabled aliyunEnabled := providerConfig.Aliyun.Enabled if captchaProvidersConflict(turnstileEnabled, tencentEnabled, aliyunEnabled) { return ErrCaptchaProviderConflict } if tencentEnabled { if s.tencentCaptchaService == nil { return ErrTencentCaptchaNotConfigured } return s.tencentCaptchaService.VerifyTicketWithConfig(ctx, providerConfig.Tencent, proof.TencentTicket, proof.TencentRandstr, remoteIP) } if aliyunEnabled { if s.aliyunCaptchaService == nil { return ErrAliyunCaptchaNotConfigured } return s.aliyunCaptchaService.VerifyParamWithConfig(ctx, providerConfig.Aliyun, proof.TurnstileToken) } if turnstileEnabled { if s.turnstileService == nil || strings.TrimSpace(providerConfig.TurnstileSecretKey) == "" { return ErrTurnstileNotConfigured } return s.turnstileService.VerifyTokenWithSecret(ctx, providerConfig.TurnstileSecretKey, proof.TurnstileToken, remoteIP) } if required { return ErrTurnstileNotConfigured } return nil } // captchaProvidersConflict 同一时间仅允许启用一家人机验证服务商 func captchaProvidersConflict(enabled ...bool) bool { count := 0 for _, e := range enabled { if e { count++ } } return count > 1 } // VerifyActionCaptchaIfEnabled 仅保护动作触发的扩展入口(OAuth 登录启动、passkey 登录), // 腾讯天御与阿里云验证码启用时拦截;不扩大 Cloudflare Turnstile 的既有覆盖范围。 func (s *AuthService) VerifyActionCaptchaIfEnabled(ctx context.Context, proof CaptchaProof, remoteIP string) error { if s == nil || s.settingService == nil { return ErrServiceUnavailable } providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx) if err != nil { logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings") return ErrServiceUnavailable } tencentEnabled := providerConfig.Tencent.Enabled aliyunEnabled := providerConfig.Aliyun.Enabled if !tencentEnabled && !aliyunEnabled { return nil } if captchaProvidersConflict(providerConfig.TurnstileEnabled, tencentEnabled, aliyunEnabled) { return ErrCaptchaProviderConflict } if aliyunEnabled { if s.aliyunCaptchaService == nil { return ErrAliyunCaptchaNotConfigured } return s.aliyunCaptchaService.VerifyParamWithConfig(ctx, providerConfig.Aliyun, proof.TurnstileToken) } if s.tencentCaptchaService == nil { return ErrTencentCaptchaNotConfigured } return s.tencentCaptchaService.VerifyTicketWithConfig( ctx, providerConfig.Tencent, proof.TencentTicket, proof.TencentRandstr, remoteIP, ) } // VerifyTurnstileForRegister 保留旧内部接口,生产 handler 使用 VerifyCaptchaForRegister。 func (s *AuthService) VerifyTurnstileForRegister(ctx context.Context, token, remoteIP, verifyCode string) error { return s.VerifyCaptchaForRegister(ctx, CaptchaProof{TurnstileToken: token}, remoteIP, verifyCode) } // VerifyTurnstile 保留旧内部接口,生产 handler 使用 VerifyCaptcha。 func (s *AuthService) VerifyTurnstile(ctx context.Context, token string, remoteIP string) error { return s.VerifyCaptcha(ctx, CaptchaProof{TurnstileToken: token}, remoteIP) } // IsTurnstileEnabled 检查是否启用Turnstile验证 func (s *AuthService) IsTurnstileEnabled(ctx context.Context) bool { if s.turnstileService == nil { return false } return s.turnstileService.IsEnabled(ctx) } // IsRegistrationEnabled 检查是否开放注册 func (s *AuthService) IsRegistrationEnabled(ctx context.Context) bool { if s.settingService == nil { return false // 安全默认:settingService 未配置时关闭注册 } return s.settingService.IsRegistrationEnabled(ctx) } // IsEmailVerifyEnabled 检查是否开启邮件验证 func (s *AuthService) IsEmailVerifyEnabled(ctx context.Context) bool { if s.settingService == nil { return false } return s.settingService.IsEmailVerifyEnabled(ctx) } // Login 用户登录,返回JWT token func (s *AuthService) Login(ctx context.Context, email, password string) (string, *User, error) { // 查找用户 user, err := s.userRepo.GetByEmail(ctx, email) if err != nil { if errors.Is(err, ErrUserNotFound) { return "", nil, ErrInvalidCredentials } // 记录数据库错误但不暴露给用户 logger.LegacyPrintf("service.auth", "[Auth] Database error during login: %v", err) return "", nil, ErrServiceUnavailable } // 验证密码 if !s.CheckPassword(password, user.PasswordHash) { return "", nil, ErrInvalidCredentials } // 检查用户状态 if !user.IsActive() { return "", nil, ErrUserNotActive } // 生成JWT token token, err := s.GenerateToken(ctx, user) if err != nil { return "", nil, fmt.Errorf("generate token: %w", err) } return token, user, nil } // LoginOrRegisterOAuth 用于第三方 OAuth/SSO 登录: // - 如果邮箱已存在:直接登录(不需要本地密码) // - 如果邮箱不存在:创建新用户并登录 // // 注意:该函数用于 LinuxDo OAuth 登录场景(不同于上游账号的 OAuth,例如 Claude/OpenAI/Gemini)。 // 为了满足现有数据库约束(需要密码哈希),新用户会生成随机密码并进行哈希保存。 func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username string) (string, *User, error) { email = strings.TrimSpace(email) if email == "" || len(email) > 255 { return "", nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email") } if _, err := mail.ParseAddress(email); err != nil { return "", nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email") } username = strings.TrimSpace(username) if len([]rune(username)) > 100 { username = string([]rune(username)[:100]) } user, err := s.userRepo.GetByEmail(ctx, email) if err != nil { if errors.Is(err, ErrUserNotFound) { // OAuth 首次登录视为注册(fail-close:settingService 未配置时不允许注册) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return "", nil, ErrRegDisabled } randomPassword, err := randomHexString(32) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to generate random password for oauth signup: %v", err) return "", nil, ErrServiceUnavailable } hashedPassword, err := s.HashPassword(randomPassword) if err != nil { return "", nil, fmt.Errorf("hash password: %w", err) } signupSource := inferLegacySignupSource(email) grantPlan := s.resolveSignupGrantPlan(ctx, signupSource) var defaultRPMLimit int if s.settingService != nil { defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx) } newUser := &User{ Email: email, Username: username, PasswordHash: hashedPassword, Role: RoleUser, Balance: grantPlan.Balance, Concurrency: grantPlan.Concurrency, RPMLimit: defaultRPMLimit, Status: StatusActive, SignupSource: signupSource, } if err := s.userRepo.Create(ctx, newUser); err != nil { if errors.Is(err, ErrEmailExists) { // 并发场景:GetByEmail 与 Create 之间用户被创建。 user, err = s.userRepo.GetByEmail(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error getting user after conflict: %v", err) return "", nil, ErrServiceUnavailable } } else { logger.LegacyPrintf("service.auth", "[Auth] Database error creating oauth user: %v", err) return "", nil, ErrServiceUnavailable } } else { user = newUser s.postAuthUserBootstrap(ctx, user, signupSource, false) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") // snapshot user × platform quota(fail-open) _ = s.snapshotPlatformQuotaDefaults(ctx, user.ID, &grantPlan) } } else { logger.LegacyPrintf("service.auth", "[Auth] Database error during oauth login: %v", err) return "", nil, ErrServiceUnavailable } } if !user.IsActive() { return "", nil, ErrUserNotActive } // 尽力补全:当用户名为空时,使用第三方返回的用户名回填。 if user.Username == "" && username != "" { user.Username = username if err := s.userRepo.Update(ctx, user, UserUpdateFields{Username: true}); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after oauth login: %v", err) } } token, err := s.GenerateToken(ctx, user) if err != nil { return "", nil, fmt.Errorf("generate token: %w", err) } return token, user, nil } // canBypassRegistrationDisabledForOAuth 在钉钉企业模式(internal_only)且 // dingtalk_connect_bypass_registration=true 时,允许跳过全局 registration_enabled 检查。 func (s *AuthService) canBypassRegistrationDisabledForOAuth(ctx context.Context, signupSource string) bool { if signupSource != "dingtalk" { return false } cfg, err := s.settingService.GetDingTalkConnectOAuthConfig(ctx) if err != nil || !cfg.Enabled || !cfg.BypassRegistration { return false } return cfg.CorpRestrictionPolicy == "internal_only" } // LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。 // 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token。 // invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。 // affiliateCode 用于邀请返利绑定,仅在新用户注册时使用。 // signupSource 标识来源渠道("dingtalk"/"linuxdo"/"wechat"/"oidc" 等),仅用于豁免检查。 func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode, signupSource string) (*TokenPair, *User, error) { return s.loginOrRegisterOAuthWithTokenPair(ctx, email, username, invitationCode, affiliateCode, "", signupSource) } // LoginOrRegisterOAuthWithTokenPairAndPromoCode behaves like // LoginOrRegisterOAuthWithTokenPair and applies promoCode only when a new user // is created. func (s *AuthService) LoginOrRegisterOAuthWithTokenPairAndPromoCode(ctx context.Context, email, username, invitationCode, affiliateCode, promoCode, signupSource string) (*TokenPair, *User, error) { return s.loginOrRegisterOAuthWithTokenPair(ctx, email, username, invitationCode, affiliateCode, promoCode, signupSource) } func (s *AuthService) loginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode, promoCode, signupSource string) (*TokenPair, *User, error) { // 检查 refreshTokenCache 是否可用 if s.refreshTokenCache == nil { return nil, nil, errors.New("refresh token cache not configured") } email = strings.TrimSpace(email) if email == "" || len(email) > 255 { return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email") } if _, err := mail.ParseAddress(email); err != nil { return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email") } username = strings.TrimSpace(username) if len([]rune(username)) > 100 { username = string([]rune(username)[:100]) } user, err := s.userRepo.GetByEmail(ctx, email) created := false if err != nil { if errors.Is(err, ErrUserNotFound) { // OAuth 首次登录视为注册 if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) { return nil, nil, ErrRegDisabled } // 检查是否需要邀请码 var invitationRedeemCode *RedeemCode if s.settingService != nil && s.settingService.IsInvitationCodeEnabled(ctx) { if invitationCode == "" { return nil, nil, ErrOAuthInvitationRequired } redeemCode, err := s.redeemRepo.GetByCode(ctx, invitationCode) if err != nil { return nil, nil, ErrInvitationCodeInvalid } if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() { return nil, nil, ErrInvitationCodeInvalid } invitationRedeemCode = redeemCode } randomPassword, err := randomHexString(32) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to generate random password for oauth signup: %v", err) return nil, nil, ErrServiceUnavailable } hashedPassword, err := s.HashPassword(randomPassword) if err != nil { return nil, nil, fmt.Errorf("hash password: %w", err) } // 优先用 caller 显式传入的 signupSource(如 "dingtalk" / "linuxdo" / "oidc" / "wechat"), // 否则才按邮箱后缀推断——避免有真实邮箱的 OAuth 用户被推断为 "email" 渠道,导致渠道授权错读。 if strings.TrimSpace(signupSource) == "" { signupSource = inferLegacySignupSource(email) } grantPlan := s.resolveSignupGrantPlan(ctx, signupSource) var defaultRPMLimit int if s.settingService != nil { defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx) } newUser := &User{ Email: email, Username: username, PasswordHash: hashedPassword, Role: RoleUser, Balance: grantPlan.Balance, Concurrency: grantPlan.Concurrency, RPMLimit: defaultRPMLimit, Status: StatusActive, SignupSource: signupSource, } if s.entClient != nil && invitationRedeemCode != nil { tx, err := s.entClient.Tx(ctx) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to begin transaction for oauth registration: %v", err) return nil, nil, ErrServiceUnavailable } defer func() { _ = tx.Rollback() }() txCtx := dbent.NewTxContext(ctx, tx) if err := s.userRepo.Create(txCtx, newUser); err != nil { if errors.Is(err, ErrEmailExists) { user, err = s.userRepo.GetByEmail(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error getting user after conflict: %v", err) return nil, nil, ErrServiceUnavailable } } else { logger.LegacyPrintf("service.auth", "[Auth] Database error creating oauth user: %v", err) return nil, nil, ErrServiceUnavailable } } else { if err := s.redeemRepo.Use(txCtx, invitationRedeemCode.ID, newUser.ID); err != nil { return nil, nil, ErrInvitationCodeInvalid } if err := tx.Commit(); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to commit oauth registration transaction: %v", err) return nil, nil, ErrServiceUnavailable } user = newUser created = true s.postAuthUserBootstrap(ctx, user, signupSource, false) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") // snapshot user × platform quota(fail-open) _ = s.snapshotPlatformQuotaDefaults(ctx, user.ID, &grantPlan) s.bindOAuthAffiliate(ctx, user.ID, affiliateCode) } } else { if err := s.userRepo.Create(ctx, newUser); err != nil { if errors.Is(err, ErrEmailExists) { user, err = s.userRepo.GetByEmail(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error getting user after conflict: %v", err) return nil, nil, ErrServiceUnavailable } } else { logger.LegacyPrintf("service.auth", "[Auth] Database error creating oauth user: %v", err) return nil, nil, ErrServiceUnavailable } } else { user = newUser created = true s.postAuthUserBootstrap(ctx, user, signupSource, false) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") // snapshot user × platform quota(fail-open) _ = s.snapshotPlatformQuotaDefaults(ctx, user.ID, &grantPlan) s.bindOAuthAffiliate(ctx, user.ID, affiliateCode) if invitationRedeemCode != nil { if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil { return nil, nil, ErrInvitationCodeInvalid } } } } } else { logger.LegacyPrintf("service.auth", "[Auth] Database error during oauth login: %v", err) return nil, nil, ErrServiceUnavailable } } if !user.IsActive() { return nil, nil, ErrUserNotActive } if user.Username == "" && username != "" { user.Username = username if err := s.userRepo.Update(ctx, user, UserUpdateFields{Username: true}); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after oauth login: %v", err) } } if created { user = s.applyOAuthSignupPromoCode(ctx, user, promoCode) } tokenPair, err := s.GenerateTokenPair(ctx, user, "") if err != nil { return nil, nil, fmt.Errorf("generate token pair: %w", err) } return tokenPair, user, nil } func (s *AuthService) ApplyOAuthSignupPromoCode(ctx context.Context, userID int64, promoCode string) { if userID <= 0 { return } s.applyOAuthSignupPromoCode(ctx, &User{ID: userID}, promoCode) } func (s *AuthService) applyOAuthSignupPromoCode(ctx context.Context, user *User, promoCode string) *User { promoCode = strings.TrimSpace(promoCode) if user == nil || user.ID <= 0 || promoCode == "" || s.promoService == nil || s.settingService == nil || !s.settingService.IsPromoCodeEnabled(ctx) { return user } if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to apply promo code for oauth user %d: %v", user.ID, err) return user } if updatedUser, err := s.userRepo.GetByID(ctx, user.ID); err == nil { return updatedUser } return user } func (s *AuthService) assignSubscriptions(ctx context.Context, userID int64, items []DefaultSubscriptionSetting, notes string) { if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 { return } for _, item := range items { if _, _, err := s.defaultSubAssigner.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{ UserID: userID, GroupID: item.GroupID, ValidityDays: item.ValidityDays, Notes: notes, }); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to assign default subscription: user_id=%d group_id=%d err=%v", userID, item.GroupID, err) } } } func (s *AuthService) resolveSignupGrantPlan(ctx context.Context, signupSource string) signupGrantPlan { plan := signupGrantPlan{} if s != nil && s.cfg != nil { plan.Balance = s.cfg.Default.UserBalance plan.Concurrency = s.cfg.Default.UserConcurrency } if s == nil || s.settingService == nil { return plan } plan.Balance = s.settingService.GetDefaultBalance(ctx) plan.Concurrency = s.settingService.GetDefaultConcurrency(ctx) plan.Subscriptions = s.settingService.GetDefaultSubscriptions(ctx) // ============ 全局 quota 装载(必须在 ResolveAuthSourceGrantSettings 之前) ============ // 无论 auth source 是否 enabled,全局层都要先装载,确保 !enabled 早退路径也携带全局 quota。 if quotas, err := s.settingService.GetDefaultPlatformQuotas(ctx); err == nil { plan.PlatformQuotas = quotas } else { logger.LegacyPrintf("service.auth", "[Auth] Warning: load default platform quotas failed: %v (fail-open)", err) } // ============================================================================================ resolved, enabled, err := s.settingService.ResolveAuthSourceGrantSettings(ctx, signupSource, false) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to load auth source signup defaults for %s: %v", signupSource, err) return plan } if !enabled { return plan // plan.PlatformQuotas 已含全局层 } plan.Balance = resolved.Balance plan.Concurrency = resolved.Concurrency plan.Subscriptions = resolved.Subscriptions // ============ auth source quota merge(仅在 enabled 分支内) ============ asQuotas := s.settingService.GetAuthSourcePlatformQuotas(ctx, signupSource) if plan.PlatformQuotas != nil { for platform, patch := range asQuotas { if dst := plan.PlatformQuotas[platform]; dst != nil { mergePlatformQuotaDefaults(dst, patch) } } } // ============================================================================== return plan } func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource string) (ProviderDefaultGrantSettings, bool) { if defaults == nil { return ProviderDefaultGrantSettings{}, false } switch strings.ToLower(strings.TrimSpace(signupSource)) { case "email": return defaults.Email, true case "linuxdo": return defaults.LinuxDo, true case "oidc": return defaults.OIDC, true case "wechat": return defaults.WeChat, true case "github": return defaults.GitHub, true case "google": return defaults.Google, true case "dingtalk": return defaults.DingTalk, true default: return ProviderDefaultGrantSettings{}, false } } // bindOAuthAffiliate initializes the affiliate profile and binds the inviter // for an OAuth-registered user. Failures are logged but never block registration. func (s *AuthService) bindOAuthAffiliate(ctx context.Context, userID int64, affiliateCode string) { if s.affiliateService == nil || userID <= 0 { return } if _, err := s.affiliateService.EnsureUserAffiliate(ctx, userID); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to initialize affiliate profile for user %d: %v", userID, err) } if code := strings.TrimSpace(affiliateCode); code != "" { if err := s.affiliateService.BindInviterByCode(ctx, userID, code); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to bind affiliate inviter for user %d: %v", userID, err) } } } func (s *AuthService) postAuthUserBootstrap(ctx context.Context, user *User, signupSource string, touchLogin bool) { if user == nil || user.ID <= 0 { return } if strings.TrimSpace(signupSource) == "" { signupSource = "email" } s.updateUserSignupSource(ctx, user.ID, signupSource) if touchLogin { s.touchUserLogin(ctx, user.ID) } } func (s *AuthService) updateUserSignupSource(ctx context.Context, userID int64, signupSource string) { if s == nil || s.entClient == nil || userID <= 0 { return } if strings.TrimSpace(signupSource) == "" { return } if err := s.entClient.User.UpdateOneID(userID). SetSignupSource(signupSource). Exec(ctx); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to update signup source: user_id=%d source=%s err=%v", userID, signupSource, err) } } func (s *AuthService) touchUserLogin(ctx context.Context, userID int64) { if s == nil || s.entClient == nil || userID <= 0 { return } now := time.Now().UTC() if err := s.entClient.User.UpdateOneID(userID). SetLastLoginAt(now). SetLastActiveAt(now). Exec(ctx); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to touch login timestamps: user_id=%d err=%v", userID, err) } } func (s *AuthService) backfillEmailIdentityOnSuccessfulLogin(ctx context.Context, user *User) { if s == nil || user == nil || user.ID <= 0 { return } identity, created := s.ensureEmailAuthIdentity(ctx, user, "auth_service_login_backfill") if s.shouldApplyEmailFirstBindDefaults(ctx, user.ID, identity, created) { if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, user.ID, "email"); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to apply email first bind defaults: user_id=%d err=%v", user.ID, err) } } } func (s *AuthService) shouldApplyEmailFirstBindDefaults( ctx context.Context, userID int64, identity *dbent.AuthIdentity, created bool, ) bool { source := emailAuthIdentitySource(identity.Metadata) if source == "auth_service_login_backfill" { return false } if created { return true } if s == nil || s.entClient == nil || userID <= 0 || identity == nil || identity.UserID != userID { return false } if source != "auth_service_dual_write" { return false } hasGrant, err := s.hasProviderGrantRecord(ctx, userID, "email", "first_bind") if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to inspect email first bind grant state: user_id=%d err=%v", userID, err) return false } return !hasGrant } func emailAuthIdentitySource(metadata map[string]any) string { if len(metadata) == 0 { return "" } raw, ok := metadata["source"] if !ok { return "" } return strings.TrimSpace(fmt.Sprint(raw)) } func (s *AuthService) hasProviderGrantRecord( ctx context.Context, userID int64, providerType string, grantReason string, ) (bool, error) { if s == nil || s.entClient == nil || userID <= 0 { return false, nil } rows, err := s.entClient.QueryContext( ctx, `SELECT 1 FROM user_provider_default_grants WHERE user_id = $1 AND provider_type = $2 AND grant_reason = $3 LIMIT 1`, userID, strings.TrimSpace(providerType), strings.TrimSpace(grantReason), ) if err != nil { return false, err } defer func() { _ = rows.Close() }() return rows.Next(), rows.Err() } func (s *AuthService) ensureEmailAuthIdentity(ctx context.Context, user *User, source string) (*dbent.AuthIdentity, bool) { if s == nil || s.entClient == nil || user == nil || user.ID <= 0 { return nil, false } email := strings.ToLower(strings.TrimSpace(user.Email)) if email == "" || isReservedEmail(email) { return nil, false } if strings.TrimSpace(source) == "" { source = "auth_service_dual_write" } client := s.entClient if tx := dbent.TxFromContext(ctx); tx != nil { client = tx.Client() } buildQuery := func() *dbent.AuthIdentityQuery { return client.AuthIdentity.Query().Where( authidentity.ProviderTypeEQ("email"), authidentity.ProviderKeyEQ("email"), authidentity.ProviderSubjectEQ(email), ) } existed, err := buildQuery().Exist(ctx) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to inspect email auth identity: user_id=%d email=%s err=%v", user.ID, email, err) return nil, false } if !existed { if err = client.AuthIdentity.Create(). SetUserID(user.ID). SetProviderType("email"). SetProviderKey("email"). SetProviderSubject(email). SetVerifiedAt(time.Now().UTC()). SetMetadata(map[string]any{ "source": strings.TrimSpace(source), }). OnConflictColumns( authidentity.FieldProviderType, authidentity.FieldProviderKey, authidentity.FieldProviderSubject, ). DoNothing(). Exec(ctx); err != nil { if isSQLNoRowsError(err) { return nil, false } } if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to ensure email auth identity: user_id=%d email=%s err=%v", user.ID, email, err) return nil, false } } identity, err := buildQuery().Only(ctx) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to reload email auth identity: user_id=%d email=%s err=%v", user.ID, email, err) return nil, false } if identity.UserID != user.ID { logger.LegacyPrintf("service.auth", "[Auth] Email auth identity ownership mismatch: user_id=%d email=%s owner_id=%d", user.ID, email, identity.UserID) return nil, false } return identity, !existed } func inferLegacySignupSource(email string) string { normalized := strings.ToLower(strings.TrimSpace(email)) switch { case strings.HasSuffix(normalized, DingTalkConnectSyntheticEmailDomain): return "dingtalk" case strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain): return "linuxdo" case strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain): return "oidc" case strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain): return "wechat" default: return "email" } } func (s *AuthService) validateRegistrationEmailPolicy(ctx context.Context, email string) error { if s.settingService == nil { return nil } whitelist := s.settingService.GetRegistrationEmailSuffixWhitelist(ctx) if !IsRegistrationEmailSuffixAllowed(email, whitelist) { return buildEmailSuffixNotAllowedError(whitelist) } return nil } // validateRegistrationEmailQuota 保留白名单为空时的全放行行为;配置白名单后, // 非白名单域名默认直接拒绝(严格白名单模式);仅当域名限量注册开关开启时, // 非白名单域名每个最多允许一个账户。 func (s *AuthService) validateRegistrationEmailQuota(ctx context.Context, email string) error { if s.settingService == nil { return nil } whitelist := s.settingService.GetRegistrationEmailSuffixWhitelist(ctx) if !IsRegistrationEmailSuffixLimited(email, whitelist) { return nil } if !s.settingService.IsRegistrationEmailDomainQuotaEnabled(ctx) { return buildEmailSuffixNotAllowedError(whitelist) } domain := RegistrationEmailDomain(email) if domain == "" { return buildEmailSuffixNotAllowedError(whitelist) } quotaRepo, ok := s.userRepo.(RegistrationEmailDomainRepository) if !ok { // 生产装配必须提供原子仓储能力;没有数据库的 unit 测试桩保留旧路径, // 避免无关测试被注册专用依赖干扰。 if s.entClient != nil { return ErrServiceUnavailable } return nil } count, err := quotaRepo.CountUsersByEmailDomain(ctx, domain) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to count registration email domain %s: %v", domain, err) return ErrServiceUnavailable } if count > 0 { return ErrEmailDomainRegistrationLimit } return nil } func (s *AuthService) createUserWithRegistrationEmailGuard(ctx context.Context, user *User) error { if s == nil || s.userRepo == nil { return ErrServiceUnavailable } whitelist := []string{} if s.settingService != nil { whitelist = s.settingService.GetRegistrationEmailSuffixWhitelist(ctx) } domain := RegistrationEmailDomain(user.Email) if !IsRegistrationEmailSuffixLimited(user.Email, whitelist) { return s.userRepo.CreateWithEmailAliasGuard(ctx, user) } // 开关关闭时非白名单域名在校验阶段已被拒绝;此处兜底防御设置竞态变更。 if s.settingService == nil || !s.settingService.IsRegistrationEmailDomainQuotaEnabled(ctx) { return buildEmailSuffixNotAllowedError(whitelist) } if domain == "" { return buildEmailSuffixNotAllowedError(whitelist) } quotaRepo, ok := s.userRepo.(RegistrationEmailDomainRepository) if !ok { if s.entClient != nil { return ErrServiceUnavailable } return s.userRepo.CreateWithEmailAliasGuard(ctx, user) } return quotaRepo.CreateWithEmailAliasGuardAndDomainLimit(ctx, user, domain) } // createUserAndClaimInvitation 原子化完成“用户创建 + 邀请码占用”。 // // 背景:邀请码属于一次性凭证,必须保证“一个邀请码最多注册一个账号”。旧实现先检查 // CanUse()、再创建用户、最后才 redeemRepo.Use()(且失败仅记日志),检查与消耗分离且 // 不在同一事务,并发注册可在同一邀请码上同时通过检查并各自创建账号(TOCTOU 竞态)。 // // 本实现把两者放入同一个数据库事务: // - 占用走 redeemRepo.Use 的条件更新(WHERE status='unused',乐观锁); // - 并发下只有一个事务能占用成功,其余事务回滚——既不产生多余账号,也不让码被烧掉; // - 事务回滚同时撤销用户创建,避免“账号已建、码被占用”的中间态。 // // 无邀请码时保持原单次创建路径(不开事务);entClient 缺失的异常配置下退化为顺序执行, // 并发正确性仍由 Use 的条件更新兜底(可能产生孤儿用户,但不会放行第二个注册)。 func (s *AuthService) createUserAndClaimInvitation(ctx context.Context, user *User, invitation *RedeemCode) error { commitUser := func(execCtx context.Context) error { if err := s.createUserWithRegistrationEmailGuard(execCtx, user); err != nil { return err } if invitation == nil { return nil } // createUserWithRegistrationEmailGuard 会回填 user.ID(applyUserEntityToService), // 直接以其原子占用邀请码;占用失败即整体回滚(含用户创建,见 user_repo.create // 对外部事务的复用)。 if err := s.redeemRepo.Use(execCtx, invitation.ID, user.ID); err != nil { // 并发下唯一的合法失败路径:另一个注册已占用该码 logger.LegacyPrintf("service.auth", "[Auth] Rejected registration: invitation code %s already claimed (user_id=%d err=%v)", invitation.Code, user.ID, err) return ErrInvitationCodeInvalid } return nil } if invitation == nil { return commitUser(ctx) } if s.entClient == nil { return commitUser(ctx) } tx, err := s.entClient.Tx(ctx) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to start registration transaction: %v", err) return ErrServiceUnavailable } defer func() { _ = tx.Rollback() }() execCtx := dbent.NewTxContext(ctx, tx) if err := commitUser(execCtx); err != nil { return err } if err := tx.Commit(); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to commit registration transaction: %v", err) return ErrServiceUnavailable } return nil } func buildEmailSuffixNotAllowedError(whitelist []string) error { if len(whitelist) == 0 { return ErrEmailSuffixNotAllowed } allowed := strings.Join(whitelist, ", ") return infraerrors.BadRequest( "EMAIL_SUFFIX_NOT_ALLOWED", fmt.Sprintf("email suffix is not allowed, allowed suffixes: %s", allowed), ).WithMetadata(map[string]string{ "allowed_suffixes": strings.Join(whitelist, ","), "allowed_suffix_count": strconv.Itoa(len(whitelist)), }) } // ValidateToken 验证JWT token并返回用户声明 func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) { // 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。 if len(tokenString) > maxTokenLength { return nil, ErrTokenTooLarge } // 使用解析器并限制可接受的签名算法,防止算法混淆。 parser := jwt.NewParser(jwt.WithValidMethods([]string{ jwt.SigningMethodHS256.Name, jwt.SigningMethodHS384.Name, jwt.SigningMethodHS512.Name, })) // 保留默认 claims 校验(exp/nbf),避免放行过期或未生效的 token。 token, err := parser.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) { // 验证签名方法 if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(s.cfg.JWT.Secret), nil }) if err != nil { if errors.Is(err, jwt.ErrTokenExpired) { // token 过期但仍返回 claims(用于 RefreshToken 等场景) // jwt-go 在解析时即使遇到过期错误,token.Claims 仍会被填充 if claims, ok := token.Claims.(*JWTClaims); ok { return claims, ErrTokenExpired } return nil, ErrTokenExpired } return nil, ErrInvalidToken } if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { return claims, nil } return nil, ErrInvalidToken } func randomHexString(byteLength int) (string, error) { if byteLength <= 0 { byteLength = 16 } buf := make([]byte, byteLength) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func isReservedEmail(email string) bool { normalized := strings.ToLower(strings.TrimSpace(email)) return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) || strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain) || strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain) || strings.HasSuffix(normalized, DingTalkConnectSyntheticEmailDomain) } // GenerateToken 生成JWT access token // 使用新的access_token_expire_minutes配置项(如果配置了),否则回退到expire_hour。 // 会话指纹(IP/UA)从 ctx 中提取(由 HTTP 入口中间件注入),缺失时生成不带绑定的 token。 func (s *AuthService) GenerateToken(ctx context.Context, user *User) (string, error) { sessionID, err := randomHexString(8) if err != nil { return "", fmt.Errorf("generate session id: %w", err) } return s.generateAccessToken(user, sessionID, sessionBindingHashFromContext(ctx)) } // generateAccessToken 生成带会话 ID 与绑定指纹的 access token。 func (s *AuthService) generateAccessToken(user *User, sessionID, bindingHash string) (string, error) { now := time.Now() var expiresAt time.Time if s.cfg.JWT.AccessTokenExpireMinutes > 0 { expiresAt = now.Add(time.Duration(s.cfg.JWT.AccessTokenExpireMinutes) * time.Minute) } else { // 向后兼容:使用旧的expire_hour配置 expiresAt = now.Add(time.Duration(s.cfg.JWT.ExpireHour) * time.Hour) } claims := &JWTClaims{ UserID: user.ID, Email: user.Email, Role: user.Role, TokenVersion: resolvedTokenVersion(user), SessionID: sessionID, BindingHash: bindingHash, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expiresAt), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(s.cfg.JWT.Secret)) if err != nil { return "", fmt.Errorf("sign token: %w", err) } return tokenString, nil } // GetAccessTokenExpiresIn 返回Access Token的有效期(秒) // 用于前端设置刷新定时器 func (s *AuthService) GetAccessTokenExpiresIn() int { if s.cfg.JWT.AccessTokenExpireMinutes > 0 { return s.cfg.JWT.AccessTokenExpireMinutes * 60 } return s.cfg.JWT.ExpireHour * 3600 } // HashPassword 使用bcrypt加密密码 func (s *AuthService) HashPassword(password string) (string, error) { hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } return string(hashedBytes), nil } // CheckPassword 验证密码是否匹配 func (s *AuthService) CheckPassword(password, hashedPassword string) bool { err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) return err == nil } // RefreshToken 刷新token func (s *AuthService) RefreshToken(ctx context.Context, oldTokenString string) (string, error) { // 验证旧token(即使过期也允许,用于刷新) claims, err := s.ValidateToken(oldTokenString) if err != nil && !errors.Is(err, ErrTokenExpired) { return "", err } // 获取最新的用户信息 user, err := s.userRepo.GetByID(ctx, claims.UserID) if err != nil { if errors.Is(err, ErrUserNotFound) { return "", ErrInvalidToken } logger.LegacyPrintf("service.auth", "[Auth] Database error refreshing token: %v", err) return "", ErrServiceUnavailable } // 检查用户状态 if !user.IsActive() { return "", ErrUserNotActive } // Security: Check TokenVersion to prevent refreshing revoked tokens // This ensures tokens issued before a password change cannot be refreshed if claims.TokenVersion != resolvedTokenVersion(user) { return "", ErrTokenRevoked } // 会话绑定检查:指纹变化的旧 token 不允许换发新 token。 if s.settingService != nil && s.settingService.IsSessionBindingEnabled(ctx) && claims.BindingHash != "" { if current := sessionBindingHashFromContext(ctx); current != "" && current != claims.BindingHash { _ = s.RevokeSessionFamily(ctx, claims.SessionID) return "", ErrSessionBindingMismatch } } // 生成新token return s.GenerateToken(ctx, user) } // IsPasswordResetEnabled 检查是否启用密码重置功能 // 要求:必须同时开启邮件验证且 SMTP 配置正确 func (s *AuthService) IsPasswordResetEnabled(ctx context.Context) bool { if s.settingService == nil { return false } // Must have email verification enabled and SMTP configured if !s.settingService.IsEmailVerifyEnabled(ctx) { return false } return s.settingService.IsPasswordResetEnabled(ctx) } // preparePasswordReset validates the password reset request and returns necessary data // Returns (siteName, resetURL, shouldProceed) // shouldProceed is false when we should silently return success (to prevent enumeration) func (s *AuthService) preparePasswordReset(ctx context.Context, email, frontendBaseURL string) (string, string, bool) { // Check if user exists (but don't reveal this to the caller) user, err := s.userRepo.GetByEmail(ctx, email) if err != nil { if errors.Is(err, ErrUserNotFound) { // Security: Log but don't reveal that user doesn't exist logger.LegacyPrintf("service.auth", "[Auth] Password reset requested for non-existent email: %s", email) return "", "", false } logger.LegacyPrintf("service.auth", "[Auth] Database error checking email for password reset: %v", err) return "", "", false } // Check if user is active if !user.IsActive() { logger.LegacyPrintf("service.auth", "[Auth] Password reset requested for inactive user: %s", email) return "", "", false } // Get site name siteName := "Sub2API" if s.settingService != nil { siteName = s.settingService.GetSiteName(ctx) } // Build reset URL base resetURL := fmt.Sprintf("%s/reset-password", strings.TrimSuffix(frontendBaseURL, "/")) return siteName, resetURL, true } // RequestPasswordReset 请求密码重置(同步发送) // Security: Returns the same response regardless of whether the email exists (prevent user enumeration) func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendBaseURL string, locale ...string) error { if !s.IsPasswordResetEnabled(ctx) { return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") } if s.emailService == nil { return ErrServiceUnavailable } siteName, resetURL, shouldProceed := s.preparePasswordReset(ctx, email, frontendBaseURL) if !shouldProceed { return nil // Silent success to prevent enumeration } if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL, firstEmailLocale(locale)); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to send password reset email to %s: %v", email, err) return nil // Silent success to prevent enumeration } logger.LegacyPrintf("service.auth", "[Auth] Password reset email sent to: %s", email) return nil } // RequestPasswordResetAsync 异步请求密码重置(队列发送) // Security: Returns the same response regardless of whether the email exists (prevent user enumeration) func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, frontendBaseURL string, locale ...string) error { if !s.IsPasswordResetEnabled(ctx) { return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") } if s.emailQueueService == nil { return ErrServiceUnavailable } siteName, resetURL, shouldProceed := s.preparePasswordReset(ctx, email, frontendBaseURL) if !shouldProceed { return nil // Silent success to prevent enumeration } if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL, firstEmailLocale(locale)); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue password reset email for %s: %v", email, err) return nil // Silent success to prevent enumeration } logger.LegacyPrintf("service.auth", "[Auth] Password reset email enqueued for: %s", email) return nil } // ResetPassword 重置密码 // Security: Increments TokenVersion to invalidate all existing JWT tokens func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPassword string) error { // Check if password reset is enabled if !s.IsPasswordResetEnabled(ctx) { return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") } if s.emailService == nil { return ErrServiceUnavailable } // Verify and consume the reset token (one-time use) if err := s.emailService.ConsumePasswordResetToken(ctx, email, token); err != nil { return err } // Get user user, err := s.userRepo.GetByEmail(ctx, email) if err != nil { if errors.Is(err, ErrUserNotFound) { return ErrInvalidResetToken // Token was valid but user was deleted } logger.LegacyPrintf("service.auth", "[Auth] Database error getting user for password reset: %v", err) return ErrServiceUnavailable } // Check if user is active if !user.IsActive() { return ErrUserNotActive } // Hash new password hashedPassword, err := s.HashPassword(newPassword) if err != nil { return fmt.Errorf("hash password: %w", err) } // Update password and increment TokenVersion user.PasswordHash = hashedPassword user.TokenVersion++ // Invalidate all existing tokens // TokenVersion 无对应数据库列(见 resolvedTokenVersion:由 email+password_hash 指纹推导), // 写回 password_hash 本身即可让旧 token 失效。 if err := s.userRepo.Update(ctx, user, UserUpdateFields{PasswordHash: true}); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error updating password for user %d: %v", user.ID, err) return ErrServiceUnavailable } // Also revoke all refresh tokens for this user if err := s.RevokeAllUserSessions(ctx, user.ID); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to revoke refresh tokens for user %d: %v", user.ID, err) // Don't return error - password was already changed successfully } logger.LegacyPrintf("service.auth", "[Auth] Password reset successful for user: %s", email) return nil } // ==================== Refresh Token Methods ==================== // TokenPair 包含Access Token和Refresh Token type TokenPair struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` // Access Token有效期(秒) } // TokenPairWithUser extends TokenPair with user role for backend mode checks type TokenPairWithUser struct { TokenPair UserRole string } // GenerateTokenPair 生成Access Token和Refresh Token对 // familyID: 可选的Token家族ID,用于Token轮转时保持家族关系 func (s *AuthService) GenerateTokenPair(ctx context.Context, user *User, familyID string) (*TokenPair, error) { // 检查 refreshTokenCache 是否可用 if s.refreshTokenCache == nil { return nil, errors.New("refresh token cache not configured") } // 提前确定家族ID:作为 access token 的会话ID(sid),保证同一会话的 // access/refresh token 可以互相关联(单会话撤销、step-up 授权绑定)。 if familyID == "" { familyBytes := make([]byte, 16) if _, err := rand.Read(familyBytes); err != nil { return nil, fmt.Errorf("generate family id: %w", err) } familyID = hex.EncodeToString(familyBytes) } // 生成Access Token(携带会话ID与绑定指纹) accessToken, err := s.generateAccessToken(user, familyID, sessionBindingHashFromContext(ctx)) if err != nil { return nil, fmt.Errorf("generate access token: %w", err) } // 生成Refresh Token refreshToken, err := s.generateRefreshToken(ctx, user, familyID) if err != nil { return nil, fmt.Errorf("generate refresh token: %w", err) } return &TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: s.GetAccessTokenExpiresIn(), }, nil } // generateRefreshToken 生成并存储Refresh Token func (s *AuthService) generateRefreshToken(ctx context.Context, user *User, familyID string) (string, error) { // 生成随机Token tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { return "", fmt.Errorf("generate random bytes: %w", err) } rawToken := refreshTokenPrefix + hex.EncodeToString(tokenBytes) // 计算Token哈希(存储哈希而非原始Token) tokenHash := hashToken(rawToken) // 如果没有提供familyID,生成新的 if familyID == "" { familyBytes := make([]byte, 16) if _, err := rand.Read(familyBytes); err != nil { return "", fmt.Errorf("generate family id: %w", err) } familyID = hex.EncodeToString(familyBytes) } now := time.Now() ttl := time.Duration(s.cfg.JWT.RefreshTokenExpireDays) * 24 * time.Hour data := &RefreshTokenData{ UserID: user.ID, TokenVersion: resolvedTokenVersion(user), FamilyID: familyID, BindingHash: sessionBindingHashFromContext(ctx), CreatedAt: now, ExpiresAt: now.Add(ttl), } // 存储Token数据 if err := s.refreshTokenCache.StoreRefreshToken(ctx, tokenHash, data, ttl); err != nil { return "", fmt.Errorf("store refresh token: %w", err) } // 添加到用户Token集合 if err := s.refreshTokenCache.AddToUserTokenSet(ctx, user.ID, tokenHash, ttl); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to add token to user set: %v", err) // 不影响主流程 } // 添加到家族Token集合 if err := s.refreshTokenCache.AddToFamilyTokenSet(ctx, familyID, tokenHash, ttl); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to add token to family set: %v", err) // 不影响主流程 } return rawToken, nil } // RefreshTokenPair 使用Refresh Token刷新Token对 // 实现Token轮转:每次刷新都会生成新的Refresh Token,旧Token立即失效 func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string) (*TokenPairWithUser, error) { // 检查 refreshTokenCache 是否可用 if s.refreshTokenCache == nil { return nil, ErrRefreshTokenInvalid } // 验证Token格式 if !strings.HasPrefix(refreshToken, refreshTokenPrefix) { return nil, ErrRefreshTokenInvalid } tokenHash := hashToken(refreshToken) // 获取Token数据 data, err := s.refreshTokenCache.GetRefreshToken(ctx, tokenHash) if err != nil { if errors.Is(err, ErrRefreshTokenNotFound) { // Token不存在,可能是已被使用(Token轮转)或已过期 logger.LegacyPrintf("service.auth", "[Auth] Refresh token not found, possible reuse attack") return nil, ErrRefreshTokenInvalid } logger.LegacyPrintf("service.auth", "[Auth] Error getting refresh token: %v", err) return nil, ErrServiceUnavailable } // 检查Token是否过期 if time.Now().After(data.ExpiresAt) { // 删除过期Token _ = s.refreshTokenCache.DeleteRefreshToken(ctx, tokenHash) return nil, ErrRefreshTokenExpired } // 获取用户信息 user, err := s.userRepo.GetByID(ctx, data.UserID) if err != nil { if errors.Is(err, ErrUserNotFound) { // 用户已删除,撤销整个Token家族 _ = s.refreshTokenCache.DeleteTokenFamily(ctx, data.FamilyID) return nil, ErrRefreshTokenInvalid } logger.LegacyPrintf("service.auth", "[Auth] Database error getting user for token refresh: %v", err) return nil, ErrServiceUnavailable } // 检查用户状态 if !user.IsActive() { // 用户被禁用,撤销整个Token家族 _ = s.refreshTokenCache.DeleteTokenFamily(ctx, data.FamilyID) return nil, ErrUserNotActive } // 检查TokenVersion(密码更改后所有Token失效) if data.TokenVersion != resolvedTokenVersion(user) { // TokenVersion不匹配,撤销整个Token家族 _ = s.refreshTokenCache.DeleteTokenFamily(ctx, data.FamilyID) return nil, ErrTokenRevoked } // 会话绑定检查:IP/UA 任一变化即撤销整个会话家族。 // data.BindingHash 为空表示功能开启前签发的旧会话,放行并在轮转时补齐绑定。 if s.settingService != nil && s.settingService.IsSessionBindingEnabled(ctx) && data.BindingHash != "" { if current := sessionBindingHashFromContext(ctx); current != "" && current != data.BindingHash { _ = s.refreshTokenCache.DeleteTokenFamily(ctx, data.FamilyID) logger.LegacyPrintf("service.auth", "[Auth] Session binding mismatch on refresh for user %d, family revoked", data.UserID) return nil, ErrSessionBindingMismatch } } // Token轮转:立即使旧Token失效 if err := s.refreshTokenCache.DeleteRefreshToken(ctx, tokenHash); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to delete old refresh token: %v", err) // 继续处理,不影响主流程 } // 生成新的Token对,保持同一个家族ID pair, err := s.GenerateTokenPair(ctx, user, data.FamilyID) if err != nil { return nil, err } return &TokenPairWithUser{ TokenPair: *pair, UserRole: user.Role, }, nil } // RevokeRefreshToken 撤销单个Refresh Token func (s *AuthService) RevokeRefreshToken(ctx context.Context, refreshToken string) error { if s.refreshTokenCache == nil { return nil // No-op if cache not configured } if !strings.HasPrefix(refreshToken, refreshTokenPrefix) { return ErrRefreshTokenInvalid } tokenHash := hashToken(refreshToken) return s.refreshTokenCache.DeleteRefreshToken(ctx, tokenHash) } // RevokeSessionFamily 撤销单个会话家族(该会话的所有 refresh token)。 // 用于会话绑定失效等单会话级撤销场景,不影响用户的其他设备会话。 func (s *AuthService) RevokeSessionFamily(ctx context.Context, familyID string) error { if s.refreshTokenCache == nil || familyID == "" { return nil } return s.refreshTokenCache.DeleteTokenFamily(ctx, familyID) } // RevokeAllUserSessions 撤销用户的所有会话(所有Refresh Token) // 用于密码更改或用户主动登出所有设备 func (s *AuthService) RevokeAllUserSessions(ctx context.Context, userID int64) error { if s.refreshTokenCache == nil { return nil // No-op if cache not configured } return s.refreshTokenCache.DeleteUserRefreshTokens(ctx, userID) } // RevokeAllUserTokens invalidates both stateless access tokens and refresh sessions. // // 注意:users 表没有 token_version 列(resolvedTokenVersion 由 email+password_hash // 指纹推导),因此对 user.TokenVersion 自增只影响内存副本。之前紧跟其后的整行 // Update 不写任何有效数据,却会用旧快照覆盖并发写入的列,故已移除。 // 会话撤销由下面的 refresh session 清理承担;改密路径通过 password_hash 变化 // 改变指纹,从而使旧 token 失效。 func (s *AuthService) RevokeAllUserTokens(ctx context.Context, userID int64) error { if _, err := s.userRepo.GetByID(ctx, userID); err != nil { return fmt.Errorf("get user: %w", err) } if err := s.RevokeAllUserSessions(ctx, userID); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Failed to revoke refresh sessions after token invalidation for user %d: %v", userID, err) } return nil } // hashToken 计算Token的SHA256哈希 func hashToken(token string) string { hash := sha256.Sum256([]byte(token)) return hex.EncodeToString(hash[:]) } func resolvedTokenVersion(user *User) int64 { if user == nil { return 0 } if user.TokenVersionResolved { return user.TokenVersion } material := strings.ToLower(strings.TrimSpace(user.Email)) + "\n" + user.PasswordHash sum := sha256.Sum256([]byte(material)) fingerprint := int64(binary.BigEndian.Uint64(sum[:8]) & 0x7fffffffffffffff) return user.TokenVersion ^ fingerprint } // snapshotPlatformQuotaDefaults 把 plan.PlatformQuotas(platform × 3 window)以 // BulkInsertInitial 形式写入 user_platform_quotas 表。失败 fail-open(仅 warn log)。 func (s *AuthService) snapshotPlatformQuotaDefaults(ctx context.Context, userID int64, plan *signupGrantPlan) error { if s.userPlatformQuotaRepo == nil || plan == nil || len(plan.PlatformQuotas) == 0 { return nil } // 平台配额快照是 best-effort(fail-open):必须脱离调用方事务执行。 // 否则某平台违反 user_platform_quotas 的 CHECK 约束(如尚未进约束的新平台)会让 // 整个调用方事务被 Postgres 标记 aborted,把"无关紧要的默认配额快照"放大成 // "整笔注册失败"(OAuth pending 路径曾因此 500 → 清 cookie → 404)。 ctx = dbent.WithoutTx(ctx) records := make([]UserPlatformQuotaRecord, 0, len(plan.PlatformQuotas)) for platform, q := range plan.PlatformQuotas { rec := UserPlatformQuotaRecord{ UserID: userID, Platform: platform, } if q != nil { rec.DailyLimitUSD = q.DailyLimitUSD rec.WeeklyLimitUSD = q.WeeklyLimitUSD rec.MonthlyLimitUSD = q.MonthlyLimitUSD } records = append(records, rec) } if err := s.userPlatformQuotaRepo.BulkInsertInitial(ctx, records); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Warning: snapshot platform quota failed user=%d: %v (fail-open)", userID, err) return nil // fail-open:返回 nil,让调用方继续 } return nil }