auth_email_binding.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "net/mail"
  7. "strings"
  8. "time"
  9. dbent "github.com/Wei-Shaw/sub2api/ent"
  10. "github.com/Wei-Shaw/sub2api/ent/authidentity"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  13. )
  14. // BindEmailIdentity verifies and binds a local email/password identity to the
  15. // current user, or replaces the existing bound primary email.
  16. func (s *AuthService) BindEmailIdentity(
  17. ctx context.Context,
  18. userID int64,
  19. email string,
  20. verifyCode string,
  21. password string,
  22. ) (*User, error) {
  23. if s == nil {
  24. return nil, ErrServiceUnavailable
  25. }
  26. normalizedEmail, err := normalizeEmailForIdentityBinding(email)
  27. if err != nil {
  28. return nil, err
  29. }
  30. if isReservedEmail(normalizedEmail) {
  31. return nil, ErrEmailReserved
  32. }
  33. if strings.TrimSpace(password) == "" {
  34. return nil, ErrPasswordRequired
  35. }
  36. if err := s.VerifyOAuthEmailCode(ctx, normalizedEmail, verifyCode); err != nil {
  37. return nil, err
  38. }
  39. if err := s.validateRegistrationEmailPolicy(ctx, normalizedEmail); err != nil {
  40. return nil, err
  41. }
  42. currentUser, err := s.userRepo.GetByID(ctx, userID)
  43. if err != nil {
  44. return nil, err
  45. }
  46. firstRealEmailBind := !hasBindableEmailIdentitySubject(currentUser.Email)
  47. if firstRealEmailBind && len(password) < 6 {
  48. return nil, infraerrors.BadRequest("PASSWORD_TOO_SHORT", "password must be at least 6 characters")
  49. }
  50. if !firstRealEmailBind && !s.CheckPassword(password, currentUser.PasswordHash) {
  51. return nil, ErrPasswordIncorrect
  52. }
  53. if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil {
  54. return nil, err
  55. }
  56. hashedPassword, err := s.HashPassword(password)
  57. if err != nil {
  58. return nil, fmt.Errorf("hash password: %w", err)
  59. }
  60. if s.entClient != nil {
  61. if err := s.updateBoundEmailIdentityTx(ctx, currentUser, normalizedEmail, hashedPassword, firstRealEmailBind); err != nil {
  62. return nil, err
  63. }
  64. s.revokeEmailIdentitySessions(ctx, userID)
  65. return currentUser, nil
  66. }
  67. currentUser.Email = normalizedEmail
  68. currentUser.PasswordHash = hashedPassword
  69. if err := s.userRepo.Update(ctx, currentUser, UserUpdateFields{Email: true, PasswordHash: true}); err != nil {
  70. if errors.Is(err, ErrEmailExists) {
  71. return nil, ErrEmailExists
  72. }
  73. return nil, ErrServiceUnavailable
  74. }
  75. if firstRealEmailBind {
  76. if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, userID, "email"); err != nil {
  77. return nil, fmt.Errorf("apply email first bind defaults: %w", err)
  78. }
  79. }
  80. s.revokeEmailIdentitySessions(ctx, userID)
  81. return currentUser, nil
  82. }
  83. // SendEmailIdentityBindCode sends a verification code for authenticated email binding flows.
  84. func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string, locale ...string) error {
  85. if s == nil {
  86. return ErrServiceUnavailable
  87. }
  88. normalizedEmail, err := normalizeEmailForIdentityBinding(email)
  89. if err != nil {
  90. return err
  91. }
  92. if isReservedEmail(normalizedEmail) {
  93. return ErrEmailReserved
  94. }
  95. if err := s.validateRegistrationEmailPolicy(ctx, normalizedEmail); err != nil {
  96. return err
  97. }
  98. if s.emailService == nil {
  99. return ErrServiceUnavailable
  100. }
  101. currentUser, err := s.userRepo.GetByID(ctx, userID)
  102. if err != nil {
  103. if errors.Is(err, ErrUserNotFound) {
  104. return ErrUserNotFound
  105. }
  106. return ErrServiceUnavailable
  107. }
  108. if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil {
  109. return err
  110. }
  111. siteName := "Sub2API"
  112. if s.settingService != nil {
  113. siteName = s.settingService.GetSiteName(ctx)
  114. }
  115. return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName, firstEmailLocale(locale))
  116. }
  117. // ensureEmailIdentityAvailableForUser 在发码 / 提交换绑前做快速查重。
  118. // 精确地址或 provider alias 若已指向其他用户的收件箱则直接拒绝;
  119. // 当前用户自己的收件箱允许继续,便于其更换自身的 alias 变体。
  120. func (s *AuthService) ensureEmailIdentityAvailableForUser(
  121. ctx context.Context,
  122. currentUser *User,
  123. email string,
  124. ) error {
  125. if currentUser == nil {
  126. return ErrUserNotFound
  127. }
  128. existingUser, err := s.userRepo.GetByEmail(ctx, email)
  129. switch {
  130. case err == nil:
  131. if existingUser == nil || existingUser.ID == currentUser.ID {
  132. break
  133. }
  134. return ErrEmailExists
  135. case errors.Is(err, ErrUserNotFound):
  136. // Continue to alias lookup below.
  137. default:
  138. return ErrServiceUnavailable
  139. }
  140. if NormalizeEmailForAliasDedup(currentUser.Email) == NormalizeEmailForAliasDedup(email) {
  141. return nil
  142. }
  143. aliasExists, err := s.userRepo.ExistsByEmailAlias(ctx, email)
  144. if err != nil {
  145. return ErrServiceUnavailable
  146. }
  147. if aliasExists {
  148. return ErrEmailExists
  149. }
  150. return nil
  151. }
  152. func normalizeEmailForIdentityBinding(email string) (string, error) {
  153. normalized := strings.ToLower(strings.TrimSpace(email))
  154. if normalized == "" || len(normalized) > 255 {
  155. return "", infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
  156. }
  157. if _, err := mail.ParseAddress(normalized); err != nil {
  158. return "", infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
  159. }
  160. return normalized, nil
  161. }
  162. func hasBindableEmailIdentitySubject(email string) bool {
  163. normalized := strings.ToLower(strings.TrimSpace(email))
  164. return normalized != "" && !isReservedEmail(normalized)
  165. }
  166. // emailIdentityAliasGuardRepository 是主邮箱替换所需的事务内原子仓储能力,
  167. // 用于关闭服务层前置查重与实际写入之间的并发窗口。
  168. type emailIdentityAliasGuardRepository interface {
  169. UpdateEmailWithAliasGuard(ctx context.Context, userID int64, email string, passwordHash string) error
  170. }
  171. func (s *AuthService) updateBoundEmailIdentityTx(
  172. ctx context.Context,
  173. currentUser *User,
  174. email string,
  175. hashedPassword string,
  176. applyFirstBindDefaults bool,
  177. ) error {
  178. if tx := dbent.TxFromContext(ctx); tx != nil {
  179. return s.updateBoundEmailIdentityWithClient(ctx, tx.Client(), currentUser, email, hashedPassword, applyFirstBindDefaults)
  180. }
  181. tx, err := s.entClient.Tx(ctx)
  182. if err != nil {
  183. return ErrServiceUnavailable
  184. }
  185. defer func() { _ = tx.Rollback() }()
  186. txCtx := dbent.NewTxContext(ctx, tx)
  187. if err := s.updateBoundEmailIdentityWithClient(txCtx, tx.Client(), currentUser, email, hashedPassword, applyFirstBindDefaults); err != nil {
  188. return err
  189. }
  190. if err := tx.Commit(); err != nil {
  191. return ErrServiceUnavailable
  192. }
  193. return nil
  194. }
  195. func (s *AuthService) updateBoundEmailIdentityWithClient(
  196. ctx context.Context,
  197. client *dbent.Client,
  198. currentUser *User,
  199. email string,
  200. hashedPassword string,
  201. applyFirstBindDefaults bool,
  202. ) error {
  203. if client == nil || currentUser == nil || currentUser.ID <= 0 {
  204. return ErrServiceUnavailable
  205. }
  206. guard, ok := s.userRepo.(emailIdentityAliasGuardRepository)
  207. if !ok {
  208. return ErrServiceUnavailable
  209. }
  210. if err := guard.UpdateEmailWithAliasGuard(ctx, currentUser.ID, email, hashedPassword); err != nil {
  211. return err
  212. }
  213. oldEmail := currentUser.Email
  214. if err := replaceBoundEmailAuthIdentityWithClient(ctx, client, currentUser.ID, oldEmail, email, "auth_service_email_bind"); err != nil {
  215. if errors.Is(err, ErrEmailExists) {
  216. return ErrEmailExists
  217. }
  218. return ErrServiceUnavailable
  219. }
  220. if applyFirstBindDefaults {
  221. if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, currentUser.ID, "email"); err != nil {
  222. return fmt.Errorf("apply email first bind defaults: %w", err)
  223. }
  224. }
  225. updatedUser, err := client.User.Get(ctx, currentUser.ID)
  226. if err != nil {
  227. return ErrServiceUnavailable
  228. }
  229. currentUser.Email = updatedUser.Email
  230. currentUser.PasswordHash = updatedUser.PasswordHash
  231. currentUser.Balance = updatedUser.Balance
  232. currentUser.Concurrency = updatedUser.Concurrency
  233. currentUser.UpdatedAt = updatedUser.UpdatedAt
  234. return nil
  235. }
  236. func (s *AuthService) revokeEmailIdentitySessions(ctx context.Context, userID int64) {
  237. if err := s.RevokeAllUserSessions(ctx, userID); err != nil {
  238. logger.LegacyPrintf("service.auth", "[Auth] Failed to revoke refresh sessions after email identity bind for user %d: %v", userID, err)
  239. }
  240. }
  241. func replaceBoundEmailAuthIdentityWithClient(
  242. ctx context.Context,
  243. client *dbent.Client,
  244. userID int64,
  245. oldEmail string,
  246. newEmail string,
  247. source string,
  248. ) error {
  249. newSubject := normalizeBoundEmailAuthIdentitySubject(newEmail)
  250. if err := ensureBoundEmailAuthIdentityWithClient(ctx, client, userID, newSubject, source); err != nil {
  251. return err
  252. }
  253. oldSubject := normalizeBoundEmailAuthIdentitySubject(oldEmail)
  254. if oldSubject == "" || oldSubject == newSubject {
  255. return nil
  256. }
  257. _, err := client.AuthIdentity.Delete().
  258. Where(
  259. authidentity.UserIDEQ(userID),
  260. authidentity.ProviderTypeEQ("email"),
  261. authidentity.ProviderKeyEQ("email"),
  262. authidentity.ProviderSubjectEQ(oldSubject),
  263. ).
  264. Exec(ctx)
  265. return err
  266. }
  267. func ensureBoundEmailAuthIdentityWithClient(
  268. ctx context.Context,
  269. client *dbent.Client,
  270. userID int64,
  271. subject string,
  272. source string,
  273. ) error {
  274. if client == nil || userID <= 0 || subject == "" {
  275. return nil
  276. }
  277. if strings.TrimSpace(source) == "" {
  278. source = "auth_service_email_bind"
  279. }
  280. if err := client.AuthIdentity.Create().
  281. SetUserID(userID).
  282. SetProviderType("email").
  283. SetProviderKey("email").
  284. SetProviderSubject(subject).
  285. SetVerifiedAt(time.Now().UTC()).
  286. SetMetadata(map[string]any{"source": strings.TrimSpace(source)}).
  287. OnConflictColumns(
  288. authidentity.FieldProviderType,
  289. authidentity.FieldProviderKey,
  290. authidentity.FieldProviderSubject,
  291. ).
  292. DoNothing().
  293. Exec(ctx); err != nil {
  294. if !isSQLNoRowsError(err) {
  295. return err
  296. }
  297. }
  298. identity, err := client.AuthIdentity.Query().
  299. Where(
  300. authidentity.ProviderTypeEQ("email"),
  301. authidentity.ProviderKeyEQ("email"),
  302. authidentity.ProviderSubjectEQ(subject),
  303. ).
  304. Only(ctx)
  305. if err != nil {
  306. if dbent.IsNotFound(err) {
  307. return nil
  308. }
  309. return err
  310. }
  311. if identity.UserID != userID {
  312. return ErrEmailExists
  313. }
  314. return nil
  315. }
  316. func normalizeBoundEmailAuthIdentitySubject(email string) string {
  317. normalized := strings.ToLower(strings.TrimSpace(email))
  318. if normalized == "" || isReservedEmail(normalized) {
  319. return ""
  320. }
  321. return normalized
  322. }