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 } func NewUsersService( usersRepo usersRepository, passwordGenerator password_generator.IPasswordGenerator, emailSender email_sender.IEmailSender, processorJWT processor_jwt.IProcessorJWT, refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo, ) *UsersService { return &UsersService{ usersRepo: usersRepo, passwordGenerator: passwordGenerator, emailSender: emailSender, processorJWT: processorJWT, refreshTokensRepo: refreshTokensRepo, } } // 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: "Приветствую тебя, детектив!", Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password), }) 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: "Сброс пароля", Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password), }) 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") } // Отклоняем форму "Имя " и всё, что не является чистым адресом. 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) }