feat: add pending oauth email onboarding flow
This commit is contained in:
151
backend/internal/service/auth_oauth_email_flow.go
Normal file
151
backend/internal/service/auth_oauth_email_flow.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
return nil, nil, ErrRegDisabled
|
||||
}
|
||||
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
if isReservedEmail(email) {
|
||||
return nil, nil, ErrEmailReserved
|
||||
}
|
||||
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := s.VerifyOAuthEmailCode(ctx, email, verifyCode); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var invitationRedeemCode *RedeemCode
|
||||
if s.settingService.IsInvitationCodeEnabled(ctx) {
|
||||
if invitationCode == "" {
|
||||
return nil, nil, ErrInvitationCodeRequired
|
||||
}
|
||||
redeemCode, err := s.redeemRepo.GetByCode(ctx, invitationCode)
|
||||
if err != nil {
|
||||
return nil, nil, ErrInvitationCodeInvalid
|
||||
}
|
||||
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
|
||||
return nil, nil, ErrInvitationCodeInvalid
|
||||
}
|
||||
invitationRedeemCode = redeemCode
|
||||
}
|
||||
|
||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, nil, ErrServiceUnavailable
|
||||
}
|
||||
if existsEmail {
|
||||
return nil, nil, ErrEmailExists
|
||||
}
|
||||
|
||||
hashedPassword, err := s.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
signupSource = strings.TrimSpace(strings.ToLower(signupSource))
|
||||
if signupSource == "" {
|
||||
signupSource = "email"
|
||||
}
|
||||
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
||||
|
||||
user := &User{
|
||||
Email: email,
|
||||
PasswordHash: hashedPassword,
|
||||
Role: RoleUser,
|
||||
Balance: grantPlan.Balance,
|
||||
Concurrency: grantPlan.Concurrency,
|
||||
Status: StatusActive,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
if errors.Is(err, ErrEmailExists) {
|
||||
return nil, nil, ErrEmailExists
|
||||
}
|
||||
return nil, nil, ErrServiceUnavailable
|
||||
}
|
||||
|
||||
s.postAuthUserBootstrap(ctx, user, signupSource, true)
|
||||
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
|
||||
|
||||
if invitationRedeemCode != nil {
|
||||
if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil {
|
||||
return nil, nil, ErrInvitationCodeInvalid
|
||||
}
|
||||
}
|
||||
|
||||
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate token pair: %w", err)
|
||||
}
|
||||
return tokenPair, user, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
s.touchUserLogin(ctx, userID)
|
||||
}
|
||||
@@ -217,6 +217,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
keys := []string{
|
||||
SettingKeyRegistrationEnabled,
|
||||
SettingKeyEmailVerifyEnabled,
|
||||
SettingKeyForceEmailOnThirdPartySignup,
|
||||
SettingKeyRegistrationEmailSuffixWhitelist,
|
||||
SettingKeyPromoCodeEnabled,
|
||||
SettingKeyPasswordResetEnabled,
|
||||
@@ -294,6 +295,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
return &PublicSettings{
|
||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||
EmailVerifyEnabled: emailVerifyEnabled,
|
||||
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
||||
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||
PasswordResetEnabled: passwordResetEnabled,
|
||||
|
||||
@@ -77,3 +77,16 @@ func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T)
|
||||
require.Equal(t, 50, settings.TableDefaultPageSize)
|
||||
require.Equal(t, []int{20, 50, 100}, settings.TablePageSizeOptions)
|
||||
}
|
||||
|
||||
func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) {
|
||||
repo := &settingPublicRepoStub{
|
||||
values: map[string]string{
|
||||
SettingKeyForceEmailOnThirdPartySignup: "true",
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
settings, err := svc.GetPublicSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.True(t, settings.ForceEmailOnThirdPartySignup)
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ type DefaultSubscriptionSetting struct {
|
||||
type PublicSettings struct {
|
||||
RegistrationEnabled bool
|
||||
EmailVerifyEnabled bool
|
||||
ForceEmailOnThirdPartySignup bool
|
||||
RegistrationEmailSuffixWhitelist []string
|
||||
PromoCodeEnabled bool
|
||||
PasswordResetEnabled bool
|
||||
|
||||
Reference in New Issue
Block a user