1. Вступ
Коли username/email уже перевіряються окремо, пароль у відкритому вигляді перетворюється на passwordHash, а стартові прапорці зрозумілі, реєстрація все ще може виглядати як набір розрізнених фрагментів: десь DTO, десь existsBy..., десь buildAccount(...), а цілісного маршруту не видно. Зараз важливо саме зібрати все докупи: один зрозумілий шлях від POST /api/auth/register до збереженого облікового запису, порожнього профілю та передбачуваної відповіді.
Головна ідея тут проста: реєстрація — це конвеєр. На вхід приходить JSON, а далі ми послідовно вирішуємо чотири речі: чи взагалі можна створювати обліковий запис із такими ідентифікаторами, яким має бути UserAccount, що саме зберігається в БД і який безпечний результат отримає клієнт. Щойно порядок фіксується, реєстрація перестає бути «купою корисних шматків» і стає нормальним сценарієм.
2. Контракт реєстрації: що вже зафіксовано
Фінальний сценарій уже спирається на короткий контракт: на вході username, email, password; на виході — тільки userId і username. Це не мінімалізм заради мінімалізму: пароль, passwordHash, ролі, прапорці стану та дати життя облікового запису — це серверні рішення. Клієнт не повинен задавати їх і не повинен бачити у відповіді.
Зазвичай реєстрація починається з таких JSON-даних:
{
"username": "alice",
"email": "alice@example.com",
"password": "S0methingStr0ng!"
}
А відповідь після успішної реєстрації — максимально скромна:
{
"userId": 42,
"username": "alice"
}
На рівні Java це той самий RegistrationResponse(Long userId, String username) — коротка й нудна відповідь без витоків.
3. Тонкий AuthController: прийняти, перевірити, делегувати
Контролер у реєстрації схожий на адміністратора на ресепшені: він приймає запит, перевіряє, чи все оформлено правильно, і передає його далі. Він не повинен іти «в цех» і сам плавити сталь. Щойно контролер починає сам хешувати пароль, лізти в репозиторій і вирішувати бізнес-правила, ви отримуєте клас «усе-в-одному» — класичну Java-версію кухонної раковини: і посуд миє, і каву варить, і безпеку ламає.
Тому кінцева точка тут залишається максимально тонкою: @Valid запускає валідацію DTO, контролер виставляє коректний HTTP-статус і делегує все сервісу.
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final RegistrationService registrationService;
public AuthController(RegistrationService registrationService) {
this.registrationService = registrationService;
}
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public RegistrationResponse register(@Valid @RequestBody RegisterRequest request) {
return registrationService.register(request);
}
}
Тут свідомо немає existsBy..., encode(...) і save(...). Щойно вони з’являються тут, web-шар знову починає вирішувати те, що має вирішувати сценарій реєстрації.
4. RegistrationService: один маршрут без конкуруючих версій
Усередині сервісу зібраний сценарій має читатися зверху вниз без стрибків по проєкту. Нам не потрібні окрема розповідь про DTO, ще одна версія exception або ще один buildAccount(...). Нам потрібен один робочий маршрут.
Усередині RegistrationService цей маршрут виглядає так:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public RegistrationResponse register(RegisterRequest request) {
ensureRegistrationDataAvailable(request);
UserAccount account = buildAccount(request);
UserAccount savedAccount = userAccountRepository.save(account);
userProfileService.createEmptyProfile(savedAccount.getId());
return new RegistrationResponse(savedAccount.getId(), savedAccount.getUsername());
}
Тут ensureRegistrationDataAvailable(...) — це той самий крок із блоку про унікальність: username/email перевіряються до створення облікового запису, а зовнішня відповідь залишається нейтральною.
buildAccount(...) — це той самий крок, де пароль у відкритому вигляді перетворюється на passwordHash, email приводиться до нормалізованого вигляду, призначається Role.USER, а enabled=true і accountNonLocked=true фіксують нормальний стартовий стан облікового запису.
Порожній UserProfile створюється після save(...), коли в облікового запису вже є реальний id. Так профіль лишається окремою доменною сутністю, але не випадає з цього сценарію.
5. Нейтральний конфлікт і остання лінія захисту БД
Рання перевірка потрібна, щоб сценарій реєстрації зупинявся до зайвих дій. Але вона не замінює unique constraint на username і email. Два паралельні запити можуть одночасно пройти existsBy... і зіткнутися вже на save(...). У цей момент БД лишається останньою лінією захисту інваріанта.
Зовні поведінка все одно не повинна роздвоюватися. Якщо конфлікт упіймали в сервісі — відправляється нейтральний 409 Conflict. Якщо дублікат сплив пізніше, наприклад як DataIntegrityViolationException через unique constraint, на зовнішній межі зміст той самий: Дані реєстрації недоступні. Клієнт не повинен отримувати одного разу акуратний конфлікт, а іншого — сире повідомлення бази.
Саме на такій стабільній поведінці потім узагалі можна зібрати єдиний JSON-контракт помилок. Поки джерело відмови плаває, REST-контур теж лишається випадковим.
6. Інтеграція і схема сценарію
Правило в SecurityFilterChain
Реєстрація за змістом має бути доступною анонімному користувачеві — інакше це буде найзахищеніший сервіс у світі, куди не може потрапити взагалі ніхто. Тому в нашій SecurityFilterChain кінцева точка реєстрації має бути в permitAll. Ми не переписуємо всю конфігурацію, а лише перевіряємо, що потрібне правило існує і не перекрите більш загальними правилами вище в ланцюжку.
Мінімально це виглядає так:
import org.springframework.http.HttpMethod;
http.authorizeHttpRequests(auth -> auth
// Дозволяємо анонімний доступ строго до реєстрації (POST), а не до всього /api/auth/**
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
// Усе інше закриваємо: реєстрація — публічний вхід, а не «відкритий проєкт»
.anyRequest().authenticated()
);
Тут важливий не синтаксис, а думка: кінцева точка реєстрації — частина публічної зони, але лише в сенсі «можна викликати», а не «можна робити що завгодно». Вона створює обліковий запис лише на умовах системи.
Схема всього сценарію: від JSON до записів у БД
Коли ви збираєте сценарій цілком, дуже корисно один раз побачити його як карту. Це допомагає і пояснювати, і налагоджувати, і не тягнути зайві обов’язки в неправильні місця. Нижче — спрощена схема нашого сценарію реєстрації.
flowchart TD
A["Клієнт: POST /api/auth/register"] --> B["AuthController"]
B -->|"@Valid DTO"| C["RegistrationService.register"]
C --> D["ensureRegistrationDataAvailable"]
D -->|taken| X["409 Conflict
Дані реєстрації недоступні"]
D -->|available| E["buildAccount"]
E --> F["UserAccountRepository.save"]
F -->|unique constraint violation| X
F -->|saved| G["userProfileService.createEmptyProfile"]
G --> H["RegistrationResponse { userId, username }"]
H --> I["Клієнт отримує 201 Created + безпечне тіло відповіді"]
І щоб закріпити розподіл обов’язків не тільки на схемі, а й у голові, зручно тримати маленьку таблицю-нагадування:
| Учасник | Що робить у реєстрації | Що не повинен робити |
|---|---|---|
| AuthController | Приймає HTTP-запит, запускає @Valid, виставляє 201 Created, делегує сервісу | Кодувати пароль, ходити в БД, призначати ролі |
| RegistrationService | Збирає сценарій: перевірка доступності, складання облікового запису, збереження, створення профілю, відповідь | Повертає назовні сутності, дублювати кілька несумісних версій одного сценарію |
| UserAccountRepository | Надає способи перевірити й зберегти (existsBy..., save) | Містити бізнес-логіку сценарію реєстрації |
| PasswordEncoder | Виконує encode (а потім і matches у потоці автентифікації) | Зберігати пароль або «розшифровувати його назад» |
| UserProfileService | Створює порожній профіль після появи userId | Підміняти собою реєстрацію або зберігати passwordHash |
7. Типові помилки під час реєстрації
Помилка №1: зібрати сценарій реєстрації з різних версій одного й того самого сценарію.
Коли в проєкті один контролер вважає, що реєстрація повертає void, сервіс — що RegistrationResponse, а шлях помилки живе в третьому місці, сценарій перестає бути передбачуваним. Для реєстрації особливо шкідливо, коли різні шматки коду ухвалюють різні рішення щодо одного й того самого. Має бути один зрозумілий маршрут, а не набір «майже однакових» реалізацій.
Помилка №2: нейтральний конфлікт лише в сервісній перевірці, а при дублікаті на save(...) — сирий exception бази.
Такий проєкт поводиться непослідовно: сьогодні акуратно відповідає 409 Conflict, а завтра викидає назовні текст про порушення unique constraint. Клієнту байдуже, де саме сплив дублікат. Зовні це один і той самий конфлікт реєстраційних даних, і він має виглядати однаково.
Помилка №3: permitAll() не збігається з реальним маршрутом або перекритий ширшим matcher-ом.
Навіть хороший сценарій реєстрації марний, якщо до нього не можна дійти анонімно. Класика жанру: контролер слухає /api/auth/register, а в security-конфігу випадково дозволили інший шлях або поставили занадто загальний matcher вище. Підсумок — дивні 401/403 ще до входу в сценарій реєстрації.
Помилка №4: створювати UserProfile до save(account) або виносити цей крок у випадкове місце.
Профіль залежить від уже наявного облікового запису та його id. Якщо ви намагаєтеся створювати профіль десь поруч зі збереженням облікового запису, легко отримати напівготовий сценарій: обліковий запис не зберігся, профіль уже спробували створити; обліковий запис зберігся, профіль забули; і все це ще й у різних транзакційних шматках. Простіше й надійніше: спочатку save(account), потім createEmptyProfile(...).
Помилка №5: повернути назовні UserAccount цілком або логувати RegisterRequest цілком.
Щойно ви починаєте віддавати назовні сутність, разом із нею назовні поступово витікають ролі, прапорці стану та все, що ви ще додасте в модель. А якщо ще й логувати RegisterRequest цілком, то в логах раптом з’являються паролі. Реєстраційна кінцева точка має бути нудною: приймає мінімум, створює обліковий запис, повертає безпечний мінімум.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ