generated from VLADIMIR/template
375 lines
13 KiB
Go
375 lines
13 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"
|
||
|
||
"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),
|
||
}
|
||
}
|
||
|
||
// 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: 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 {
|
||
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: 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))
|
||
}
|
||
|
||
// 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)
|
||
}
|