feat: complete email binding and pending oauth verification flows
This commit is contained in:
128
backend/internal/service/auth_email_binding.go
Normal file
128
backend/internal/service/auth_email_binding.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// BindEmailIdentity verifies and binds a local email/password identity to the current user.
|
||||
func (s *AuthService) BindEmailIdentity(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
email string,
|
||||
verifyCode string,
|
||||
password string,
|
||||
) (*User, error) {
|
||||
if s == nil {
|
||||
return nil, ErrServiceUnavailable
|
||||
}
|
||||
|
||||
normalizedEmail, err := normalizeEmailForIdentityBinding(email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isReservedEmail(normalizedEmail) {
|
||||
return nil, ErrEmailReserved
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return nil, ErrPasswordRequired
|
||||
}
|
||||
if err := s.VerifyOAuthEmailCode(ctx, normalizedEmail, verifyCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currentUser, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail)
|
||||
switch {
|
||||
case err == nil && existingUser != nil && existingUser.ID != userID:
|
||||
return nil, ErrEmailExists
|
||||
case err != nil && !errors.Is(err, ErrUserNotFound):
|
||||
return nil, ErrServiceUnavailable
|
||||
}
|
||||
|
||||
hashedPassword, err := s.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
firstRealEmailBind := !hasBindableEmailIdentitySubject(currentUser.Email)
|
||||
currentUser.Email = normalizedEmail
|
||||
currentUser.PasswordHash = hashedPassword
|
||||
if err := s.userRepo.Update(ctx, currentUser); err != nil {
|
||||
if errors.Is(err, ErrEmailExists) {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
return nil, ErrServiceUnavailable
|
||||
}
|
||||
|
||||
if firstRealEmailBind {
|
||||
if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, userID, "email"); err != nil {
|
||||
return nil, fmt.Errorf("apply email first bind defaults: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return currentUser, nil
|
||||
}
|
||||
|
||||
// SendEmailIdentityBindCode sends a verification code for authenticated email binding flows.
|
||||
func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int64, email string) error {
|
||||
if s == nil {
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
|
||||
normalizedEmail, err := normalizeEmailForIdentityBinding(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isReservedEmail(normalizedEmail) {
|
||||
return ErrEmailReserved
|
||||
}
|
||||
if s.emailService == nil {
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
|
||||
existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail)
|
||||
switch {
|
||||
case err == nil && existingUser != nil && existingUser.ID != userID:
|
||||
return ErrEmailExists
|
||||
case err != nil && !errors.Is(err, ErrUserNotFound):
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
|
||||
siteName := "Sub2API"
|
||||
if s.settingService != nil {
|
||||
siteName = s.settingService.GetSiteName(ctx)
|
||||
}
|
||||
return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName)
|
||||
}
|
||||
|
||||
func normalizeEmailForIdentityBinding(email string) (string, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
if normalized == "" || len(normalized) > 255 {
|
||||
return "", infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
|
||||
}
|
||||
if _, err := mail.ParseAddress(normalized); err != nil {
|
||||
return "", infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func hasBindableEmailIdentitySubject(email string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
return normalized != "" && !isReservedEmail(normalized)
|
||||
}
|
||||
Reference in New Issue
Block a user