| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362 |
- package service
- import (
- "context"
- "errors"
- "fmt"
- "net/mail"
- "strings"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/authidentity"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- )
- // BindEmailIdentity verifies and binds a local email/password identity to the
- // current user, or replaces the existing bound primary email.
- func (s *AuthService) BindEmailIdentity(
- ctx context.Context,
- userID int64,
- email string,
- verifyCode string,
- password string,
- ) (*User, error) {
- if s == nil {
- return nil, ErrServiceUnavailable
- }
- normalizedEmail, err := normalizeEmailForIdentityBinding(email)
- if err != nil {
- return nil, err
- }
- if isReservedEmail(normalizedEmail) {
- return nil, ErrEmailReserved
- }
- if strings.TrimSpace(password) == "" {
- return nil, ErrPasswordRequired
- }
- if err := s.VerifyOAuthEmailCode(ctx, normalizedEmail, verifyCode); err != nil {
- return nil, err
- }
- if err := s.validateRegistrationEmailPolicy(ctx, normalizedEmail); err != nil {
- return nil, err
- }
- currentUser, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- return nil, err
- }
- firstRealEmailBind := !hasBindableEmailIdentitySubject(currentUser.Email)
- if firstRealEmailBind && len(password) < 6 {
- return nil, infraerrors.BadRequest("PASSWORD_TOO_SHORT", "password must be at least 6 characters")
- }
- if !firstRealEmailBind && !s.CheckPassword(password, currentUser.PasswordHash) {
- return nil, ErrPasswordIncorrect
- }
- if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil {
- return nil, err
- }
- hashedPassword, err := s.HashPassword(password)
- if err != nil {
- return nil, fmt.Errorf("hash password: %w", err)
- }
- if s.entClient != nil {
- if err := s.updateBoundEmailIdentityTx(ctx, currentUser, normalizedEmail, hashedPassword, firstRealEmailBind); err != nil {
- return nil, err
- }
- s.revokeEmailIdentitySessions(ctx, userID)
- return currentUser, nil
- }
- currentUser.Email = normalizedEmail
- currentUser.PasswordHash = hashedPassword
- if err := s.userRepo.Update(ctx, currentUser, UserUpdateFields{Email: true, PasswordHash: true}); err != nil {
- if errors.Is(err, ErrEmailExists) {
- return nil, ErrEmailExists
- }
- return nil, ErrServiceUnavailable
- }
- if firstRealEmailBind {
- if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, userID, "email"); err != nil {
- return nil, fmt.Errorf("apply email first bind defaults: %w", err)
- }
- }
- s.revokeEmailIdentitySessions(ctx, userID)
- return currentUser, nil
- }
- // SendEmailIdentityBindCode sends a verification code for authenticated email binding flows.
- func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string, locale ...string) error {
- if s == nil {
- return ErrServiceUnavailable
- }
- normalizedEmail, err := normalizeEmailForIdentityBinding(email)
- if err != nil {
- return err
- }
- if isReservedEmail(normalizedEmail) {
- return ErrEmailReserved
- }
- if err := s.validateRegistrationEmailPolicy(ctx, normalizedEmail); err != nil {
- return err
- }
- if s.emailService == nil {
- return ErrServiceUnavailable
- }
- currentUser, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- if errors.Is(err, ErrUserNotFound) {
- return ErrUserNotFound
- }
- return ErrServiceUnavailable
- }
- if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil {
- return err
- }
- siteName := "Sub2API"
- if s.settingService != nil {
- siteName = s.settingService.GetSiteName(ctx)
- }
- return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName, firstEmailLocale(locale))
- }
- // ensureEmailIdentityAvailableForUser 在发码 / 提交换绑前做快速查重。
- // 精确地址或 provider alias 若已指向其他用户的收件箱则直接拒绝;
- // 当前用户自己的收件箱允许继续,便于其更换自身的 alias 变体。
- func (s *AuthService) ensureEmailIdentityAvailableForUser(
- ctx context.Context,
- currentUser *User,
- email string,
- ) error {
- if currentUser == nil {
- return ErrUserNotFound
- }
- existingUser, err := s.userRepo.GetByEmail(ctx, email)
- switch {
- case err == nil:
- if existingUser == nil || existingUser.ID == currentUser.ID {
- break
- }
- return ErrEmailExists
- case errors.Is(err, ErrUserNotFound):
- // Continue to alias lookup below.
- default:
- return ErrServiceUnavailable
- }
- if NormalizeEmailForAliasDedup(currentUser.Email) == NormalizeEmailForAliasDedup(email) {
- return nil
- }
- aliasExists, err := s.userRepo.ExistsByEmailAlias(ctx, email)
- if err != nil {
- return ErrServiceUnavailable
- }
- if aliasExists {
- return ErrEmailExists
- }
- return nil
- }
- func normalizeEmailForIdentityBinding(email string) (string, error) {
- normalized := strings.ToLower(strings.TrimSpace(email))
- if normalized == "" || len(normalized) > 255 {
- return "", infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
- }
- if _, err := mail.ParseAddress(normalized); err != nil {
- return "", infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
- }
- return normalized, nil
- }
- func hasBindableEmailIdentitySubject(email string) bool {
- normalized := strings.ToLower(strings.TrimSpace(email))
- return normalized != "" && !isReservedEmail(normalized)
- }
- // emailIdentityAliasGuardRepository 是主邮箱替换所需的事务内原子仓储能力,
- // 用于关闭服务层前置查重与实际写入之间的并发窗口。
- type emailIdentityAliasGuardRepository interface {
- UpdateEmailWithAliasGuard(ctx context.Context, userID int64, email string, passwordHash string) error
- }
- func (s *AuthService) updateBoundEmailIdentityTx(
- ctx context.Context,
- currentUser *User,
- email string,
- hashedPassword string,
- applyFirstBindDefaults bool,
- ) error {
- if tx := dbent.TxFromContext(ctx); tx != nil {
- return s.updateBoundEmailIdentityWithClient(ctx, tx.Client(), currentUser, email, hashedPassword, applyFirstBindDefaults)
- }
- tx, err := s.entClient.Tx(ctx)
- if err != nil {
- return ErrServiceUnavailable
- }
- defer func() { _ = tx.Rollback() }()
- txCtx := dbent.NewTxContext(ctx, tx)
- if err := s.updateBoundEmailIdentityWithClient(txCtx, tx.Client(), currentUser, email, hashedPassword, applyFirstBindDefaults); err != nil {
- return err
- }
- if err := tx.Commit(); err != nil {
- return ErrServiceUnavailable
- }
- return nil
- }
- func (s *AuthService) updateBoundEmailIdentityWithClient(
- ctx context.Context,
- client *dbent.Client,
- currentUser *User,
- email string,
- hashedPassword string,
- applyFirstBindDefaults bool,
- ) error {
- if client == nil || currentUser == nil || currentUser.ID <= 0 {
- return ErrServiceUnavailable
- }
- guard, ok := s.userRepo.(emailIdentityAliasGuardRepository)
- if !ok {
- return ErrServiceUnavailable
- }
- if err := guard.UpdateEmailWithAliasGuard(ctx, currentUser.ID, email, hashedPassword); err != nil {
- return err
- }
- oldEmail := currentUser.Email
- if err := replaceBoundEmailAuthIdentityWithClient(ctx, client, currentUser.ID, oldEmail, email, "auth_service_email_bind"); err != nil {
- if errors.Is(err, ErrEmailExists) {
- return ErrEmailExists
- }
- return ErrServiceUnavailable
- }
- if applyFirstBindDefaults {
- if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, currentUser.ID, "email"); err != nil {
- return fmt.Errorf("apply email first bind defaults: %w", err)
- }
- }
- updatedUser, err := client.User.Get(ctx, currentUser.ID)
- if err != nil {
- return ErrServiceUnavailable
- }
- currentUser.Email = updatedUser.Email
- currentUser.PasswordHash = updatedUser.PasswordHash
- currentUser.Balance = updatedUser.Balance
- currentUser.Concurrency = updatedUser.Concurrency
- currentUser.UpdatedAt = updatedUser.UpdatedAt
- return nil
- }
- func (s *AuthService) revokeEmailIdentitySessions(ctx context.Context, userID int64) {
- if err := s.RevokeAllUserSessions(ctx, userID); err != nil {
- logger.LegacyPrintf("service.auth", "[Auth] Failed to revoke refresh sessions after email identity bind for user %d: %v", userID, err)
- }
- }
- func replaceBoundEmailAuthIdentityWithClient(
- ctx context.Context,
- client *dbent.Client,
- userID int64,
- oldEmail string,
- newEmail string,
- source string,
- ) error {
- newSubject := normalizeBoundEmailAuthIdentitySubject(newEmail)
- if err := ensureBoundEmailAuthIdentityWithClient(ctx, client, userID, newSubject, source); err != nil {
- return err
- }
- oldSubject := normalizeBoundEmailAuthIdentitySubject(oldEmail)
- if oldSubject == "" || oldSubject == newSubject {
- return nil
- }
- _, err := client.AuthIdentity.Delete().
- Where(
- authidentity.UserIDEQ(userID),
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ(oldSubject),
- ).
- Exec(ctx)
- return err
- }
- func ensureBoundEmailAuthIdentityWithClient(
- ctx context.Context,
- client *dbent.Client,
- userID int64,
- subject string,
- source string,
- ) error {
- if client == nil || userID <= 0 || subject == "" {
- return nil
- }
- if strings.TrimSpace(source) == "" {
- source = "auth_service_email_bind"
- }
- if err := client.AuthIdentity.Create().
- SetUserID(userID).
- SetProviderType("email").
- SetProviderKey("email").
- SetProviderSubject(subject).
- 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 err
- }
- }
- identity, err := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ(subject),
- ).
- Only(ctx)
- if err != nil {
- if dbent.IsNotFound(err) {
- return nil
- }
- return err
- }
- if identity.UserID != userID {
- return ErrEmailExists
- }
- return nil
- }
- func normalizeBoundEmailAuthIdentitySubject(email string) string {
- normalized := strings.ToLower(strings.TrimSpace(email))
- if normalized == "" || isReservedEmail(normalized) {
- return ""
- }
- return normalized
- }
|