| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500 |
- package service
- import (
- "context"
- "errors"
- "fmt"
- "log/slog"
- "net/mail"
- "strings"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/redeemcode"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- )
- func normalizeOAuthSignupSource(signupSource string) string {
- signupSource = strings.TrimSpace(strings.ToLower(signupSource))
- switch signupSource {
- case "", "email":
- return "email"
- case "linuxdo", "wechat", "oidc", "github", "google", "dingtalk":
- return signupSource
- default:
- return "email"
- }
- }
- // SendPendingOAuthVerifyCode sends a local verification code for pending OAuth
- // account-creation flows without relying on the public registration gate.
- func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) {
- email = strings.TrimSpace(strings.ToLower(email))
- if email == "" {
- return nil, ErrEmailVerifyRequired
- }
- if _, err := mail.ParseAddress(email); err != nil {
- return nil, ErrEmailVerifyRequired
- }
- if isReservedEmail(email) {
- return nil, ErrEmailReserved
- }
- if s == nil || s.emailService == nil {
- return nil, ErrServiceUnavailable
- }
- if err := s.validateRegistrationEmailQuota(ctx, email); err != nil {
- return nil, err
- }
- siteName := "Sub2API"
- if s.settingService != nil {
- siteName = s.settingService.GetSiteName(ctx)
- }
- if err := s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale)); err != nil {
- return nil, err
- }
- return &SendVerifyCodeResult{
- Countdown: int(verifyCodeCooldown / time.Second),
- }, nil
- }
- func (s *AuthService) validateOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
- if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
- return nil, nil
- }
- if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
- return nil, ErrServiceUnavailable
- }
- invitationCode = strings.TrimSpace(invitationCode)
- if invitationCode == "" {
- return nil, ErrInvitationCodeRequired
- }
- redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
- if err != nil {
- return nil, ErrInvitationCodeInvalid
- }
- if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() {
- return nil, ErrInvitationCodeInvalid
- }
- return redeemCode, nil
- }
- // VerifyOAuthEmailCode verifies the locally entered email verification code for
- // third-party signup and binding flows. This is intentionally independent from
- // the global registration email verification toggle.
- func (s *AuthService) VerifyOAuthEmailCode(ctx context.Context, email, verifyCode string) error {
- email = strings.TrimSpace(strings.ToLower(email))
- verifyCode = strings.TrimSpace(verifyCode)
- if email == "" {
- return ErrEmailVerifyRequired
- }
- if verifyCode == "" {
- return ErrEmailVerifyRequired
- }
- if s == nil || s.emailService == nil {
- return ErrServiceUnavailable
- }
- return s.emailService.VerifyCode(ctx, email, verifyCode)
- }
- // RegisterOAuthEmailAccount creates a local account from a third-party first
- // login after the user has verified a local email address.
- func (s *AuthService) RegisterOAuthEmailAccount(
- ctx context.Context,
- email string,
- password string,
- verifyCode string,
- invitationCode string,
- signupSource string,
- ) (*TokenPair, *User, error) {
- if s == nil {
- return nil, nil, ErrServiceUnavailable
- }
- if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
- return nil, nil, ErrRegDisabled
- }
- email = strings.TrimSpace(strings.ToLower(email))
- if isReservedEmail(email) {
- return nil, nil, ErrEmailReserved
- }
- if err := s.VerifyOAuthEmailCode(ctx, email, verifyCode); err != nil {
- slog.Error("oauth email register: verify code failed", "email", email, "error", err.Error())
- return nil, nil, err
- }
- if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
- slog.Error("oauth email register: invitation failed", "email", email, "error", err.Error())
- return nil, nil, err
- }
- // 含 +别名 / Gmail 点号 / FQDN 根点变体归一化:该路径同样发放注册赠额,不能被单个收件箱刷号。
- existsEmail, err := s.existsByEmailOrAlias(ctx, email)
- if err != nil {
- slog.Error("oauth email register: ExistsByEmail failed", "email", email, "error", err.Error())
- return nil, nil, ErrServiceUnavailable
- }
- if existsEmail {
- return nil, nil, ErrEmailExists
- }
- if err := s.validateRegistrationEmailQuota(ctx, email); err != nil {
- slog.Error("oauth email register: policy rejected", "email", email, "error", err.Error())
- return nil, nil, err
- }
- hashedPassword, err := s.HashPassword(password)
- if err != nil {
- return nil, nil, fmt.Errorf("hash password: %w", err)
- }
- signupSource = normalizeOAuthSignupSource(signupSource)
- grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
- user := &User{
- Email: email,
- PasswordHash: hashedPassword,
- Role: RoleUser,
- Balance: grantPlan.Balance,
- Concurrency: grantPlan.Concurrency,
- Status: StatusActive,
- SignupSource: signupSource,
- }
- if err := s.createUserWithRegistrationEmailGuard(ctx, user); err != nil {
- switch {
- case errors.Is(err, ErrEmailExists):
- return nil, nil, ErrEmailExists
- case errors.Is(err, ErrEmailDomainRegistrationLimit):
- return nil, nil, ErrEmailDomainRegistrationLimit
- default:
- slog.Error("oauth email register: userRepo.Create failed", "email", email, "signup_source", signupSource, "error", err.Error())
- return nil, nil, ErrServiceUnavailable
- }
- }
- tokenPair, err := s.GenerateTokenPair(ctx, user, "")
- if err != nil {
- _ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
- return nil, nil, fmt.Errorf("generate token pair: %w", err)
- }
- return tokenPair, user, nil
- }
- // RegisterVerifiedOAuthEmailAccount creates a local account from an OAuth
- // provider that has already returned a verified email address.
- func (s *AuthService) RegisterVerifiedOAuthEmailAccount(
- ctx context.Context,
- email string,
- password string,
- invitationCode string,
- signupSource string,
- ) (*TokenPair, *User, error) {
- if s == nil {
- return nil, nil, ErrServiceUnavailable
- }
- if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
- return nil, nil, ErrRegDisabled
- }
- email = strings.TrimSpace(strings.ToLower(email))
- if email == "" || len(email) > 255 {
- return nil, nil, ErrEmailVerifyRequired
- }
- if _, err := mail.ParseAddress(email); err != nil {
- return nil, nil, ErrEmailVerifyRequired
- }
- if isReservedEmail(email) {
- return nil, nil, ErrEmailReserved
- }
- if strings.TrimSpace(password) == "" {
- return nil, nil, infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required")
- }
- if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
- return nil, nil, err
- }
- // 与本地注册同口径:同一收件箱的别名变体不能各自建号(该路径也发放注册赠额)。
- existsEmail, err := s.existsByEmailOrAlias(ctx, email)
- if err != nil {
- return nil, nil, ErrServiceUnavailable
- }
- if existsEmail {
- return nil, nil, ErrEmailExists
- }
- if err := s.validateRegistrationEmailQuota(ctx, email); err != nil {
- return nil, nil, err
- }
- hashedPassword, err := s.HashPassword(password)
- if err != nil {
- return nil, nil, fmt.Errorf("hash password: %w", err)
- }
- signupSource = normalizeOAuthSignupSource(signupSource)
- grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
- 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,
- SignupSource: signupSource,
- }
- if err := s.createUserWithRegistrationEmailGuard(ctx, user); err != nil {
- switch {
- case errors.Is(err, ErrEmailExists):
- return nil, nil, ErrEmailExists
- case errors.Is(err, ErrEmailDomainRegistrationLimit):
- return nil, nil, ErrEmailDomainRegistrationLimit
- default:
- return nil, nil, ErrServiceUnavailable
- }
- }
- tokenPair, err := s.GenerateTokenPair(ctx, user, "")
- if err != nil {
- _ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
- return nil, nil, fmt.Errorf("generate token pair: %w", err)
- }
- return tokenPair, user, nil
- }
- // FinalizeOAuthEmailAccount applies invitation usage and normal signup bootstrap
- // only after the pending OAuth flow has fully reached its last reversible step.
- func (s *AuthService) FinalizeOAuthEmailAccount(
- ctx context.Context,
- user *User,
- invitationCode string,
- signupSource string,
- affiliateCode string,
- ) error {
- if s == nil || user == nil || user.ID <= 0 {
- return ErrServiceUnavailable
- }
- signupSource = normalizeOAuthSignupSource(signupSource)
- invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
- if err != nil {
- return err
- }
- if invitationRedeemCode != nil {
- if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
- return ErrInvitationCodeInvalid
- }
- }
- s.updateOAuthSignupSource(ctx, user.ID, signupSource)
- grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
- 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)
- return nil
- }
- // RollbackOAuthEmailAccountCreation removes a partially-created local account
- // and restores any invitation code already consumed by that account.
- func (s *AuthService) RollbackOAuthEmailAccountCreation(ctx context.Context, userID int64, invitationCode string) error {
- if s == nil || s.userRepo == nil || userID <= 0 {
- return ErrServiceUnavailable
- }
- if err := s.restoreOAuthRegistrationInvitation(ctx, invitationCode, userID); err != nil {
- return err
- }
- if err := s.userRepo.Delete(ctx, userID); err != nil {
- return fmt.Errorf("delete created oauth user: %w", err)
- }
- return nil
- }
- func (s *AuthService) restoreOAuthRegistrationInvitation(ctx context.Context, invitationCode string, userID int64) error {
- if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
- return nil
- }
- if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
- return ErrServiceUnavailable
- }
- invitationCode = strings.TrimSpace(invitationCode)
- if invitationCode == "" || userID <= 0 {
- return nil
- }
- redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
- if err != nil {
- if errors.Is(err, ErrRedeemCodeNotFound) {
- return nil
- }
- return fmt.Errorf("load invitation code: %w", err)
- }
- if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUsed || redeemCode.UsedBy == nil || *redeemCode.UsedBy != userID {
- return nil
- }
- redeemCode.Status = StatusUnused
- redeemCode.UsedBy = nil
- redeemCode.UsedAt = nil
- if err := s.updateOAuthRegistrationInvitation(ctx, redeemCode); err != nil {
- return fmt.Errorf("restore invitation code: %w", err)
- }
- return nil
- }
- func (s *AuthService) oauthEmailFlowClient(ctx context.Context) *dbent.Client {
- if s == nil || s.entClient == nil {
- return nil
- }
- if tx := dbent.TxFromContext(ctx); tx != nil {
- return tx.Client()
- }
- return s.entClient
- }
- func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
- if client := s.oauthEmailFlowClient(ctx); client != nil {
- entity, err := client.RedeemCode.Query().Where(redeemcode.CodeEQ(invitationCode)).Only(ctx)
- if err != nil {
- if dbent.IsNotFound(err) {
- return nil, ErrRedeemCodeNotFound
- }
- return nil, err
- }
- return &RedeemCode{
- ID: entity.ID,
- Code: entity.Code,
- Type: entity.Type,
- Value: entity.Value,
- Status: entity.Status,
- UsedBy: entity.UsedBy,
- UsedAt: entity.UsedAt,
- Notes: oauthEmailFlowStringValue(entity.Notes),
- CreatedAt: entity.CreatedAt,
- ExpiresAt: entity.ExpiresAt,
- GroupID: entity.GroupID,
- ValidityDays: entity.ValidityDays,
- }, nil
- }
- return s.redeemRepo.GetByCode(ctx, invitationCode)
- }
- func (s *AuthService) useOAuthRegistrationInvitation(ctx context.Context, invitationID, userID int64) error {
- if client := s.oauthEmailFlowClient(ctx); client != nil {
- affected, err := client.RedeemCode.Update().
- Where(
- redeemcode.IDEQ(invitationID),
- redeemcode.StatusEQ(StatusUnused),
- redeemcode.Or(redeemcode.ExpiresAtIsNil(), redeemcode.ExpiresAtGT(time.Now().UTC())),
- ).
- SetStatus(StatusUsed).
- SetUsedBy(userID).
- SetUsedAt(time.Now().UTC()).
- Save(ctx)
- if err != nil {
- return err
- }
- if affected == 0 {
- return ErrRedeemCodeUsed
- }
- return nil
- }
- return s.redeemRepo.Use(ctx, invitationID, userID)
- }
- func (s *AuthService) updateOAuthRegistrationInvitation(ctx context.Context, code *RedeemCode) error {
- if code == nil {
- return nil
- }
- if client := s.oauthEmailFlowClient(ctx); client != nil {
- update := client.RedeemCode.UpdateOneID(code.ID).
- SetCode(code.Code).
- SetType(code.Type).
- SetValue(code.Value).
- SetStatus(code.Status).
- SetNotes(code.Notes).
- SetValidityDays(code.ValidityDays)
- if code.ExpiresAt != nil {
- update = update.SetExpiresAt(*code.ExpiresAt)
- } else {
- update = update.ClearExpiresAt()
- }
- if code.UsedBy != nil {
- update = update.SetUsedBy(*code.UsedBy)
- } else {
- update = update.ClearUsedBy()
- }
- if code.UsedAt != nil {
- update = update.SetUsedAt(*code.UsedAt)
- } else {
- update = update.ClearUsedAt()
- }
- if code.GroupID != nil {
- update = update.SetGroupID(*code.GroupID)
- } else {
- update = update.ClearGroupID()
- }
- _, err := update.Save(ctx)
- return err
- }
- return s.redeemRepo.Update(ctx, code)
- }
- func (s *AuthService) updateOAuthSignupSource(ctx context.Context, userID int64, signupSource string) {
- client := s.oauthEmailFlowClient(ctx)
- if client == nil || userID <= 0 || strings.TrimSpace(signupSource) == "" {
- return
- }
- _ = client.User.UpdateOneID(userID).SetSignupSource(signupSource).Exec(ctx)
- }
- func oauthEmailFlowStringValue(value *string) string {
- if value == nil {
- return ""
- }
- return *value
- }
- // ValidatePasswordCredentials checks the local password without completing the
- // login flow. This is used by pending third-party account adoption flows before
- // the external identity has been bound.
- func (s *AuthService) ValidatePasswordCredentials(ctx context.Context, email, password string) (*User, error) {
- if s == nil {
- return nil, ErrServiceUnavailable
- }
- user, err := s.userRepo.GetByEmail(ctx, strings.TrimSpace(strings.ToLower(email)))
- if err != nil {
- if errors.Is(err, ErrUserNotFound) {
- return nil, ErrInvalidCredentials
- }
- return nil, ErrServiceUnavailable
- }
- if !user.IsActive() {
- return nil, ErrUserNotActive
- }
- if !s.CheckPassword(password, user.PasswordHash) {
- return nil, ErrInvalidCredentials
- }
- return user, nil
- }
- // RecordSuccessfulLogin updates last-login activity after a non-standard login
- // flow finishes with a real session.
- func (s *AuthService) RecordSuccessfulLogin(ctx context.Context, userID int64) {
- if s != nil && s.userRepo != nil && userID > 0 {
- user, err := s.userRepo.GetByID(ctx, userID)
- if err == nil && user != nil && !isReservedEmail(user.Email) {
- s.backfillEmailIdentityOnSuccessfulLogin(ctx, user)
- }
- }
- s.touchUserLogin(ctx, userID)
- }
|