Files
evening_detective_server/internal/services/users_service/service.go
T
2026-08-20 02:31:14 +07:00

368 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package users_service
import (
"context"
"errors"
"evening_detective_server/internal/modules/email_sender"
"evening_detective_server/internal/modules/password_generator"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/roles"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/refresh_tokens_repo"
"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"
)
// Версии документов, акцепт которых фиксируется. Должны совпадать с
// редакциями, опубликованными по постоянным URL (/api/terms и /api/privacy,
// rpc GetTerms/GetPrivacy, текст встраивается из пакета docs): при изменении
// текста документа версия ОБЯЗАНА увеличиваться, иначе повторный акцепт
// не будет зафиксирован как согласие с новой редакцией
// (UNIQUE (user_id, agreement_type, version)).
const (
termsVersion = "1.0"
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 usersRepository
passwordGenerator password_generator.IPasswordGenerator
emailSender email_sender.IEmailSender
processorJWT processor_jwt.IProcessorJWT
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo
}
func NewUsersService(
usersRepo usersRepository,
passwordGenerator password_generator.IPasswordGenerator,
emailSender email_sender.IEmailSender,
processorJWT processor_jwt.IProcessorJWT,
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo,
) *UsersService {
return &UsersService{
usersRepo: usersRepo,
passwordGenerator: passwordGenerator,
emailSender: emailSender,
processorJWT: processorJWT,
refreshTokensRepo: refreshTokensRepo,
}
}
// ErrTermsNotAccepted — регистрация невозможна без акцепта соглашений.
var ErrTermsNotAccepted = errors.New("Необходимо принять Пользовательское соглашение и Политику конфиденциальности")
func (s *UsersService) AddUser(
ctx context.Context,
username string,
email string,
acceptTerms bool,
acceptPrivacy bool,
ip string,
userAgent string,
) error {
if !acceptTerms || !acceptPrivacy {
return ErrTermsNotAccepted
}
email = normalizeEmail(email)
if err := validateEmail(email); err != nil {
return err
}
password, err := s.passwordGenerator.Generate()
if err != nil {
return err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
// Создание пользователя и фиксация акцептов — в одной транзакции:
// без записей о согласиях пользователь не существует.
id, err := s.usersRepo.AddUserWithAgreements(
ctx,
username,
email,
string(hashedPassword),
[]string{"user"},
[]user_agreements_repo.Agreement{
{
AgreementType: user_agreements_repo.AgreementTypeTerms,
Version: termsVersion,
IP: ip,
UserAgent: userAgent,
},
{
AgreementType: user_agreements_repo.AgreementTypePrivacy,
Version: privacyVersion,
IP: ip,
UserAgent: userAgent,
},
},
)
if err != nil {
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 nil
}
// DeleteAccount удаляет учётную запись и все персональные данные пользователя
// (ст. 21 152-ФЗ). Требует подтверждения паролем: обладатель украденного
// access-токена не должен мочь удалить чужой аккаунт.
func (s *UsersService) DeleteAccount(
ctx context.Context,
userId int,
password string,
) error {
user, err := s.usersRepo.GetUserByID(ctx, userId)
if err != nil {
return err
}
if err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return errors.New("Неверный пароль")
}
return s.usersRepo.DeleteUser(ctx, userId)
}
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
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
// Письмо отправляется ДО смены пароля: при сбое отправки пароль
// остаётся прежним и аккаунт не блокируется (иначе старый пароль уже
// не работает, а новый пользователь так и не узнал).
err = s.emailSender.Send(ctx, email_sender.Message{
To: user.Email,
Subject: "Сброс пароля",
Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password),
})
if err != nil {
return fmt.Errorf("email send: %w", 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(
ctx context.Context,
email string,
password string,
) (string, string, error) {
// Email нормализуется так же, как при регистрации: иначе пользователь,
// зарегистрировавшийся как user@example.com, не войдёт с User@Example.COM.
user, err := s.usersRepo.GetUserByEmail(
ctx,
normalizeEmail(email),
)
if err != nil {
return "", "", err
}
if err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return "", "", errors.New("Не верен email или пароль")
}
accessToken, err := s.processorJWT.GenerateAccessToken(user.ID, user.Email, user.Roles)
if err != nil {
return "", "", errors.New("Не получилось получить доступ")
}
refreshToken, err := s.processorJWT.GenerateRefreshToken(user.ID)
if err != nil {
return "", "", errors.New("Не получилось получить доступ")
}
refreshTokenClaims, err := s.processorJWT.GetClaims(refreshToken)
if err != nil {
return "", "", errors.New("Не получилось получить доступ")
}
if err := s.refreshTokensRepo.AddRefreshToken(ctx, user.ID, refreshTokenClaims.ExpiresAt.Time, refreshTokenClaims.ID); err != nil {
return "", "", err
}
return accessToken, refreshToken, nil
}
func (s *UsersService) Refresh(
ctx context.Context,
token string,
) (string, string, error) {
claims, err := s.processorJWT.GetClaims(token)
if err != nil {
return "", "", errors.New("Пользователь не найден")
}
now := time.Now()
ok, err := s.refreshTokensRepo.ExistsRefreshToken(ctx, claims.UserID, claims.ID, now)
if err != nil {
return "", "", err
}
if !ok {
return "", "", errors.New("Пользователь не найден")
}
if err := s.refreshTokensRepo.DeleteRefreshTokenByJti(ctx, claims.ID); err != nil {
return "", "", err
}
user, err := s.usersRepo.GetUserByID(ctx, claims.UserID)
if err != nil {
return "", "", err
}
accessToken, err := s.processorJWT.GenerateAccessToken(user.ID, user.Email, user.Roles)
if err != nil {
return "", "", errors.New("Не получилось получить доступ")
}
refreshToken, err := s.processorJWT.GenerateRefreshToken(user.ID)
if err != nil {
return "", "", errors.New("Не получилось получить доступ")
}
refreshTokenClaims, err := s.processorJWT.GetClaims(refreshToken)
if err != nil {
return "", "", errors.New("Не получилось получить доступ")
}
if err := s.refreshTokensRepo.AddRefreshToken(ctx, user.ID, refreshTokenClaims.ExpiresAt.Time, refreshTokenClaims.ID); err != nil {
return "", "", err
}
return accessToken, refreshToken, nil
}
func (s *UsersService) GetUsers(
ctx context.Context,
) ([]*repos.User, error) {
users, err := s.usersRepo.GetUsers(ctx)
if err != nil {
return nil, err
}
return users, nil
}
func (s *UsersService) GetUserByID(
ctx context.Context,
userId int,
) (*repos.User, error) {
user, err := s.usersRepo.GetUserByID(ctx, userId)
if err != nil {
return nil, err
}
return user, nil
}
func (s *UsersService) AddUserRole(
ctx context.Context,
userId int,
role string,
) error {
if !slices.Contains(roles.AllRoles, role) {
return fmt.Errorf("role %s not support", role)
}
return s.usersRepo.AddUserRole(ctx, userId, role)
}
func (s *UsersService) DeleteUserRole(
ctx context.Context,
userId int,
role string,
) error {
if !slices.Contains(roles.AllRoles, role) {
return fmt.Errorf("role %s not support", role)
}
return s.usersRepo.DeleteUserRole(ctx, userId, role)
}