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)
|
||||
}
|
||||
Reference in New Issue
Block a user