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 ( // ErrEmailUnavailable indicates that email-based account operations are disabled. ErrEmailUnavailable = errors.New("почтовые операции отключены на локальной площадке; обратитесь к организатору") 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 s.emailSender == nil { return ErrEmailUnavailable } 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 { if s.emailSender == nil { return ErrEmailUnavailable } 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 }