auth_oauth_email_flow.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "net/mail"
  8. "strings"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/ent/redeemcode"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. )
  14. func normalizeOAuthSignupSource(signupSource string) string {
  15. signupSource = strings.TrimSpace(strings.ToLower(signupSource))
  16. switch signupSource {
  17. case "", "email":
  18. return "email"
  19. case "linuxdo", "wechat", "oidc", "github", "google", "dingtalk":
  20. return signupSource
  21. default:
  22. return "email"
  23. }
  24. }
  25. // SendPendingOAuthVerifyCode sends a local verification code for pending OAuth
  26. // account-creation flows without relying on the public registration gate.
  27. func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) {
  28. email = strings.TrimSpace(strings.ToLower(email))
  29. if email == "" {
  30. return nil, ErrEmailVerifyRequired
  31. }
  32. if _, err := mail.ParseAddress(email); err != nil {
  33. return nil, ErrEmailVerifyRequired
  34. }
  35. if isReservedEmail(email) {
  36. return nil, ErrEmailReserved
  37. }
  38. if s == nil || s.emailService == nil {
  39. return nil, ErrServiceUnavailable
  40. }
  41. if err := s.validateRegistrationEmailQuota(ctx, email); err != nil {
  42. return nil, err
  43. }
  44. siteName := "Sub2API"
  45. if s.settingService != nil {
  46. siteName = s.settingService.GetSiteName(ctx)
  47. }
  48. if err := s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale)); err != nil {
  49. return nil, err
  50. }
  51. return &SendVerifyCodeResult{
  52. Countdown: int(verifyCodeCooldown / time.Second),
  53. }, nil
  54. }
  55. func (s *AuthService) validateOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
  56. if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
  57. return nil, nil
  58. }
  59. if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
  60. return nil, ErrServiceUnavailable
  61. }
  62. invitationCode = strings.TrimSpace(invitationCode)
  63. if invitationCode == "" {
  64. return nil, ErrInvitationCodeRequired
  65. }
  66. redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
  67. if err != nil {
  68. return nil, ErrInvitationCodeInvalid
  69. }
  70. if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() {
  71. return nil, ErrInvitationCodeInvalid
  72. }
  73. return redeemCode, nil
  74. }
  75. // VerifyOAuthEmailCode verifies the locally entered email verification code for
  76. // third-party signup and binding flows. This is intentionally independent from
  77. // the global registration email verification toggle.
  78. func (s *AuthService) VerifyOAuthEmailCode(ctx context.Context, email, verifyCode string) error {
  79. email = strings.TrimSpace(strings.ToLower(email))
  80. verifyCode = strings.TrimSpace(verifyCode)
  81. if email == "" {
  82. return ErrEmailVerifyRequired
  83. }
  84. if verifyCode == "" {
  85. return ErrEmailVerifyRequired
  86. }
  87. if s == nil || s.emailService == nil {
  88. return ErrServiceUnavailable
  89. }
  90. return s.emailService.VerifyCode(ctx, email, verifyCode)
  91. }
  92. // RegisterOAuthEmailAccount creates a local account from a third-party first
  93. // login after the user has verified a local email address.
  94. func (s *AuthService) RegisterOAuthEmailAccount(
  95. ctx context.Context,
  96. email string,
  97. password string,
  98. verifyCode string,
  99. invitationCode string,
  100. signupSource string,
  101. ) (*TokenPair, *User, error) {
  102. if s == nil {
  103. return nil, nil, ErrServiceUnavailable
  104. }
  105. if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
  106. return nil, nil, ErrRegDisabled
  107. }
  108. email = strings.TrimSpace(strings.ToLower(email))
  109. if isReservedEmail(email) {
  110. return nil, nil, ErrEmailReserved
  111. }
  112. if err := s.VerifyOAuthEmailCode(ctx, email, verifyCode); err != nil {
  113. slog.Error("oauth email register: verify code failed", "email", email, "error", err.Error())
  114. return nil, nil, err
  115. }
  116. if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
  117. slog.Error("oauth email register: invitation failed", "email", email, "error", err.Error())
  118. return nil, nil, err
  119. }
  120. // 含 +别名 / Gmail 点号 / FQDN 根点变体归一化:该路径同样发放注册赠额,不能被单个收件箱刷号。
  121. existsEmail, err := s.existsByEmailOrAlias(ctx, email)
  122. if err != nil {
  123. slog.Error("oauth email register: ExistsByEmail failed", "email", email, "error", err.Error())
  124. return nil, nil, ErrServiceUnavailable
  125. }
  126. if existsEmail {
  127. return nil, nil, ErrEmailExists
  128. }
  129. if err := s.validateRegistrationEmailQuota(ctx, email); err != nil {
  130. slog.Error("oauth email register: policy rejected", "email", email, "error", err.Error())
  131. return nil, nil, err
  132. }
  133. hashedPassword, err := s.HashPassword(password)
  134. if err != nil {
  135. return nil, nil, fmt.Errorf("hash password: %w", err)
  136. }
  137. signupSource = normalizeOAuthSignupSource(signupSource)
  138. grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
  139. user := &User{
  140. Email: email,
  141. PasswordHash: hashedPassword,
  142. Role: RoleUser,
  143. Balance: grantPlan.Balance,
  144. Concurrency: grantPlan.Concurrency,
  145. Status: StatusActive,
  146. SignupSource: signupSource,
  147. }
  148. if err := s.createUserWithRegistrationEmailGuard(ctx, user); err != nil {
  149. switch {
  150. case errors.Is(err, ErrEmailExists):
  151. return nil, nil, ErrEmailExists
  152. case errors.Is(err, ErrEmailDomainRegistrationLimit):
  153. return nil, nil, ErrEmailDomainRegistrationLimit
  154. default:
  155. slog.Error("oauth email register: userRepo.Create failed", "email", email, "signup_source", signupSource, "error", err.Error())
  156. return nil, nil, ErrServiceUnavailable
  157. }
  158. }
  159. tokenPair, err := s.GenerateTokenPair(ctx, user, "")
  160. if err != nil {
  161. _ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
  162. return nil, nil, fmt.Errorf("generate token pair: %w", err)
  163. }
  164. return tokenPair, user, nil
  165. }
  166. // RegisterVerifiedOAuthEmailAccount creates a local account from an OAuth
  167. // provider that has already returned a verified email address.
  168. func (s *AuthService) RegisterVerifiedOAuthEmailAccount(
  169. ctx context.Context,
  170. email string,
  171. password string,
  172. invitationCode string,
  173. signupSource string,
  174. ) (*TokenPair, *User, error) {
  175. if s == nil {
  176. return nil, nil, ErrServiceUnavailable
  177. }
  178. if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
  179. return nil, nil, ErrRegDisabled
  180. }
  181. email = strings.TrimSpace(strings.ToLower(email))
  182. if email == "" || len(email) > 255 {
  183. return nil, nil, ErrEmailVerifyRequired
  184. }
  185. if _, err := mail.ParseAddress(email); err != nil {
  186. return nil, nil, ErrEmailVerifyRequired
  187. }
  188. if isReservedEmail(email) {
  189. return nil, nil, ErrEmailReserved
  190. }
  191. if strings.TrimSpace(password) == "" {
  192. return nil, nil, infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required")
  193. }
  194. if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
  195. return nil, nil, err
  196. }
  197. // 与本地注册同口径:同一收件箱的别名变体不能各自建号(该路径也发放注册赠额)。
  198. existsEmail, err := s.existsByEmailOrAlias(ctx, email)
  199. if err != nil {
  200. return nil, nil, ErrServiceUnavailable
  201. }
  202. if existsEmail {
  203. return nil, nil, ErrEmailExists
  204. }
  205. if err := s.validateRegistrationEmailQuota(ctx, email); err != nil {
  206. return nil, nil, err
  207. }
  208. hashedPassword, err := s.HashPassword(password)
  209. if err != nil {
  210. return nil, nil, fmt.Errorf("hash password: %w", err)
  211. }
  212. signupSource = normalizeOAuthSignupSource(signupSource)
  213. grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
  214. var defaultRPMLimit int
  215. if s.settingService != nil {
  216. defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
  217. }
  218. user := &User{
  219. Email: email,
  220. PasswordHash: hashedPassword,
  221. Role: RoleUser,
  222. Balance: grantPlan.Balance,
  223. Concurrency: grantPlan.Concurrency,
  224. RPMLimit: defaultRPMLimit,
  225. Status: StatusActive,
  226. SignupSource: signupSource,
  227. }
  228. if err := s.createUserWithRegistrationEmailGuard(ctx, user); err != nil {
  229. switch {
  230. case errors.Is(err, ErrEmailExists):
  231. return nil, nil, ErrEmailExists
  232. case errors.Is(err, ErrEmailDomainRegistrationLimit):
  233. return nil, nil, ErrEmailDomainRegistrationLimit
  234. default:
  235. return nil, nil, ErrServiceUnavailable
  236. }
  237. }
  238. tokenPair, err := s.GenerateTokenPair(ctx, user, "")
  239. if err != nil {
  240. _ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
  241. return nil, nil, fmt.Errorf("generate token pair: %w", err)
  242. }
  243. return tokenPair, user, nil
  244. }
  245. // FinalizeOAuthEmailAccount applies invitation usage and normal signup bootstrap
  246. // only after the pending OAuth flow has fully reached its last reversible step.
  247. func (s *AuthService) FinalizeOAuthEmailAccount(
  248. ctx context.Context,
  249. user *User,
  250. invitationCode string,
  251. signupSource string,
  252. affiliateCode string,
  253. ) error {
  254. if s == nil || user == nil || user.ID <= 0 {
  255. return ErrServiceUnavailable
  256. }
  257. signupSource = normalizeOAuthSignupSource(signupSource)
  258. invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
  259. if err != nil {
  260. return err
  261. }
  262. if invitationRedeemCode != nil {
  263. if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
  264. return ErrInvitationCodeInvalid
  265. }
  266. }
  267. s.updateOAuthSignupSource(ctx, user.ID, signupSource)
  268. grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
  269. s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
  270. // snapshot user × platform quota(fail-open)
  271. _ = s.snapshotPlatformQuotaDefaults(ctx, user.ID, &grantPlan)
  272. s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
  273. return nil
  274. }
  275. // RollbackOAuthEmailAccountCreation removes a partially-created local account
  276. // and restores any invitation code already consumed by that account.
  277. func (s *AuthService) RollbackOAuthEmailAccountCreation(ctx context.Context, userID int64, invitationCode string) error {
  278. if s == nil || s.userRepo == nil || userID <= 0 {
  279. return ErrServiceUnavailable
  280. }
  281. if err := s.restoreOAuthRegistrationInvitation(ctx, invitationCode, userID); err != nil {
  282. return err
  283. }
  284. if err := s.userRepo.Delete(ctx, userID); err != nil {
  285. return fmt.Errorf("delete created oauth user: %w", err)
  286. }
  287. return nil
  288. }
  289. func (s *AuthService) restoreOAuthRegistrationInvitation(ctx context.Context, invitationCode string, userID int64) error {
  290. if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
  291. return nil
  292. }
  293. if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
  294. return ErrServiceUnavailable
  295. }
  296. invitationCode = strings.TrimSpace(invitationCode)
  297. if invitationCode == "" || userID <= 0 {
  298. return nil
  299. }
  300. redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
  301. if err != nil {
  302. if errors.Is(err, ErrRedeemCodeNotFound) {
  303. return nil
  304. }
  305. return fmt.Errorf("load invitation code: %w", err)
  306. }
  307. if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUsed || redeemCode.UsedBy == nil || *redeemCode.UsedBy != userID {
  308. return nil
  309. }
  310. redeemCode.Status = StatusUnused
  311. redeemCode.UsedBy = nil
  312. redeemCode.UsedAt = nil
  313. if err := s.updateOAuthRegistrationInvitation(ctx, redeemCode); err != nil {
  314. return fmt.Errorf("restore invitation code: %w", err)
  315. }
  316. return nil
  317. }
  318. func (s *AuthService) oauthEmailFlowClient(ctx context.Context) *dbent.Client {
  319. if s == nil || s.entClient == nil {
  320. return nil
  321. }
  322. if tx := dbent.TxFromContext(ctx); tx != nil {
  323. return tx.Client()
  324. }
  325. return s.entClient
  326. }
  327. func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
  328. if client := s.oauthEmailFlowClient(ctx); client != nil {
  329. entity, err := client.RedeemCode.Query().Where(redeemcode.CodeEQ(invitationCode)).Only(ctx)
  330. if err != nil {
  331. if dbent.IsNotFound(err) {
  332. return nil, ErrRedeemCodeNotFound
  333. }
  334. return nil, err
  335. }
  336. return &RedeemCode{
  337. ID: entity.ID,
  338. Code: entity.Code,
  339. Type: entity.Type,
  340. Value: entity.Value,
  341. Status: entity.Status,
  342. UsedBy: entity.UsedBy,
  343. UsedAt: entity.UsedAt,
  344. Notes: oauthEmailFlowStringValue(entity.Notes),
  345. CreatedAt: entity.CreatedAt,
  346. ExpiresAt: entity.ExpiresAt,
  347. GroupID: entity.GroupID,
  348. ValidityDays: entity.ValidityDays,
  349. }, nil
  350. }
  351. return s.redeemRepo.GetByCode(ctx, invitationCode)
  352. }
  353. func (s *AuthService) useOAuthRegistrationInvitation(ctx context.Context, invitationID, userID int64) error {
  354. if client := s.oauthEmailFlowClient(ctx); client != nil {
  355. affected, err := client.RedeemCode.Update().
  356. Where(
  357. redeemcode.IDEQ(invitationID),
  358. redeemcode.StatusEQ(StatusUnused),
  359. redeemcode.Or(redeemcode.ExpiresAtIsNil(), redeemcode.ExpiresAtGT(time.Now().UTC())),
  360. ).
  361. SetStatus(StatusUsed).
  362. SetUsedBy(userID).
  363. SetUsedAt(time.Now().UTC()).
  364. Save(ctx)
  365. if err != nil {
  366. return err
  367. }
  368. if affected == 0 {
  369. return ErrRedeemCodeUsed
  370. }
  371. return nil
  372. }
  373. return s.redeemRepo.Use(ctx, invitationID, userID)
  374. }
  375. func (s *AuthService) updateOAuthRegistrationInvitation(ctx context.Context, code *RedeemCode) error {
  376. if code == nil {
  377. return nil
  378. }
  379. if client := s.oauthEmailFlowClient(ctx); client != nil {
  380. update := client.RedeemCode.UpdateOneID(code.ID).
  381. SetCode(code.Code).
  382. SetType(code.Type).
  383. SetValue(code.Value).
  384. SetStatus(code.Status).
  385. SetNotes(code.Notes).
  386. SetValidityDays(code.ValidityDays)
  387. if code.ExpiresAt != nil {
  388. update = update.SetExpiresAt(*code.ExpiresAt)
  389. } else {
  390. update = update.ClearExpiresAt()
  391. }
  392. if code.UsedBy != nil {
  393. update = update.SetUsedBy(*code.UsedBy)
  394. } else {
  395. update = update.ClearUsedBy()
  396. }
  397. if code.UsedAt != nil {
  398. update = update.SetUsedAt(*code.UsedAt)
  399. } else {
  400. update = update.ClearUsedAt()
  401. }
  402. if code.GroupID != nil {
  403. update = update.SetGroupID(*code.GroupID)
  404. } else {
  405. update = update.ClearGroupID()
  406. }
  407. _, err := update.Save(ctx)
  408. return err
  409. }
  410. return s.redeemRepo.Update(ctx, code)
  411. }
  412. func (s *AuthService) updateOAuthSignupSource(ctx context.Context, userID int64, signupSource string) {
  413. client := s.oauthEmailFlowClient(ctx)
  414. if client == nil || userID <= 0 || strings.TrimSpace(signupSource) == "" {
  415. return
  416. }
  417. _ = client.User.UpdateOneID(userID).SetSignupSource(signupSource).Exec(ctx)
  418. }
  419. func oauthEmailFlowStringValue(value *string) string {
  420. if value == nil {
  421. return ""
  422. }
  423. return *value
  424. }
  425. // ValidatePasswordCredentials checks the local password without completing the
  426. // login flow. This is used by pending third-party account adoption flows before
  427. // the external identity has been bound.
  428. func (s *AuthService) ValidatePasswordCredentials(ctx context.Context, email, password string) (*User, error) {
  429. if s == nil {
  430. return nil, ErrServiceUnavailable
  431. }
  432. user, err := s.userRepo.GetByEmail(ctx, strings.TrimSpace(strings.ToLower(email)))
  433. if err != nil {
  434. if errors.Is(err, ErrUserNotFound) {
  435. return nil, ErrInvalidCredentials
  436. }
  437. return nil, ErrServiceUnavailable
  438. }
  439. if !user.IsActive() {
  440. return nil, ErrUserNotActive
  441. }
  442. if !s.CheckPassword(password, user.PasswordHash) {
  443. return nil, ErrInvalidCredentials
  444. }
  445. return user, nil
  446. }
  447. // RecordSuccessfulLogin updates last-login activity after a non-standard login
  448. // flow finishes with a real session.
  449. func (s *AuthService) RecordSuccessfulLogin(ctx context.Context, userID int64) {
  450. if s != nil && s.userRepo != nil && userID > 0 {
  451. user, err := s.userRepo.GetByID(ctx, userID)
  452. if err == nil && user != nil && !isReservedEmail(user.Email) {
  453. s.backfillEmailIdentityOnSuccessfulLogin(ctx, user)
  454. }
  455. }
  456. s.touchUserLogin(ctx, userID)
  457. }