updates email

This commit is contained in:
2026-08-20 02:31:14 +07:00
parent be15264764
commit 4ff43cc2dd
8 changed files with 802 additions and 64 deletions
+93 -17
View File
@@ -12,7 +12,10 @@ import (
"evening_detective_server/internal/repos/user_agreements_repo"
"evening_detective_server/internal/repos/users_repo"
"fmt"
"log"
"net/mail"
"slices"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
@@ -29,8 +32,28 @@ const (
privacyVersion = "1.0"
)
// usersRepository — граница доступа к данным пользователей, реализуемая
// *users_repo.UsersRepo; интерфейс позволяет тестировать сервис без БД.
type usersRepository interface {
AddUserWithAgreements(
ctx context.Context,
username string,
email string,
passwordHash string,
roles []string,
agreements []user_agreements_repo.Agreement,
) (int, error)
UpdateUserPassword(ctx context.Context, email string, passwordHash string) error
GetUserByEmail(ctx context.Context, email string) (*repos.User, error)
GetUserByID(ctx context.Context, id int) (*repos.User, error)
GetUsers(ctx context.Context) ([]*repos.User, error)
AddUserRole(ctx context.Context, userId int, role string) error
DeleteUserRole(ctx context.Context, userId int, role string) error
DeleteUser(ctx context.Context, userId int) error
}
type UsersService struct {
usersRepo *users_repo.UsersRepo
usersRepo usersRepository
passwordGenerator password_generator.IPasswordGenerator
emailSender email_sender.IEmailSender
processorJWT processor_jwt.IProcessorJWT
@@ -38,7 +61,7 @@ type UsersService struct {
}
func NewUsersService(
usersRepo *users_repo.UsersRepo,
usersRepo usersRepository,
passwordGenerator password_generator.IPasswordGenerator,
emailSender email_sender.IEmailSender,
processorJWT processor_jwt.IProcessorJWT,
@@ -69,6 +92,11 @@ func (s *UsersService) AddUser(
return ErrTermsNotAccepted
}
email = normalizeEmail(email)
if err := validateEmail(email); err != nil {
return err
}
password, err := s.passwordGenerator.Generate()
if err != nil {
return err
@@ -79,7 +107,7 @@ func (s *UsersService) AddUser(
}
// Создание пользователя и фиксация акцептов — в одной транзакции:
// без записей о согласиях пользователь не существует.
_, err = s.usersRepo.AddUserWithAgreements(
id, err := s.usersRepo.AddUserWithAgreements(
ctx,
username,
email,
@@ -104,13 +132,25 @@ func (s *UsersService) AddUser(
return err
}
// Консистентность ответа и состояния БД: если письмо с паролем не
// ушло, пользователь не должен оставаться в системе «полусозданным»
// (клиент получит ошибку и повторит регистрацию). Компенсация —
// удаление созданного пользователя. Выполняется вне отменённого
// контекста: письмо могло упасть именно из-за истечения таймаута ctx,
// и тогда тот же ctx не дал бы выполнить удаление.
err = s.emailSender.Send(ctx, email_sender.Message{
To: email,
Subject: "Приветствую тебя, детектив!",
Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password),
})
if err != nil {
if delErr := s.usersRepo.DeleteUser(context.WithoutCancel(ctx), id); delErr != nil {
log.Printf("email: отправка не удалась (%v) и компенсация не выполнена: %v", err, delErr)
}
return fmt.Errorf("email send: %w", err)
}
return err
return nil
}
// DeleteAccount удаляет учётную запись и все персональные данные пользователя
@@ -135,6 +175,21 @@ func (s *UsersService) RefreshPassword(
ctx context.Context,
email string,
) error {
email = normalizeEmail(email)
if err := validateEmail(email); err != nil {
return err
}
// Анти-enumeration: ответ одинаков для существующего и несуществующего
// адреса, письмо отправляется только реальному пользователю.
user, err := s.usersRepo.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, users_repo.ErrUserNotFound) {
return nil
}
return err
}
password, err := s.passwordGenerator.Generate()
if err != nil {
return err
@@ -144,22 +199,41 @@ func (s *UsersService) RefreshPassword(
return err
}
err = s.usersRepo.UpdateUserPassword(
ctx,
email,
string(hashedPassword),
)
if err != nil {
return err
}
// Письмо отправляется ДО смены пароля: при сбое отправки пароль
// остаётся прежним и аккаунт не блокируется (иначе старый пароль уже
// не работает, а новый пользователь так и не узнал).
err = s.emailSender.Send(ctx, email_sender.Message{
To: email,
Subject: "Приветствую тебя, детектив!",
To: user.Email,
Subject: "Сброс пароля",
Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password),
})
if err != nil {
return fmt.Errorf("email send: %w", err)
}
return err
return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword))
}
// normalizeEmail приводит адрес к каноническому виду (без пробелов, нижний
// регистр): "User@Example.COM " и "user@example.com" — один аккаунт.
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// validateEmail проверяет, что строка является одиночным email-адресом.
func validateEmail(email string) error {
if email == "" {
return errors.New("Email не указан")
}
addr, err := mail.ParseAddress(email)
if err != nil {
return errors.New("Некорректный email")
}
// Отклоняем форму "Имя <a@b.c>" и всё, что не является чистым адресом.
if !strings.EqualFold(addr.Address, email) {
return errors.New("Некорректный email")
}
return nil
}
func (s *UsersService) Login(
@@ -167,9 +241,11 @@ func (s *UsersService) Login(
email string,
password string,
) (string, string, error) {
// Email нормализуется так же, как при регистрации: иначе пользователь,
// зарегистрировавшийся как user@example.com, не войдёт с User@Example.COM.
user, err := s.usersRepo.GetUserByEmail(
ctx,
email,
normalizeEmail(email),
)
if err != nil {
return "", "", err