feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
This commit is contained in:
299
internal/service/auth_contact_binding.go
Normal file
299
internal/service/auth_contact_binding.go
Normal file
@@ -0,0 +1,299 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user