300 lines
7.3 KiB
Go
300 lines
7.3 KiB
Go
|
|
package service
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/user-management-system/internal/domain"
|
||
|
|
)
|
||
|
|
|
||
|
|
func (s *AuthService) SendEmailBindCode(ctx context.Context, userID int64, email string) error {
|
||
|
|
if s == nil || s.userRepo == nil || s.emailCodeSvc == nil {
|
||
|
|
return errors.New("email binding is not configured")
|
||
|
|
}
|
||
|
|
|
||
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := s.ensureUserActive(user); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
normalizedEmail := strings.TrimSpace(email)
|
||
|
|
if normalizedEmail == "" {
|
||
|
|
return errors.New("email is required")
|
||
|
|
}
|
||
|
|
if strings.EqualFold(strings.TrimSpace(domain.DerefStr(user.Email)), normalizedEmail) {
|
||
|
|
return errors.New("email is already bound to the current account")
|
||
|
|
}
|
||
|
|
|
||
|
|
exists, err := s.userRepo.ExistsByEmail(ctx, normalizedEmail)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if exists {
|
||
|
|
return errors.New("email already in use")
|
||
|
|
}
|
||
|
|
|
||
|
|
return s.emailCodeSvc.SendEmailCode(ctx, normalizedEmail, "bind")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AuthService) BindEmail(
|
||
|
|
ctx context.Context,
|
||
|
|
userID int64,
|
||
|
|
email string,
|
||
|
|
code string,
|
||
|
|
currentPassword string,
|
||
|
|
totpCode string,
|
||
|
|
) error {
|
||
|
|
if s == nil || s.userRepo == nil || s.emailCodeSvc == nil {
|
||
|
|
return errors.New("email binding is not configured")
|
||
|
|
}
|
||
|
|
|
||
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := s.ensureUserActive(user); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
normalizedEmail := strings.TrimSpace(email)
|
||
|
|
if normalizedEmail == "" {
|
||
|
|
return errors.New("email is required")
|
||
|
|
}
|
||
|
|
if strings.EqualFold(strings.TrimSpace(domain.DerefStr(user.Email)), normalizedEmail) {
|
||
|
|
return errors.New("email is already bound to the current account")
|
||
|
|
}
|
||
|
|
|
||
|
|
exists, err := s.userRepo.ExistsByEmail(ctx, normalizedEmail)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if exists {
|
||
|
|
return errors.New("email already in use")
|
||
|
|
}
|
||
|
|
if err := s.verifySensitiveAction(ctx, user, currentPassword, totpCode); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := s.emailCodeSvc.VerifyEmailCode(ctx, normalizedEmail, "bind", strings.TrimSpace(code)); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
user.Email = domain.StrPtr(normalizedEmail)
|
||
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
s.cacheUserInfo(ctx, user)
|
||
|
|
s.publishEvent(ctx, domain.EventUserUpdated, map[string]interface{}{
|
||
|
|
"user_id": user.ID,
|
||
|
|
"email": normalizedEmail,
|
||
|
|
"action": "bind_email",
|
||
|
|
})
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AuthService) UnbindEmail(ctx context.Context, userID int64, currentPassword, totpCode string) error {
|
||
|
|
if s == nil || s.userRepo == nil {
|
||
|
|
return errors.New("email binding is not configured")
|
||
|
|
}
|
||
|
|
|
||
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := s.ensureUserActive(user); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(domain.DerefStr(user.Email)) == "" {
|
||
|
|
return errors.New("email is not bound")
|
||
|
|
}
|
||
|
|
if err := s.verifySensitiveAction(ctx, user, currentPassword, totpCode); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
accounts, err := s.GetSocialAccounts(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if s.availableLoginMethodCountAfterContactRemoval(user, accounts, true, false) == 0 {
|
||
|
|
return errors.New("at least one login method must remain after unbinding")
|
||
|
|
}
|
||
|
|
|
||
|
|
user.Email = nil
|
||
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
s.cacheUserInfo(ctx, user)
|
||
|
|
s.publishEvent(ctx, domain.EventUserUpdated, map[string]interface{}{
|
||
|
|
"user_id": user.ID,
|
||
|
|
"action": "unbind_email",
|
||
|
|
})
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AuthService) SendPhoneBindCode(ctx context.Context, userID int64, phone string) (*SendCodeResponse, error) {
|
||
|
|
if s == nil || s.userRepo == nil || s.smsCodeSvc == nil {
|
||
|
|
return nil, errors.New("phone binding is not configured")
|
||
|
|
}
|
||
|
|
|
||
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := s.ensureUserActive(user); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
normalizedPhone := strings.TrimSpace(phone)
|
||
|
|
if normalizedPhone == "" {
|
||
|
|
return nil, errors.New("phone is required")
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(domain.DerefStr(user.Phone)) == normalizedPhone {
|
||
|
|
return nil, errors.New("phone is already bound to the current account")
|
||
|
|
}
|
||
|
|
|
||
|
|
exists, err := s.userRepo.ExistsByPhone(ctx, normalizedPhone)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if exists {
|
||
|
|
return nil, errors.New("phone already in use")
|
||
|
|
}
|
||
|
|
|
||
|
|
return s.smsCodeSvc.SendCode(ctx, &SendCodeRequest{
|
||
|
|
Phone: normalizedPhone,
|
||
|
|
Purpose: "bind",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AuthService) BindPhone(
|
||
|
|
ctx context.Context,
|
||
|
|
userID int64,
|
||
|
|
phone string,
|
||
|
|
code string,
|
||
|
|
currentPassword string,
|
||
|
|
totpCode string,
|
||
|
|
) error {
|
||
|
|
if s == nil || s.userRepo == nil || s.smsCodeSvc == nil {
|
||
|
|
return errors.New("phone binding is not configured")
|
||
|
|
}
|
||
|
|
|
||
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := s.ensureUserActive(user); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
normalizedPhone := strings.TrimSpace(phone)
|
||
|
|
if normalizedPhone == "" {
|
||
|
|
return errors.New("phone is required")
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(domain.DerefStr(user.Phone)) == normalizedPhone {
|
||
|
|
return errors.New("phone is already bound to the current account")
|
||
|
|
}
|
||
|
|
|
||
|
|
exists, err := s.userRepo.ExistsByPhone(ctx, normalizedPhone)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if exists {
|
||
|
|
return errors.New("phone already in use")
|
||
|
|
}
|
||
|
|
if err := s.verifySensitiveAction(ctx, user, currentPassword, totpCode); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := s.smsCodeSvc.VerifyCode(ctx, normalizedPhone, "bind", strings.TrimSpace(code)); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
user.Phone = domain.StrPtr(normalizedPhone)
|
||
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
s.cacheUserInfo(ctx, user)
|
||
|
|
s.publishEvent(ctx, domain.EventUserUpdated, map[string]interface{}{
|
||
|
|
"user_id": user.ID,
|
||
|
|
"phone": normalizedPhone,
|
||
|
|
"action": "bind_phone",
|
||
|
|
})
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AuthService) UnbindPhone(ctx context.Context, userID int64, currentPassword, totpCode string) error {
|
||
|
|
if s == nil || s.userRepo == nil {
|
||
|
|
return errors.New("phone binding is not configured")
|
||
|
|
}
|
||
|
|
|
||
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := s.ensureUserActive(user); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(domain.DerefStr(user.Phone)) == "" {
|
||
|
|
return errors.New("phone is not bound")
|
||
|
|
}
|
||
|
|
if err := s.verifySensitiveAction(ctx, user, currentPassword, totpCode); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
accounts, err := s.GetSocialAccounts(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if s.availableLoginMethodCountAfterContactRemoval(user, accounts, false, true) == 0 {
|
||
|
|
return errors.New("at least one login method must remain after unbinding")
|
||
|
|
}
|
||
|
|
|
||
|
|
user.Phone = nil
|
||
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
s.cacheUserInfo(ctx, user)
|
||
|
|
s.publishEvent(ctx, domain.EventUserUpdated, map[string]interface{}{
|
||
|
|
"user_id": user.ID,
|
||
|
|
"action": "unbind_phone",
|
||
|
|
})
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AuthService) availableLoginMethodCountAfterContactRemoval(
|
||
|
|
user *domain.User,
|
||
|
|
accounts []*domain.SocialAccount,
|
||
|
|
removeEmail bool,
|
||
|
|
removePhone bool,
|
||
|
|
) int {
|
||
|
|
if user == nil {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
count := 0
|
||
|
|
if strings.TrimSpace(user.Password) != "" {
|
||
|
|
count++
|
||
|
|
}
|
||
|
|
if !removeEmail && s.emailCodeSvc != nil && strings.TrimSpace(domain.DerefStr(user.Email)) != "" {
|
||
|
|
count++
|
||
|
|
}
|
||
|
|
if !removePhone && s.smsCodeSvc != nil && strings.TrimSpace(domain.DerefStr(user.Phone)) != "" {
|
||
|
|
count++
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, account := range accounts {
|
||
|
|
if account == nil || account.Status != domain.SocialAccountStatusActive {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
count++
|
||
|
|
}
|
||
|
|
|
||
|
|
return count
|
||
|
|
}
|