auth_email_oauth_auto.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "net/mail"
  7. "strings"
  8. dbent "github.com/Wei-Shaw/sub2api/ent"
  9. "github.com/Wei-Shaw/sub2api/ent/authidentity"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  12. )
  13. type EmailOAuthIdentityInput struct {
  14. ProviderType string
  15. ProviderKey string
  16. ProviderSubject string
  17. Email string
  18. EmailVerified bool
  19. Username string
  20. DisplayName string
  21. AvatarURL string
  22. UpstreamMetadata map[string]any
  23. }
  24. func (s *AuthService) LoginOrRegisterVerifiedEmailOAuth(ctx context.Context, input EmailOAuthIdentityInput) (*TokenPair, *User, error) {
  25. return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, "", "", "")
  26. }
  27. func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation(
  28. ctx context.Context,
  29. input EmailOAuthIdentityInput,
  30. invitationCode string,
  31. affiliateCode string,
  32. ) (*TokenPair, *User, error) {
  33. return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode, "")
  34. }
  35. func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
  36. ctx context.Context,
  37. input EmailOAuthIdentityInput,
  38. invitationCode string,
  39. affiliateCode string,
  40. promoCode string,
  41. ) (*TokenPair, *User, error) {
  42. return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode, promoCode)
  43. }
  44. func (s *AuthService) loginOrRegisterVerifiedEmailOAuth(
  45. ctx context.Context,
  46. input EmailOAuthIdentityInput,
  47. invitationCode string,
  48. affiliateCode string,
  49. promoCode string,
  50. ) (*TokenPair, *User, error) {
  51. if s == nil || s.userRepo == nil || s.entClient == nil {
  52. return nil, nil, ErrServiceUnavailable
  53. }
  54. providerType := normalizeOAuthSignupSource(input.ProviderType)
  55. if providerType != "github" && providerType != "google" && providerType != "oidc" {
  56. return nil, nil, infraerrors.BadRequest("OAUTH_PROVIDER_INVALID", "oauth provider is invalid")
  57. }
  58. providerKey := strings.TrimSpace(input.ProviderKey)
  59. if providerKey == "" {
  60. providerKey = providerType
  61. }
  62. providerSubject := strings.TrimSpace(input.ProviderSubject)
  63. if providerSubject == "" {
  64. return nil, nil, infraerrors.BadRequest("OAUTH_SUBJECT_MISSING", "oauth subject is missing")
  65. }
  66. if !input.EmailVerified {
  67. return nil, nil, infraerrors.Forbidden("OAUTH_EMAIL_NOT_VERIFIED", "oauth email is not verified")
  68. }
  69. email := strings.TrimSpace(strings.ToLower(input.Email))
  70. if email == "" || len(email) > 255 {
  71. return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
  72. }
  73. if _, err := mail.ParseAddress(email); err != nil {
  74. return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
  75. }
  76. if isReservedEmail(email) {
  77. return nil, nil, ErrEmailReserved
  78. }
  79. if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
  80. return nil, nil, err
  81. }
  82. identityUser, err := s.findEmailOAuthIdentityOwner(ctx, providerType, providerKey, providerSubject)
  83. if err != nil {
  84. return nil, nil, err
  85. }
  86. if identityUser != nil && !strings.EqualFold(strings.TrimSpace(identityUser.Email), email) {
  87. return nil, nil, infraerrors.Conflict("AUTH_IDENTITY_EMAIL_MISMATCH", "oauth identity belongs to a different email")
  88. }
  89. user := identityUser
  90. created := false
  91. if user == nil {
  92. user, err = s.userRepo.GetByEmail(ctx, email)
  93. if err != nil {
  94. if errors.Is(err, ErrUserNotFound) {
  95. user, err = s.createEmailOAuthUser(ctx, email, input.Username, providerType, invitationCode, affiliateCode)
  96. if err != nil {
  97. return nil, nil, err
  98. }
  99. created = true
  100. } else {
  101. logger.LegacyPrintf("service.auth", "[Auth] Database error during %s oauth login: %v", providerType, err)
  102. return nil, nil, ErrServiceUnavailable
  103. }
  104. }
  105. }
  106. if !user.IsActive() {
  107. return nil, nil, ErrUserNotActive
  108. }
  109. if err := s.ensureEmailOAuthIdentity(ctx, user.ID, EmailOAuthIdentityInput{
  110. ProviderType: providerType,
  111. ProviderKey: providerKey,
  112. ProviderSubject: providerSubject,
  113. Email: email,
  114. EmailVerified: input.EmailVerified,
  115. Username: input.Username,
  116. DisplayName: input.DisplayName,
  117. AvatarURL: input.AvatarURL,
  118. UpstreamMetadata: input.UpstreamMetadata,
  119. }); err != nil {
  120. return nil, nil, err
  121. }
  122. if user.Username == "" && strings.TrimSpace(input.Username) != "" {
  123. user.Username = strings.TrimSpace(input.Username)
  124. if err := s.userRepo.Update(ctx, user, UserUpdateFields{Username: true}); err != nil {
  125. logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after %s oauth login: %v", providerType, err)
  126. }
  127. }
  128. if !created {
  129. if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, user.ID, providerType); err != nil {
  130. logger.LegacyPrintf("service.auth", "[Auth] Failed to apply %s first bind defaults: %v", providerType, err)
  131. }
  132. } else {
  133. user = s.applyOAuthSignupPromoCode(ctx, user, promoCode)
  134. }
  135. s.RecordSuccessfulLogin(ctx, user.ID)
  136. tokenPair, err := s.GenerateTokenPair(ctx, user, "")
  137. if err != nil {
  138. return nil, nil, fmt.Errorf("generate token pair: %w", err)
  139. }
  140. return tokenPair, user, nil
  141. }
  142. func (s *AuthService) createEmailOAuthUser(ctx context.Context, email, username, providerType, invitationCode, affiliateCode string) (*User, error) {
  143. if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
  144. return nil, ErrRegDisabled
  145. }
  146. invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
  147. if err != nil {
  148. if errors.Is(err, ErrInvitationCodeRequired) {
  149. return nil, ErrOAuthInvitationRequired
  150. }
  151. return nil, err
  152. }
  153. randomPassword, err := randomHexString(32)
  154. if err != nil {
  155. return nil, ErrServiceUnavailable
  156. }
  157. hashedPassword, err := s.HashPassword(randomPassword)
  158. if err != nil {
  159. return nil, fmt.Errorf("hash password: %w", err)
  160. }
  161. grantPlan := s.resolveSignupGrantPlan(ctx, providerType)
  162. var defaultRPMLimit int
  163. if s.settingService != nil {
  164. defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
  165. }
  166. user := &User{
  167. Email: email,
  168. Username: strings.TrimSpace(username),
  169. PasswordHash: hashedPassword,
  170. Role: RoleUser,
  171. Balance: grantPlan.Balance,
  172. Concurrency: grantPlan.Concurrency,
  173. RPMLimit: defaultRPMLimit,
  174. Status: StatusActive,
  175. SignupSource: providerType,
  176. }
  177. if err := s.userRepo.Create(ctx, user); err != nil {
  178. if errors.Is(err, ErrEmailExists) {
  179. existing, loadErr := s.userRepo.GetByEmail(ctx, email)
  180. if loadErr != nil {
  181. return nil, ErrServiceUnavailable
  182. }
  183. return existing, nil
  184. }
  185. return nil, ErrServiceUnavailable
  186. }
  187. s.postAuthUserBootstrap(ctx, user, providerType, false)
  188. s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
  189. // snapshot user × platform quota(fail-open)
  190. _ = s.snapshotPlatformQuotaDefaults(ctx, user.ID, &grantPlan)
  191. s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
  192. if invitationRedeemCode != nil {
  193. if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
  194. _ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, invitationCode)
  195. return nil, ErrInvitationCodeInvalid
  196. }
  197. }
  198. return user, nil
  199. }
  200. func (s *AuthService) findEmailOAuthIdentityOwner(ctx context.Context, providerType, providerKey, providerSubject string) (*User, error) {
  201. identity, err := s.entClient.AuthIdentity.Query().
  202. Where(
  203. authidentity.ProviderTypeEQ(providerType),
  204. authidentity.ProviderKeyEQ(providerKey),
  205. authidentity.ProviderSubjectEQ(providerSubject),
  206. ).
  207. Only(ctx)
  208. if err != nil {
  209. if dbent.IsNotFound(err) {
  210. return nil, nil
  211. }
  212. return nil, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
  213. }
  214. user, err := s.userRepo.GetByID(ctx, identity.UserID)
  215. if err != nil {
  216. if errors.Is(err, ErrUserNotFound) {
  217. return nil, nil
  218. }
  219. return nil, ErrServiceUnavailable
  220. }
  221. return user, nil
  222. }
  223. func (s *AuthService) ensureEmailOAuthIdentity(ctx context.Context, userID int64, input EmailOAuthIdentityInput) error {
  224. metadata := map[string]any{
  225. "email": strings.TrimSpace(strings.ToLower(input.Email)),
  226. "email_verified": input.EmailVerified,
  227. }
  228. for key, value := range input.UpstreamMetadata {
  229. metadata[key] = value
  230. }
  231. if strings.TrimSpace(input.Username) != "" {
  232. metadata["username"] = strings.TrimSpace(input.Username)
  233. }
  234. if strings.TrimSpace(input.DisplayName) != "" {
  235. metadata["display_name"] = strings.TrimSpace(input.DisplayName)
  236. }
  237. if strings.TrimSpace(input.AvatarURL) != "" {
  238. metadata["avatar_url"] = strings.TrimSpace(input.AvatarURL)
  239. }
  240. providerType := normalizeOAuthSignupSource(input.ProviderType)
  241. providerKey := strings.TrimSpace(input.ProviderKey)
  242. providerSubject := strings.TrimSpace(input.ProviderSubject)
  243. identity, err := s.entClient.AuthIdentity.Query().
  244. Where(
  245. authidentity.ProviderTypeEQ(providerType),
  246. authidentity.ProviderKeyEQ(providerKey),
  247. authidentity.ProviderSubjectEQ(providerSubject),
  248. ).
  249. Only(ctx)
  250. if err != nil && !dbent.IsNotFound(err) {
  251. return infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
  252. }
  253. if identity != nil {
  254. if identity.UserID != userID {
  255. return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
  256. }
  257. _, err = s.entClient.AuthIdentity.UpdateOneID(identity.ID).
  258. SetMetadata(metadata).
  259. Save(ctx)
  260. return err
  261. }
  262. _, err = s.entClient.AuthIdentity.Create().
  263. SetUserID(userID).
  264. SetProviderType(providerType).
  265. SetProviderKey(providerKey).
  266. SetProviderSubject(providerSubject).
  267. SetMetadata(metadata).
  268. Save(ctx)
  269. return err
  270. }