Files
evening_detective_server/internal/services/users_service/service.go
T
2026-10-03 01:28:09 +07:00

443 lines
15 KiB
Go

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"
"unicode/utf8"
"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
// appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в
// письмах; пустое значение — ссылка не добавляется.
appBaseURL string
}
func NewUsersService(
usersRepo usersRepository,
passwordGenerator password_generator.IPasswordGenerator,
emailSender email_sender.IEmailSender,
processorJWT processor_jwt.IProcessorJWT,
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo,
appBaseURL string,
) *UsersService {
return &UsersService{
usersRepo: usersRepo,
passwordGenerator: passwordGenerator,
emailSender: emailSender,
processorJWT: processorJWT,
refreshTokensRepo: refreshTokensRepo,
appBaseURL: loginURL(appBaseURL),
}
}
var (
// ErrTermsNotAccepted — регистрация невозможна без акцепта соглашений.
ErrTermsNotAccepted = errors.New("Необходимо принять Пользовательское соглашение и Политику конфиденциальности")
ErrInvalidEmail = errors.New("Некорректный email")
ErrInvalidCredentials = errors.New("Не верен email или пароль")
ErrInvalidAccountPassword = errors.New("Неверный пароль")
ErrInvalidRefreshToken = errors.New("Пользователь не найден")
ErrInvalidRole = 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 = strings.TrimSpace(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: welcomeSubject,
Body: welcomePlain(username, email, password, s.appBaseURL),
HTML: welcomeHTML(username, email, password, s.appBaseURL),
})
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 {
if errors.Is(err, users_repo.ErrUserNotFound) {
return ErrUserNotFound
}
return err
}
if err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return ErrInvalidAccountPassword
}
err = s.usersRepo.DeleteUser(ctx, userID)
if errors.Is(err, users_repo.ErrUserNotFound) {
return ErrUserNotFound
}
return err
}
func (s *UsersService) RefreshPassword(
ctx context.Context,
email string,
) error {
email = strings.TrimSpace(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: resetSubject,
Body: resetPlain(user.Username, user.Email, password, s.appBaseURL),
HTML: resetHTML(user.Username, user.Email, password, s.appBaseURL),
})
if err != nil {
return fmt.Errorf("email send: %w", err)
}
return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword))
}
// validateEmail проверяет, что строка является одиночным email-адресом.
func validateEmail(email string) error {
if email == "" {
return ErrInvalidEmail
}
addr, err := mail.ParseAddress(email)
if err != nil {
return ErrInvalidEmail
}
// Отклоняем форму "Имя <a@b.c>" и всё, что не является чистым адресом.
if !strings.EqualFold(addr.Address, email) {
return ErrInvalidEmail
}
return nil
}
func (s *UsersService) Login(
ctx context.Context,
email string,
password string,
) (string, string, error) {
// Сохраняем регистр email, как при регистрации; убираем только внешние пробелы.
user, err := s.usersRepo.GetUserByEmail(
ctx,
strings.TrimSpace(email),
)
if err != nil {
if errors.Is(err, users_repo.ErrUserNotFound) {
return "", "", fmt.Errorf("%w: %w", ErrInvalidCredentials, err)
}
return "", "", err
}
if err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return "", "", ErrInvalidCredentials
}
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 "", "", ErrInvalidRefreshToken
}
now := time.Now()
ok, err := s.refreshTokensRepo.ExistsRefreshToken(ctx, claims.UserID, claims.ID, now)
if err != nil {
return "", "", err
}
if !ok {
return "", "", ErrInvalidRefreshToken
}
if err := s.refreshTokensRepo.DeleteRefreshTokenByJti(ctx, claims.ID); err != nil {
return "", "", err
}
user, err := s.usersRepo.GetUserByID(ctx, claims.UserID)
if err != nil {
if errors.Is(err, users_repo.ErrUserNotFound) {
return "", "", ErrInvalidRefreshToken
}
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,
) ([]*User, error) {
users, err := s.usersRepo.GetUsers(ctx)
if err != nil {
return nil, err
}
return mapUsers(users), nil
}
func (s *UsersService) GetUserByID(
ctx context.Context,
userID int,
) (*User, error) {
user, err := s.usersRepo.GetUserByID(ctx, userID)
if err != nil {
if errors.Is(err, users_repo.ErrUserNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return mapUser(user), nil
}
func (s *UsersService) AddUserRole(
ctx context.Context,
userID int,
role string,
) error {
if !slices.Contains(roles.AllRoles, role) {
return ErrInvalidRole
}
err := s.usersRepo.AddUserRole(ctx, userID, role)
if errors.Is(err, users_repo.ErrUserNotFound) {
return ErrUserNotFound
}
return err
}
func (s *UsersService) DeleteUserRole(
ctx context.Context,
userID int,
role string,
) error {
if !slices.Contains(roles.AllRoles, role) {
return ErrInvalidRole
}
err := s.usersRepo.DeleteUserRole(ctx, userID, role)
if errors.Is(err, users_repo.ErrUserNotFound) {
return ErrUserNotFound
}
return err
}
var (
ErrPasswordChangeForbidden = errors.New("Изменять чужой пароль может только администратор")
ErrInvalidPasswordChange = errors.New("Укажите чужой положительный ID и пароль от 8 до 16 символов")
// ErrUserNotFound — целевой пользователь не найден.
ErrUserNotFound = errors.New("Пользователь не найден")
)
// ChangeUserPassword сохраняет только хеш; действующие сеансы не изменяются.
func (s *UsersService) ChangeUserPassword(ctx context.Context, actorID, userID int, password string) error {
actor, err := s.usersRepo.GetUserByID(ctx, actorID)
if errors.Is(err, users_repo.ErrUserNotFound) {
return ErrPasswordChangeForbidden
}
if err != nil {
return err
}
if !slices.Contains(actor.Roles, roles.Admin) {
return ErrPasswordChangeForbidden
}
passwordLength := utf8.RuneCountInString(password)
if userID <= 0 || userID == actorID || !utf8.ValidString(password) || passwordLength < 8 || passwordLength > 16 {
return ErrInvalidPasswordChange
}
user, err := s.usersRepo.GetUserByID(ctx, userID)
if err != nil {
if errors.Is(err, users_repo.ErrUserNotFound) {
return ErrUserNotFound
}
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hash)); err != nil {
if errors.Is(err, users_repo.ErrUserNotFound) {
return ErrUserNotFound
}
return err
}
return nil
}