1. Регистрация как граница безопасности
До этого момента security уже умела читать пользователя из БД и аутентифицировать его через DaoAuthenticationProvider. Теперь нужен обратный путь: создать такой UserAccount, с которым этот же login flow потом действительно сможет работать.
Регистрация выглядит как обычный endpoint: пришёл JSON, мы что-то сохранили. Но это обманчиво: регистрация — это публичная дверь в вашу систему, в которую стучится не только будущий счастливый пользователь, но и любой бот, сканер и человек, которому просто скучно вечером. Поэтому к ней относятся не как к CRUD-ручке, а как к сценарию со строгими правилами.
Если вы думаете о регистрации как о «POST /users», то рука сама тянется сделать универсальный метод “сохранить пользователя”, где клиент может прислать всё подряд. Это почти гарантированно закончится болью. Во-первых, «пользователь» в домене и «учётная запись» в security — не одно и то же. Во-вторых, у регистрации есть неизбежные security-инварианты: пароль нельзя хранить сырым, стартовая роль не должна приходить из запроса, флаги enabled/locked нельзя отдавать на откуп клиенту, а ответы об ошибках должны быть аккуратными, чтобы не помогать злоумышленнику.
В нашем проекте Secure Content Platform API регистрация — это момент, когда появляется новый UserAccount, и он должен быть сразу совместим с тем, как дальше работает DaoAuthenticationProvider. Иначе получится комичный баг: «зарегистрировался успешно», но войти невозможно, потому что пароль сохранён не так, роли не так, состояние аккаунта не так. Комедия, конечно, но обычно не в пятницу вечером на проде.
Контракт регистрации: что принимаем
Когда мы делаем endpoint регистрации, очень хочется «на всякий случай» принимать побольше полей: роль, displayName, bio, аватар, флаги, дату рождения, любимую пиццу. Но в security-курсе мы держим фокус: регистрация создаёт учётную запись, а не заполняет весь профиль. Поэтому контракт должен быть коротким и предсказуемым.
Минимальный вход для POST /api/auth/register в нашем дне — это username, email, password. Здесь важно понимать философию: это не “всё, что мы знаем о человеке”, а только то, что нужно, чтобы завести аккаунт и в будущем проверить его login/password. Всё остальное (профиль, аватар, bio) — отдельные сценарии и отдельные endpoints. И да, это иногда кажется «недружелюбным». Но зато приложение остаётся простым: одна ручка — одна ответственность.
Полезно прямо на берегу договориться, какие поля клиент никогда не должен присылать в регистрации, потому что это наше серверное решение. В виде маленькой таблицы это выглядит так:
| Категория | Поле | Кто определяет |
|---|---|---|
| Вход пользователя | username, email, password | клиент присылает |
| Security-инварианты | passwordHash | сервер вычисляет (encode) |
| Модель доступа | стартовая роль (USER) | сервер назначает |
| Жизненный цикл | enabled, accountNonLocked | сервер задаёт стартовые значения |
| Идентификаторы и даты | id, createdAt | база/сервер создают |
Эта табличка кажется очевидной… пока вы не увидели реальный баг, где кто-то «по быстрому» добавил поле role в RegisterRequest, а потом выяснилось, что регистрация внезапно стала “admin-by-request”. Такой endpoint можно показывать на Хэллоуин вместо страшилок.
3. Регистрационный flow: порядок шагов
Регистрация — это не одна операция, а последовательность шагов, и в правильном порядке. Порядок важен не из любви к бюрократии, а потому что некоторые ошибки проще и безопаснее остановить до того, как мы вообще начинаем создавать сущности и писать в базу.
В идеальном виде сценарий регистрации можно представлять как «трубу», где каждый шаг либо пропускает запрос дальше, либо останавливает его понятной ошибкой. Детали у каждого шага свои, но скелет нужно увидеть сразу, чтобы не получился метод на 200 строк в контроллере.
Вот компактная блок-схема, которая даёт правильную «картинку в голове»:
flowchart TD
%% Важно: регистрация — это сценарий, а не просто "сохранить сущность"
A["POST /api/auth/register
JSON: username, email, password"] --> B["Web слой: распарсить + валидация DTO"]
B --> C["Service слой: проверить доступность username/email"]
C --> D["Service слой: собрать UserAccount
passwordHash, role, initial flags"]
D --> E["Repository: сохранить UserAccount в БД"]
E --> F["Service слой: создать пустой UserProfile"]
F --> G["Вернуть безопасный результат регистрации"]
Обратите внимание на две вещи. Во‑первых, UserProfile у нас создаётся сразу после сохранения аккаунта, но отдельным шагом: профиль не смешивается с security-ядром учётки. Во‑вторых, «собрать аккаунт» — это не “new UserAccount() и set’ы в контроллере”, а отдельный шаг сценария. Мы не «раскидываем» создание аккаунта по слоям, а держим его внутри сервиса.
4. Где живёт логика: слои
Очень легко написать регистрацию «в контроллере»: прочитал JSON, сделал проверки, захешировал пароль, сохранил через репозиторий, вернул ответ. И всё работает. Ровно до момента, пока вы не захотите поддерживать это дольше одной недели — или пока вы не поймёте, что половина решений должна жить не в web-слое, а в бизнес-сценарии.
Контроллер — это переводчик с HTTP на ваш код. Он должен уметь принять запрос, применить базовую валидацию DTO, вызвать сервис и вернуть ответ. Сервис — это режиссёр сценария: он знает порядок шагов и принимает решения. Репозиторий — это «руки», которые трогают базу. Если контроллер начинает делать всё сразу, получается «толстый» web-слой, где перемешаны HTTP, безопасность и бизнес-логика. Такой код неудобно тестировать, неудобно менять, и он обычно превращается в “просто не трогай, оно работает”.
Если разложить шаги регистрации по слоям, получится более спокойная картина:
| Шаг | Где должен быть |
|---|---|
| Принять JSON, превратить в DTO | Controller |
| Проверить базовую валидность DTO | Controller (через Bean Validation) + иногда сервис |
| Проверить доступность регистрационных данных | Service |
| Создать UserAccount по правилам security | Service |
| Сохранить в БД | Repository (вызов из сервиса) |
| Сформировать ответ | Service → Controller |
И это не «чистая архитектура ради чистой архитектуры». Это просто способ сделать так, чтобы в вашем коде можно было глазами прочитать сценарий сверху вниз — и не искать по всему проекту, где же на самом деле кодируется пароль и назначается роль.
5. Каркас кода регистрации
Сейчас мы добавим в проект минимальные классы, которые задают форму сценария. Пока без «мяса» (уникальность, кодирование пароля, стартовые роли) — это следующие инженерные шаги регистрации. Здесь наша задача другая: сделать так, чтобы регистрация выглядела как сценарий, а не как куча случайных строк в контроллере.
Начнём с DTO запроса. Я покажу его в виде record, потому что он короткий, и Jackson отлично умеет с ним работать. Если records вам пока непривычны — воспринимайте это как компактный immutable-класс с полями.
import jakarta.validation.constraints.*;
// DTO регистрации: только входные поля, которые мы разрешаем прислать клиенту
public record RegisterRequest(
@NotBlank String username, // логин/никнейм: не должен быть пустым
@NotBlank @Email String email, // базовая проверка формата почты
@NotBlank @Size(min = 8, max = 72) String password // сырой пароль: хранить нельзя, только принять и захешировать
) {}
Здесь есть важная мысль: базовые ограничения (не пусто, корректный email, минимальная длина пароля) — это не «безопасность», но это хороший фильтр мусора на входе. А вот дальше уже начинается business/security-логика, которую мы держим в сервисе.
Наружу дальше будем отдавать только безопасный минимум — RegistrationResponse с userId и username.
Теперь сам контроллер. Мы не делаем «универсальный CRUD пользователей». Мы делаем AuthController, который занимается именно auth-сценариями, а не превращается в универсальный CRUD пользователей.
Каркас класса можно держать максимально простым:
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final RegistrationService registrationService;
public AuthController(RegistrationService registrationService) {
this.registrationService = registrationService; // внедряем сценарий регистрации
}
}
А сам метод регистрации должен быть «тонким». Он принимает DTO и делегирует. Именно это мы хотим закрепить как привычку.
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
// Важно: контроллер не должен содержать бизнес-логику регистрации
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public RegistrationResponse register(@Valid @RequestBody RegisterRequest request) {
return registrationService.register(request); // логика сценария живёт в сервисе
}
И, наконец, сервис. На старте достаточно одного факта: RegistrationService.register(...) — это отдельный сценарий, а не побочный эффект контроллера. Именно там окажутся проверка доступности данных, сборка UserAccount, сохранение и создание пустого профиля.
6. Скелет метода register(...)
Когда вы пишете сервисный метод регистрации, думайте не “как бы побыстрее сохранить сущность”, а “как бы сделать сценарий читаемым”. Хороший сервисный метод — это когда вы можете открыть код через месяц и всё ещё понять, что он делает, без археологической экспедиции в git blame.
Поэтому уже на уровне «скелета» полезно сделать метод, который читается как рецепт. Базовая DTO-валидация уже сработала на границе контроллера, поэтому внутри остаются шаги, от которых зависит security-модель аккаунта.
public RegistrationResponse register(RegisterRequest request) {
ensureRegistrationDataAvailable(request); // 1) username/email ещё свободны
UserAccount account = buildAccount(request); // 2) собираем аккаунт по security-правилам
UserAccount savedAccount = userAccountRepository.save(account); // 3) после save получаем реальный id
createInitialProfile(savedAccount); // 4) профиль — отдельный шаг того же сценария
return toResponse(savedAccount); // 5) наружу отдаём только безопасный минимум
}
Здесь важно не то, что helper-методы пока не раскрыты, а то, что код задаёт форму: сначала проверка доступности регистрационных данных, потом сборка сущности, сохранение, отдельный шаг с профилем и только после этого безопасный ответ. Это спасает от классической ошибки «создали аккаунт, потом вспомнили, что email уже занят».
Ещё одна полезная мысль для новичка: сервисный метод обычно не должен быть “умным” в том смысле, что он не обязан содержать всю логику в одной куче. Его цель — управлять сценарием. А отдельные детали лучше выносить в маленькие private-методы или отдельные компоненты. Это ровно тот случай, когда “разбить на шаги” — не академическая рекомендация, а реальная профилактика будущего хаоса.
7. Регистрация и Spring Security
Самый частый «ментальный баг» новичка звучит так: «Регистрация — это же про безопасность, значит Spring Security сам должен как-то “заняться регистрацией”». На практике Spring Security про регистрацию ничего не знает. Он умеет аутентифицировать пользователя по своим правилам, но создать UserAccount — это уже ваша прикладная логика.
Регистрация нужна не чтобы «обойти security», а чтобы подготовить данные, с которыми security потом работает. После регистрации пользователь должен уметь пройти стандартный login flow: DaoAuthenticationProvider возьмёт логин, через UserDetailsService загрузит UserDetails, сравнит raw password с passwordHash через PasswordEncoder, проверит состояния аккаунта и только потом даст аутентификацию.
Полезно держать в голове простую последовательность “write-path vs read-path”:
sequenceDiagram
%% RegistrationService пишет данные, Spring Security потом их читает и проверяет
participant C as "Client (anonymous)"
participant R as RegistrationService
participant DB as DB
participant SS as "Spring Security (DaoAuthenticationProvider)"
C->>R: register(username, email, password)
R->>DB: save(UserAccount with passwordHash, roles, flags)
Note over DB: аккаунт появился и соответствует инвариантам
C->>SS: login(username + password)
SS->>DB: load user by username
SS->>SS: PasswordEncoder.matches(raw, hash)
SS-->>C: success or failure
Из этого следует важный вывод: регистрация и логин — разные сценарии. Регистрация создаёт аккаунт. Логин доказывает, что аккаунт ваш. Мы не смешиваем их в один endpoint и не пытаемся “сразу залогинить” пользователя после регистрации. Это упрощает систему и делает поведение предсказуемым.
8. Типичные ошибки при регистрации
Ошибка №1: «толстый» контроллер.
Почти всегда первая ошибка в регистрации — это «толстый контроллер», когда в методе register() живёт половина приложения: и проверки, и кодирование пароля, и назначение ролей, и сохранение в базу, и сборка ответа, и (иногда) ещё попытка “сразу залогинить”. Такой код кажется быстрым в момент написания, но он очень плохо переживает изменения. Вы начнёте добавлять проверку уникальности, затем стартовые флаги, потом инициализацию профиля — и внезапно обнаружите, что контроллер превращён в кучу логики, которую неудобно тестировать и невозможно читать.
Ошибка №2: принятие от клиента «серверных» полей.
Вторая частая ошибка — принимать от клиента «серверные» поля. Особенно это касается роли и флагов состояния аккаунта. Если в RegisterRequest появляется role, enabled или accountNonLocked, то вы даёте клиенту возможность влиять на security-модель. Даже если «сейчас фронт не отправляет таких полей», это не аргумент: клиентом является весь интернет. Сервер должен сам назначать стартовую роль и стартовые состояния, иначе регистрация становится дырой в доступе.
Ошибка №3: лишние данные в логах и ответах.
Третья ошибка не всегда очевидна, но очень вредная: логировать или возвращать в ответе лишние данные. Самый грустный вариант — когда кто-то делает log.info("Register request: {}", request) и в лог уезжает raw password. Это не “ой, некрасиво”, это реальная утечка секретов в один из самых живучих источников информации в компании — логи. Даже в учебном проекте приучайте себя: пароль не логируем, passwordHash тоже не возвращаем, а в ответ отдаём минимально безопасный результат.
Ошибка №4: смешивание UserAccount и UserProfile в одну сущность.
Четвёртая ошибка — смешивать UserAccount и UserProfile в один бесформенный ком. Регистрация должна создать security-ядро аккаунта, а профиль — это отдельная история. Да, мы создаём пустой профиль в том же сценарии, но это отдельный шаг, который не влияет на правила хранения пароля, ролей и состояния аккаунта. Если вы склеите всё в одну сущность и один endpoint, у вас быстро появится «божественный User», где рядом лежат passwordHash и avatarPath, и от этого потом трудно отмыться.
Ошибка №5: сохранение аккаунта до проверок и попытки “откатить”.
И наконец, пятая ошибка — сохранять аккаунт до проверок и уже потом пытаться «откатить» ситуацию. Когда вы сначала делаете save(), а потом выясняете, что email занят, начинается театр: ловим исключения базы, маппим их в ошибки, пытаемся понять, что именно конфликтнуло, и почему поведение отличается в разных окружениях. Правильная привычка на fundamentals-уровне такая: сначала сценарий принимает вход, проверяет базовые условия и доступность регистрационных данных, и только после этого создаёт и сохраняет UserAccount.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ