generated from VLADIMIR/template
430 lines
12 KiB
Go
430 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|