| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962 |
- 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
- }
|