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

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

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

1. Фиксация контракта login endpoint’а

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

В stateless модели контракт особенно важен, потому что POST /api/auth/login становится центральным: это точка, где мы превращаем «сырые credentials» (логин/пароль) в token, который дальше будет использоваться клиентом. Если контракт плавает, то плавает и всё окружение: документация, тесты, обработка ошибок и даже безопасность (потому что лишнее поле в request — это иногда не «удобство», а дырка).

Есть ещё один практический момент: Spring Security даёт много готового поведения (например, browser-oriented form login), но мы сейчас делаем JSON login endpoint. Значит, именно мы отвечаем за форму request/response. Если её не зафиксировать сразу, появится соблазн «быстренько добавить ещё одно поле», потом ещё одно, и внезапно login endpoint начнёт отдавать профиль, настройки, роль, список черновиков и прогноз погоды на завтра. Это не шутка — это типичная судьба endpoint’ов без границ.

2. Границы login endpoint’а

Чтобы endpoint был здоровым (в смысле архитектуры, а не «в смысле не кашляет»), нужно честно определить его обязанности. Логин-эндпоинт в stateless JWT-модели — это не «получить пользователя» и не «разрешить доступ», а именно провести аутентификацию и выдать артефакт, который клиент потом будет прикладывать к запросам. Мы держим в голове разделение: AuthenticationManager отвечает на вопрос «кто ты?», а TokenService — «какой token выдать подтверждённому пользователю?».

Хорошая mental model: представьте турникет в метро. Турникет не обязан рассказывать вам историю метро, печатать карту маршрутов и показывать новости. Он делает две вещи: проверяет, что билет валиден, и открывает проход. Наш POST /api/auth/login — это «проверить билет» (через AuthenticationManager) и «выдать пропуск» (access token). Всё остальное — в других endpoint’ах. Если клиенту нужен профиль — пусть идёт в /api/me/profile. Если нужны роли/права — они должны быть внутри токена (или вычисляться на сервере), но не приниматься от клиента в login request.

Полезно увидеть поток в виде схемы — так меньше шансов «подмешать» в него лишнюю ответственность:

flowchart TD
    Client[API client] -->|POST /api/auth/login| AuthController[AuthController]
    AuthController --> AuthService[AuthService]
    AuthService -->|authenticate| AuthenticationManager[AuthenticationManager]
    AuthenticationManager -->|success| AuthService
    AuthService -->|issue access token| TokenService[TokenService]
    TokenService -->|token| AuthService
    AuthService -->|response| AuthController
    AuthController -->|200 OK JSON| Client

Заметьте важную деталь: здесь нет сессии, нет cookies, нет редиректов на HTML-страницы, нет “login page”. Это обычный JSON endpoint, как и все остальные в REST API.

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

Когда мы делаем login request, у нас есть соблазн «улучшить UX» прямо на уровне API: добавить “rememberMe”, “deviceId”, “timezone”, “language”, “preferredRole” и “а можно я сразу буду админом”. И вот последняя часть — не шутка: как только клиент начинает присылать что-то про роли или права, вы открываете двери для атак класса “privilege escalation” через баг в обработке request. Поэтому на уровне fundamentals мы держим request максимально простым.

Минимальный request для логина в нашей модели — это две строки: идентификатор (логин) и пароль. Всё. Это похоже на замочную скважину: если она слишком широкая, туда залезает кто угодно и что угодно, и потом вы удивляетесь, почему дверь плохо закрывается.

Имя поля: 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 endpoint’ах. Во‑вторых, вы начинаете раскрывать данные там, где это не нужно. В‑третьих, контракт разрастается, и его сложно поддерживать. А ещё это просто неудобно тестировать: вы меняете профиль — и внезапно нужно менять login response.

Поэтому 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), либо в отдельных endpoint’ах.

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;
    }

    // Публичный endpoint: сюда приходят credentials, отсюда уходит только токен-ответ
    @PostMapping("/login")
    public AuthResponse login(@Valid @RequestBody LoginRequest request) {
        // Внутри AuthService уже живёт вся security-логика
        return authService.login(request);
    }
}

Пять важных наблюдений, которые стоит «вживить в руки» сразу. Мы не видим здесь токен-секрета, не видим TTL, не видим AuthenticationManager и не видим TokenService. Контроллер не знает, как именно аутентифицируют пользователя и как именно формируют токен. Он знает только контракт: что принимает и что отдаёт.

Если ваш SecurityFilterChain закрывает всё подряд (что по умолчанию очень любит делать Spring Security), не забудьте, что /api/auth/login должен быть доступен anonymous-пользователю. Иначе получится идеальная безопасность: войти невозможно даже вам. С точки зрения security это неплохо, с точки зрения продукта — слегка неудобно.

6. HTTP-детали: метод и коды ответов

Даже если мы “просто делаем JSON”, HTTP — это часть контракта. И если её не зафиксировать, клиенты будут угадывать. А угадывание в безопасности — это всегда игра “угадай, где мы вернём 401, а где 403, а где 200 с текстом ‘ой’”.

Для нашего login endpoint разумный baseline такой: успешный логин возвращает 200 OK и JSON AuthResponse. Если request невалидный (например, пустые поля), это обычно 400 Bad Request с вашим стандартным JSON error contract. Если credentials неверные, это authentication failure, и в REST-friendly модели это обычно 401 Unauthorized. Важно, что это не “forbidden”: пользователь ещё не доказал, кто он.

Если вы уже делали единый JSON error contract (у нас это было раньше в курсе), то клиент будет видеть единый формат ошибки, а не HTML-страницу логина или редирект. И это огромная победа для любого API-клиента: Postman, curl, мобильного приложения и даже вашего будущего автотеста.

Чтобы руками проверить контракт, удобно использовать curl. Пример запроса:

# -i: показать статус и заголовки, чтобы видеть HTTP-код
# -H: явно задаём JSON, чтобы сервер корректно распарсил тело
# -d: отправляем request body с 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 с токеном. А если credentials плохие — 401. И вот здесь важный момент безопасности: в error response не нужно писать “пользователь не найден” или “пароль неверный”. Чем меньше вы рассказываете злоумышленнику, тем меньше у него материала для атак. Клиенту достаточно “не удалось войти”.

7. Типичные ошибки

Ошибка №1: “Давайте в request добавим роли/authorities, чтобы было удобно”.
Это один из самых опасных соблазнов. Как только клиент начинает присылать в login request данные о правах, вы рискуете получить сценарий, где из-за бага или неправильного маппинга сервер “поверил” клиенту и выдал токен с лишними полномочиями. Правильный источник ролей и прав — серверная user model и результат аутентификации, а не request body.

Ошибка №2: возвращать в response полный UserAccount или UserProfile.
Это кажется удобным (“одним запросом получил и токен, и профиль”), но на практике делает контракт тяжёлым, ломким и небезопасным. Login endpoint должен возвращать token data, а профиль — это отдельный ресурс с отдельными правилами доступа. Иначе вы быстро получите «слишком умный login», который невозможно стабильно поддерживать.

Ошибка №3: контроллер становится “центром вселенной”: сам проверяет пароль и сам подписывает JWT.
Когда в контроллере появляются вызовы PasswordEncoder, сборка claims и подпись токена, HTTP-слой начинает знать слишком много. Это усложняет тестирование, ухудшает читаемость и повышает шанс, что кто-то добавит туда отладочный лог с паролем. Контроллер должен быть тонкой границей: принял request → вызвал сервис → вернул response.

Ошибка №4: слишком подробные причины отказа при логине.
Сообщения вроде user not found и wrong password помогают злоумышленнику угадывать существующие логины (user enumeration). Клиенту обычно достаточно общей причины invalid credentials. Детали должны оставаться на стороне сервера (и даже там — аккуратно, без утечки чувствительных данных в логи).

Ошибка №5: попытка смешать browser-semantics и JSON API.
Иногда по привычке включают redirect на login page, или возвращают HTML-страницу при ошибке. Для REST API это ломает клиентскую интеграцию: Postman и мобильное приложение не ждут HTML. Держите контракт машинно читаемым: вход и выход — JSON, ошибки — в вашем JSON error contract, без редиректов и “страничек”.

1
Задача
Spring Security, 23 уровень, 1 лекция
Недоступна
Фиксированный JSON-контракт для POST /api/auth/login
Фиксированный JSON-контракт для POST /api/auth/login
1
Задача
Spring Security, 23 уровень, 1 лекция
Недоступна
Валидация пустых полей в login request
Валидация пустых полей в login request
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ