JavaRush /Курси /Spring Security /Контракт POST

Контракт POST /api/auth/login

Spring Security
Рівень 23 , Лекція 1
Відкрита

1. Фіксація контракту login кінцевої точки

Якщо ви колись бачили API, яке «нібито працює», але кожен клієнт надсилає логін як user, username, loginName, а пароль то в password, то в pass, то в secret… ви добре розумієте, чому контракт — це не бюрократія. Контракт — це ваша домовленість зі світом: з Postman, із мобільним застосунком, із майбутнім фронтендом і, що найнеприємніше, із вами ж через місяць, коли забудеться, як саме ми входили в систему.

У stateless-моделі контракт особливо важливий, тому що POST /api/auth/login стає центральним: саме тут ми перетворюємо «сирі облікові дані» на token, який далі використовуватиме клієнт. Якщо контракт плаває, то плаває й усе оточення: документація, тести, обробка помилок і навіть безпека, бо зайве поле в запиті — це іноді не «зручність», а діра.

Є ще один практичний момент: Spring Security дає багато готової поведінки, наприклад орієнтований на браузер form login. Але ми зараз робимо JSON login endpoint. Отже, саме ми відповідаємо за форму request/response. Якщо її не зафіксувати відразу, зʼявиться спокуса «швиденько додати ще одне поле», потім ще одне, і раптом login endpoint почне віддавати профіль, налаштування, роль, список чернеток і прогноз погоди на завтра. Це не жарт — це типова доля кінцевих точок без меж.

2. Межі login кінцевої точки

Щоб кінцева точка була здоровою — в архітектурному сенсі, а не «щоб не кашляла», — потрібно чесно визначити її обовʼязки. Кінцева точка login у stateless JWT-моделі — це не «отримати користувача» і не «дозволити доступ», а саме пройти аутентифікацію і видати артефакт, який клієнт потім прикладатиме до запитів. Ми тримаємо в голові поділ: AuthenticationManager відповідає на запитання «хто ви?», а TokenService — «який token видати підтвердженому користувачу?».

Хороша ментальна модель: уявіть турнікет у метро. Турнікет не зобовʼязаний розповідати вам історію метро, друкувати карту маршрутів і показувати новини. Він робить дві речі: перевіряє, що квиток валідний, і відкриває прохід. Наш POST /api/auth/login — це «перевірити квиток» через AuthenticationManager і «видати перепустку» у вигляді access token. Усе інше — в інших кінцевих точках. Якщо клієнту потрібен профіль — нехай іде в /api/me/profile. Якщо потрібні ролі або права — вони мають бути всередині токена (або обчислюватися на сервері), але не надходити від клієнта в login-запиті.

Корисно побачити потік у вигляді схеми — так менше шансів «підмішати» в нього зайву відповідальність:

flowchart TD
    Client[Клієнт API] -->|POST /api/auth/login| AuthController[AuthController]
    AuthController --> AuthService[AuthService]
    AuthService -->|authenticate| AuthenticationManager[AuthenticationManager]
    AuthenticationManager -->|успіх| AuthService
    AuthService -->|видати access token| TokenService[TokenService]
    TokenService -->|token| AuthService
    AuthService -->|відповідь| AuthController
    AuthController -->|200 OK JSON| Client

Зверніть увагу на важливу деталь: тут немає сесії, немає cookies, немає редиректів на HTML-сторінки, немає «login page». Це звичайна JSON-кінцева точка, як і всі інші в REST API.

3. Request: мінімальний JSON для входу

Коли ми робимо login-запит, у нас є спокуса «покращити UX» прямо на рівні API: додати «rememberMe», «deviceId», «timezone», «language», «preferredRole» і «а можна я одразу буду адміном». І ось остання частина — не жарт: щойно клієнт починає надсилати щось про ролі або права, ви відкриваєте двері для атак класу «privilege escalation» через баг в обробці запиту. Тому на рівні основ ми тримаємо запит максимально простим.

Мінімальний запит для входу в нашій моделі — це два поля: ідентифікатор (логін) і пароль. Усе. Це схоже на замкову щілину: якщо вона занадто широка, туди лізе хто завгодно і що завгодно, і потім ви дивуєтеся, чому двері погано зачиняються.

Імʼя поля: login / username / email

Назва поля — дрібниця рівно доти, доки вона не стає «фішкою», яку ви потім змушені тягати роками. Якщо ви впевнені, що вхід завжди відбуватиметься через username, можна назвати поле username. Якщо вхід завжди через emailemail. Але життя любить сюжетні повороти: сьогодні ви логінитеся через username, завтра продукт каже «давайте через email, так звичніше», а післязавтра — «а можна і так, і так».

Тому в навчальному проєкті хороший компроміс — назвати поле нейтрально: login. Це означає «те, чим користувач входить», не розкриваючи внутрішнього рішення. Усередині застосунку ви вже вирішите, що саме це: username чи email. Таке найменування робить контракт стабільнішим. Клієнт надсилає login, а сервер уже вирішує, як інтерпретувати це значення.

Приклад JSON, який реально потрапить на сервер:

{
  "login": "alice",
  "password": "p@ssw0rd"
}

Так, пароль у JSON виглядає страшнувато, ніби ви кричите його в натовп. Саме тому в реальному світі це завжди має їхати через HTTPS, але ми зараз тримаємо фокус на контракті.

DTO LoginRequest: простий клас без «розумної логіки»

DTO для requestʼа — це не місце для бізнес-логіки. Його завдання — описати форму вхідних даних. Якщо ви почнете додавати туди методи типу isAdmin() або toAuthenticationToken(), DTO почне розумнішати, а потім ви зловите себе на думці, що у вас «контракт» став частиною доменної моделі. Це як записати рецепт борщу прямо на упаковці солі — наче смішно, але потім важко пояснити, чому сіль у вас залежить від буряка.

Для Jackson тут є кілька нормальних шляхів: звичайний class із порожнім конструктором і сетерами, record або constructor-based binding. Щоб не розповзатися в бік інфраструктурних деталей, візьмемо звичайний class: для @RequestBody це зрозумілий baseline, а DTO залишається простою коробкою з даними.

package com.example.securecontent.auth;

public class LoginRequest {

    // Логін у нейтральному сенсі: username або email — вирішуємо на сервері
    private String login;

    // Пароль надходить лише для аутентифікації і більше нікуди не «мандрує»
    private String password;

    public LoginRequest() {
    }

    public String getLogin() { return login; }
    public void setLogin(String login) { this.login = login; }

    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
}

Порожній конструктор і сетери дають Jackson зрозумілий шлях для десеріалізації вхідного JSON, а гетери потрібні вже нам для серіалізації, коли обʼєкт живе в коді.

Мінімальна валідація: не геройствуємо, але й не ігноруємо

Навіть у навчальному проєкті варто відрізняти «користувач увів неправильний пароль» від «користувач надіслав порожній JSON» або «пароль узагалі відсутній». Це різні класи помилок. І найголовніше — вони мають повертатися передбачувано.

Якщо в проєкті вже є spring-boot-starter-validation, можна додати просту валідацію на @NotBlank. Ми не перетворюємо лекцію на курс із validation, але мінімальна гігієна тут доречна.

package com.example.securecontent.auth;

import jakarta.validation.constraints.NotBlank;

public class LoginRequest {

    // Не даємо надіслати порожній логін: це помилка формату запиту, а не «неправильний пароль»
    @NotBlank
    private String login;

    // Аналогічно для пароля: порожнє значення — це 400, а не 401
    @NotBlank
    private String password;

    public LoginRequest() {
    }

    public String getLogin() { return login; }
    public void setLogin(String login) { this.login = login; }

    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
}

Ця валідація не «перевіряє складність пароля» і не лізе в безпеку глибше, ніж треба. Вона просто каже: «не надсилайте порожнечу».

4. Response: що повертаємо клієнту

Коли логін успішний, клієнт очікує, що сервер дасть йому щось, чим можна користуватися далі. У stateless-моделі це «щось» — access token. І тут у багатьох новачків зʼявляється бажання «зробити зручно»: повернути і токен, і обʼєкт користувача, і профіль, і ролі, і налаштування, і улюблений колір кота. У результаті login endpoint стає «God endpoint» — один запит робить усе і ще трохи.

Проблема в тому, що така відповідь швидко стає токсичною. По-перше, ви дублюєте відповідальність: профіль має жити в profile-кінцевих точках. По-друге, ви починаєте розкривати дані там, де це не потрібно. По-третє, контракт розростається, і його складно підтримувати. А ще це просто незручно тестувати: ви змінюєте профіль — і раптом потрібно змінювати login response.

Тому відповідь має бути мінімальною і машинно читабельною: токен, його тип і час життя.

DTO AuthResponse: токен-дані без «сюрпризів»

Усередині security-слою у нас уже є IssuedAccessToken, а назовні ми віддаємо AuthResponse, привʼязаний до контракту API.

package com.example.securecontent.auth;

public class AuthResponse {

    // Сам JWT (або інший формат токена), який клієнт надсилатиме в Authorization
    private final String accessToken;

    // Зазвичай "Bearer" — робимо це явним, щоб контракт був читабельний без здогадок
    private final String tokenType;

    // Час життя в секундах: зручно для клієнтів (таймери, автооновлення тощо)
    private final long expiresIn;

    public AuthResponse(String accessToken, String tokenType, long expiresIn) {
        this.accessToken = accessToken;
        this.tokenType = tokenType;
        this.expiresIn = expiresIn;
    }

    public String getAccessToken() { return accessToken; }
    public String getTokenType() { return tokenType; }
    public long getExpiresIn() { return expiresIn; }
}

tokenType майже завжди буде Bearer. Можна було б захардкодити, але нехай це буде явно — клієнту так зрозуміліше, а контракт читається як стандартна схема.

Приклад JSON, який повернеться клієнту:

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.***",
  "tokenType": "Bearer",
  "expiresIn": 900
}

У expiresIn зручно зберігати секунди, наприклад 900 секунд = 15 хвилин, тому що так простіше працювати клієнтам. Усередині сервісу ви можете зберігати TTL у мілісекундах — але назовні краще віддавати те, що простіше використовувати.

Таблиця контракту відповіді: щоб не гадати, що це за поле

Поле Тип Приклад Навіщо потрібно клієнту
accessToken String "eyJ..." Прикладати до запитів (як bearer token)
tokenType String "Bearer" Зрозуміти, якою схемою користуватися в Authorization
expiresIn long 900 Зрозуміти час життя токена і вчасно перелогінитися

І ще раз: тут немає профілю, немає ролей як окремого поля відповіді, немає «userId» і всього такого. Усе це або всередині токена, як claims, або в окремих кінцевих точках.

5. Контролер: тонка HTTP-межа, а не місце для security-логіки

Контролер має робити нудні речі. Нудьга — це добре. Чим нудніший контролер, тим менша ймовірність, що завтра ви випадково зламаєте безпеку, додавши «тимчасову налагоджувальну штуку» на пів сторінки. У stateless-логіні контролер приймає request DTO, делегує в сервіс і повертає response DTO. Жодних if(password.equals(...)), жодних «давайте тут підпишемо JWT», жодних «ой, а давайте ще у відповідь додамо email».

Мінімальний контролер виглядає так:

package com.example.securecontent.auth;

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    // Контролер не знає нічого про підпис JWT і секрети — лише делегує
    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    // Публічна кінцева точка: сюди надходять облікові дані, звідси виходить лише відповідь із токеном
    @PostMapping("/login")
    public AuthResponse login(@Valid @RequestBody LoginRequest request) {
        // Усередині AuthService уже живе вся security-логіка
        return authService.login(request);
    }
}

Пʼять важливих спостережень, які варто відразу засвоїти. Ми не бачимо тут токен-секрету, не бачимо TTL, не бачимо AuthenticationManager і не бачимо TokenService. Контролер не знає, як саме аутентифікують користувача і як саме формують токен. Він знає лише контракт: що приймає і що віддає.

Якщо ваш SecurityFilterChain закриває все підряд, що за замовчуванням дуже любить робити Spring Security, не забудьте, що /api/auth/login має бути доступним анонімному користувачу. Інакше вийде ідеальна безпека: увійти неможливо навіть вам. З погляду security це непогано, з погляду продукту — трохи незручно.

6. HTTP-деталі: метод і коди відповідей

Навіть якщо ми «просто робимо JSON», HTTP — це частина контракту. І якщо її не зафіксувати, клієнти вгадуватимуть. А вгадування в безпеці — це завжди гра «вгадайте, де ми повернемо 401, а де 403, а де 200 із текстом «ой»».

Для нашої login-кінцевої точки розумний базовий варіант такий: успішний логін повертає 200 OK і JSON AuthResponse. Якщо request невалідний, наприклад порожні поля, це зазвичай 400 Bad Request із вашим стандартним JSON-контрактом помилок. Якщо облікові дані неправильні, це authentication failure, і в REST-friendly моделі це зазвичай 401 Unauthorized. Важливо, що це не forbidden: користувач ще не довів, хто він.

Якщо ви вже робили єдиний JSON-контракт помилок, у нас це було раніше в курсі, то клієнт бачитиме єдиний формат помилки, а не HTML-сторінку входу або редирект. І це величезна перемога для будь-якого API-клієнта: Postman, curl, мобільного застосунку і навіть вашого майбутнього автотесту.

Щоб вручну перевірити контракт, зручно використовувати curl. Приклад запиту:

# -i: показати статус і заголовки, щоб бачити HTTP-код
# -H: явно задаємо JSON, щоб сервер коректно розібрав тіло
# -d: надсилаємо тіло запиту з login/password
curl -i -X POST "http://localhost:8080/api/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"login":"alice","password":"p@ssw0rd"}'

Якщо все добре, ви отримаєте 200 і JSON із токеном. А якщо облікові дані неправильні — 401. І ось тут важливий момент безпеки: в error response не потрібно писати «користувача не знайдено» або «пароль неправильний». Чим менше ви розповідаєте зловмиснику, тим менше у нього матеріалу для атак. Клієнту достатньо «увійти не вдалося».

7. Типові помилки

Помилка №1: «Давайте в запит додамо ролі/authorities, щоб було зручно».
Це одна з найнебезпечніших спокус. Щойно клієнт починає надсилати в login-запит дані про права, ви ризикуєте отримати сценарій, де через баг або неправильний мапінг сервер «повірив» клієнту і видав токен із зайвими повноваженнями. Правильне джерело ролей і прав — серверна модель користувача та результат аутентифікації, а не тіло запиту.

Помилка №2: повертати в response повний UserAccount або UserProfile.
Це здається зручним («одним запитом отримав і токен, і профіль»), але на практиці робить контракт важким, ламким і небезпечним. Login-кінцева точка має повертати token data, а профіль — це окремий ресурс із окремими правилами доступу. Інакше ви швидко отримаєте «занадто розумний login», який неможливо стабільно підтримувати.

Помилка №3: контролер стає «центром всесвіту»: сам перевіряє пароль і сам підписує JWT.
Коли в контролері зʼявляються виклики PasswordEncoder, складання claims і підпис токена, HTTP-шар починає знати занадто багато. Це ускладнює тестування, погіршує читабельність і підвищує шанс, що хтось додасть туди налагоджувальний лог із паролем. Контролер має бути тонкою межею: прийняв запит → викликав сервіс → повернув відповідь.

Помилка №4: занадто докладні причини відмови під час логіну.
Повідомлення на кшталт user not found і wrong password допомагають зловмиснику вгадувати наявні логіни. Клієнту зазвичай достатньо загальної причини invalid credentials. Деталі мають залишатися на стороні сервера — і навіть там обережно, без витоку чутливих даних у логи.

Помилка №5: спроба змішати browser semantics і JSON API.
Іноді за звичкою вмикають redirect на login page або повертають HTML-сторінку при помилці. Для REST API це ламає клієнтську інтеграцію: Postman і мобільний застосунок не чекають HTML. Тримайте контракт машинно читабельним: вхід і вихід — JSON, помилки — у вашому JSON-контракті помилок, без редиректів і «сторінок».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ