Files
evening_detective_server/internal/services/users_service/service.go
T
2026-10-08 02:07:45 +07:00

422 lines
12 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"
)
// (UNIQUE (user_id, agreement_type, version)).
const (
termsVersion = "1.0"
privacyVersion = "1.0"
)
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
}
// UsersService handles user accounts and authentication.
type UsersService struct {
usersRepo usersRepository
passwordGenerator password_generator.IPasswordGenerator
emailSender email_sender.IEmailSender
processorJWT processor_jwt.IProcessorJWT
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo
appBaseURL string
}
// NewUsersService creates a users service.
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 = errors.New("необходимо принять пользовательское соглашение и политику конфиденциальности")
// ErrInvalidEmail indicates that an email address is invalid.
ErrInvalidEmail = errors.New("некорректный email")
// ErrInvalidCredentials indicates that the supplied credentials are invalid.
ErrInvalidCredentials = errors.New("не верен email или пароль")
// ErrInvalidAccountPassword indicates that the account password is invalid.
ErrInvalidAccountPassword = errors.New("неверный пароль")
// ErrInvalidRefreshToken indicates that a refresh token is invalid.
ErrInvalidRefreshToken = errors.New("пользователь не найден")
// ErrInvalidRole indicates that a role is invalid.
ErrInvalidRole = errors.New("недопустимая роль")
)
// AddUser creates a user account.
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
}
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
}
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
}
// RefreshPassword generates and sends a new password.
func (s *UsersService) RefreshPassword(
ctx context.Context,
email string,
) error {
email = strings.TrimSpace(email)
if err := validateEmail(email); err != nil {
return err
}
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))
}
func validateEmail(email string) error {
if email == "" {
return ErrInvalidEmail
}
addr, err := mail.ParseAddress(email)
if err != nil {
return ErrInvalidEmail
}
if !strings.EqualFold(addr.Address, email) {
return ErrInvalidEmail
}
return nil
}
// Login authenticates a user and returns tokens.
func (s *UsersService) Login(
ctx context.Context,
email string,
password string,
) (string, string, error) {
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
}
// Refresh exchanges a refresh token for new tokens.
func (s *UsersService) Refresh(
ctx context.Context,
token string,
) (string, string, error) {
claims, err := s.processorJWT.GetClaims(token)
if err != nil {
return "", "", ErrInvalidRefreshToken
}
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("не получилось получить доступ")
}
ok, err := s.refreshTokensRepo.ReplaceRefreshToken(ctx, user.ID, claims.ID, time.Now(), refreshTokenClaims.ExpiresAt.Time, refreshTokenClaims.ID)
if err != nil {
return "", "", err
}
if !ok {
return "", "", ErrInvalidRefreshToken
}
return accessToken, refreshToken, nil
}
// GetUsers returns users.
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
}
// GetUserByID returns a user by ID.
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
}
// AddUserRole assigns a role to a user.
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
}
// DeleteUserRole removes a role from a user.
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 indicates that the password cannot be changed.
ErrPasswordChangeForbidden = errors.New("изменять чужой пароль может только администратор")
// ErrInvalidPasswordChange indicates that the password change request is invalid.
ErrInvalidPasswordChange = errors.New("укажите чужой положительный ID и пароль от 8 до 16 символов")
ErrUserNotFound = errors.New("пользователь не найден")
)
// ChangeUserPassword changes a user's password subject to authorization checks.
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
}