JavaRush /Курсы /Spring Security /Регистрация: POST ...

Регистрация: POST /api/auth/register

Spring Security
16 уровень , 0 лекция
Открыта

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.

1
Задача
Spring Security, 16 уровень, 0 лекция
Недоступна
Тонкий endpoint регистрации
Тонкий endpoint регистрации
1
Задача
Spring Security, 16 уровень, 0 лекция
Недоступна
Публичный доступ только к `POST /api/auth/register`
Публичный доступ только к `POST /api/auth/register`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ