Паттерны аутентификации и авторизации
Простыми словами: аутентификация против авторизации, JWT и сессии, OAuth2 с PKCE для браузера и мобильных приложений, RBAC и ABAC, защита сервис-сервис, PII в логах и аудит — когда что выбрать и почему.
Каждое приложение рано или поздно задаётся двумя вопросами: кто этот пользователь и что ему разрешено делать. Это разные задачи с разными инструментами, и их легко перепутать. Разберём с нуля.
Аутентификация и авторизация — в чём разница
Аутентификация отвечает на вопрос «кто ты?». Пользователь доказывает свою личность: вводит логин и пароль, предъявляет токен, проходит Face ID. Результат — система знает, что перед ней конкретный пользователь, например Иван с userId=42.
Авторизация отвечает на вопрос «что тебе можно?». Уже зная, кто пользователь, система решает: может ли Иван удалить чужую статью, открыть страницу администратора, посмотреть чужой заказ.
На практике они идут одна за другой: сначала аутентификация, потом авторизация. Вернуть 403 Forbidden без проверки личности — ошибка.
Токены: JWT, Opaque и Session ID
Когда пользователь вошёл, сервер должен как-то «помнить» его в следующих запросах. Есть три подхода:
Session ID — сервер создаёт сессию, хранит её (обычно в Redis), а клиенту отдаёт только короткий идентификатор в cookie. При каждом запросе сервер загружает сессию из хранилища.
JWT (JSON Web Token) — токен, который содержит данные прямо внутри себя. Структура: header.payload.signature. В payload лежат userId, роли, срок действия. Подпись позволяет серверу проверить токен локально, без обращения к базе. Размер — обычно 300–800 байт.
eyJhbGciOiJSUzI1NiJ9. ← Header: {"alg": "RS256"}
eyJzdWIiOiI0MiIsInJvbGVzIjpb ← Payload: {"sub": "42", "roles": ["ADMIN"], "exp": 1710000000}
IkFETUlOIl19.
SflKxwRJSMeKKF2QT4fwpMe... ← Signature: RSA подпись
Opaque Token — просто строка-идентификатор вроде a3f8b2c1-4d5e-.... Данных внутри нет. Для проверки нужно обратиться к серверу авторизации (этот запрос называется introspection).
| Тип | Проверка | Инвалидация | Размер |
|---|---|---|---|
| Session ID | Запрос к Redis | Мгновенная (удалить из Redis) | Маленький |
| JWT | Локально (подпись) | Нельзя до истечения | Средний |
| Opaque | Запрос к auth-серверу | Мгновенная | Маленький |
OAuth 2.0 и OpenID Connect
Раньше приложения просили у пользователя его логин и пароль, а потом от его имени ходили в другие сервисы. Это небезопасно: приложение видит пароль и может делать что угодно.
OAuth 2.0 решает эту проблему: пользователь входит напрямую в доверенный сервис (например, Keycloak или Google), а приложение получает только ограниченный токен доступа — без пароля.
OpenID Connect (OIDC) — надстройка над OAuth 2.0. Добавляет к токену доступа ещё id_token — JWT с информацией о пользователе (имя, email, роли). Именно OIDC превращает OAuth из протокола «что приложению разрешено» в протокол «кто этот пользователь».
| Токен | Назначение |
|---|---|
access_token | Что приложению разрешено делать |
refresh_token | Получить новый access_token без повторного входа |
id_token | Кто пользователь (только OIDC) |
Аутентификация для браузерного приложения (SPA)
Браузер — небезопасная среда. JavaScript-код виден всем через DevTools. Хранилища localStorage и sessionStorage доступны любому скрипту на странице. Один вредоносный скрипт в сторонней библиотеке — и все токены скомпрометированы. Это называется XSS-атакой.
Браузер умеет кое-что важное: он поддерживает cookie с флагом HttpOnly. Такой cookie JavaScript вообще не видит — только браузер. Именно это свойство используют все безопасные подходы для SPA.
Сессия в cookie + Redis (классика)
Самый простой вариант. Пользователь вводит логин и пароль, сервер проверяет их, создаёт сессию в Redis и возвращает её идентификатор в HttpOnly cookie.
Set-Cookie: SESSION=abc123;
HttpOnly; ← JavaScript не может прочитать
Secure; ← только через HTTPS
SameSite=Lax; ← защита от межсайтовых запросов
Max-Age=86400 ← 1 день
Плюсы: сессию легко удалить (мгновенная выдача пользователя из системы), простая реализация.
Минусы: нужен Redis для хранения сессий, не подходит для мобильных приложений.
OAuth2 Authorization Code Flow + PKCE (рекомендуется для SPA)
Современный стандарт. Пользователь входит через внешний Identity Provider (Keycloak, Okta, Entra ID) — приложение никогда не видит его пароль. Токены хранятся на сервере, а в браузер идёт только сессионная cookie.
PKCE (Proof Key for Code Exchange) — дополнение к потоку, защищающее от кражи промежуточного кода авторизации. Приложение генерирует случайный code_verifier, отправляет его хеш при запросе, а сам code_verifier — при обмене кода на токен. Перехватчик без code_verifier ничего не получит.
Упрощённо поток выглядит так:
- SPA обращается к backend — нет cookie →
401. - Backend перенаправляет браузер на страницу входа IdP.
- Пользователь вводит пароль на сайте IdP (не приложения).
- IdP возвращает code в callback.
- Backend обменивает code +
code_verifierна токены у IdP. - Токены сохраняются на сервере (Redis), браузер получает
HttpOnlycookie. - Дальнейшие запросы идут с cookie — backend достаёт токены из Redis.
Почему это хорошо: токены никогда не попадают в JavaScript, XSS не страшен, пароль видит только IdP, IdP может обеспечить MFA и единый вход (SSO).
Конфигурация для Spring Boot:
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: my-web-app
client-secret: ${KEYCLOAK_CLIENT_SECRET}
scope: openid,profile,email,offline_access
authorization-grant-type: authorization_code
provider:
keycloak:
issuer-uri: https://keycloak.example.com/realms/my-realm
JWT в HttpOnly cookie (без IdP)
Альтернатива, когда внешний IdP не нужен. Сервер выдаёт JWT и кладёт его в HttpOnly cookie. Проверка — локально по подписи, Redis не нужен.
Главный недостаток: JWT нельзя «отозвать» до истечения срока. Если нужна мгновенная выдача — придётся вести список отозванных токенов (черный список), что фактически возвращает нас к серверному хранилищу.
@Component
public class JwtCookieFilter extends OncePerRequestFilter {
private final JwtDecoder jwtDecoder;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String token = extractFromCookie(req, "TOKEN");
if (token != null) {
try {
Jwt jwt = jwtDecoder.decode(token);
var auth = new JwtAuthenticationToken(jwt,
jwt.getClaimAsStringList("roles").stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.toList());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (JwtException ignored) { }
}
chain.doFilter(req, res);
}
}
JWT в localStorage — почему это плохо
В туториалах часто хранят токен в localStorage. Это удобно, но опасно: любой JavaScript на странице может его прочитать. Одна уязвимость в сторонней аналитике или рекламном SDK — и токены пользователей скомпрометированы.
Если используете JWT для браузера — только в HttpOnly cookie.
Что выбрать для SPA
| Подход | Когда использовать |
|---|---|
| OAuth2 + PKCE через BFF | Лучший выбор при наличии IdP (Keycloak, Okta) |
| Сессия (cookie + Redis) | Простой вариант без внешнего IdP |
| JWT в HttpOnly cookie | Stateless, но сложнее инвалидация |
| JWT в localStorage | Не использовать в продакшене |
Аутентификация для мобильного приложения
Мобильное приложение — другая среда. У него есть защищённое хранилище (iOS Keychain, Android EncryptedSharedPreferences), куда можно безопасно положить токены. Нет браузерного cookie — токены передаются в заголовке Authorization: Bearer <token>.
OAuth2 + PKCE для мобильных (рекомендуется)
Тот же Authorization Code Flow + PKCE, но с двумя отличиями:
- Нет
client_secret— мобильное приложение не может хранить секрет безопасно. Это нормально: его роль берёт на себя PKCE. - Вход через системный браузер (Chrome Custom Tabs на Android, ASWebAuthenticationSession на iOS), а не встроенный WebView. WebView позволял бы приложению перехватить введённые данные — системный браузер этого не даёт.
// Android, AppAuth SDK
val authRequest = AuthorizationRequest.Builder(
serviceConfig,
"mobile-app",
ResponseTypeValues.CODE,
Uri.parse("myapp://callback")
).setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier())
.setScopes("openid", "profile", "email", "offline_access")
.build()
После успешного входа access_token и refresh_token сохраняются в Keychain / EncryptedSharedPreferences.
JWT Bearer Token
Приложение получает JWT от сервера авторизации и добавляет его к каждому запросу. Backend проверяет подпись локально по открытым ключам IdP (JWKS).
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/my-realm
Когда access_token истекает (обычно через 5–15 минут), приложение автоматически обновляет его с помощью refresh_token, не беспокоя пользователя повторным входом.
Opaque Token + Introspection
Приложение получает непрозрачный токен. Backend при каждом запросе спрашивает сервер авторизации: «этот токен действителен?» — и получает данные пользователя.
Плюс: мгновенная инвалидация — auth-сервер отзывает токен, и следующий запрос вернёт 401.
Минус: каждый запрос = дополнительный сетевой вызов к auth-серверу. Если auth-сервер недоступен — всё приложение перестаёт работать.
Обновление токенов
access_token живёт недолго — 5–15 минут. refresh_token — дни или недели. Когда access_token истекает, приложение использует refresh_token, чтобы получить новый, без повторного входа пользователя.
Refresh Token Rotation — хорошая практика безопасности: при каждом обновлении IdP выдаёт новый refresh_token и инвалидирует старый. Если злоумышленник украл токен и попытался использовать его повторно — IdP заметит это и инвалидирует всю цепочку. Следующий же запрос оригинального клиента вернёт 401, что сигнализирует о компрометации.
Авторизация: что пользователю разрешено
После того как система знает, кто пользователь, нужно решить — что ему можно. Есть три основных подхода.
RBAC — роли
Пользователю назначают роль (ADMIN, EDITOR, VIEWER), роль определяет доступные операции. Проверяется до входа в бизнес-логику.
@RestController
public class ArticleController {
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/articles/{id}")
public void delete(@PathVariable Long id) {
articleService.delete(id);
}
@PreAuthorize("hasAnyRole('ADMIN', 'EDITOR')")
@PutMapping("/articles/{id}")
public ArticleDto update(@PathVariable Long id, @RequestBody UpdateRequest body) {
return articleService.update(id, body);
}
}
Подходит, когда набор ролей фиксирован и небольшой. Не подходит, когда нужно проверять свойства конкретного объекта.
ABAC — атрибуты
Решение о доступе принимается на основе атрибутов: пользователя, ресурса, действия и контекста. Правила выносятся в отдельный компонент политики.
@Component("access")
public class AccessPolicy {
public boolean canEditArticle(Long articleId, UserPrincipal user) {
Article article = articleRepository.findById(articleId).orElse(null);
if (article == null) return false;
return user.hasRole("EDITOR")
&& article.getDepartment().equals(user.getDepartment())
&& article.getStatus() == ArticleStatus.DRAFT;
}
}
// В контроллере:
// @PreAuthorize("@access.canEditArticle(#id, authentication.principal)")
Здесь условие сложнее: пользователь может редактировать статью только если он редактор, статья из его отдела и ещё в статусе черновика. RBAC с этим не справится — нужно знать данные конкретного объекта.
Resource-Based — владелец
Частный случай ABAC: пользователь видит и изменяет только свои объекты.
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable Long id, Authentication auth) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Order not found"));
Long currentUserId = ((UserPrincipal) auth.getPrincipal()).getId();
if (!order.getUserId().equals(currentUserId)) {
throw new AccessDeniedException("Not your order");
}
return orderMapper.toDto(order);
}
Когда что использовать
| Модель | Когда подходит |
|---|---|
| RBAC | Фиксированные роли, грубая проверка |
| ABAC | Доступ зависит от свойств объекта или контекста |
| Resource-Based | Пользователь работает только со своими данными |
| RBAC + ABAC | RBAC на уровне API Gateway/BFF, ABAC внутри сервиса |
Авторизация в микросервисах
В микросервисной архитектуре запрос проходит через несколько уровней, и на каждом проверяется своё:
API Gateway — технический контроль: токен есть, подпись верна, срок не истёк, лимиты не превышены. Пробрасывает идентификатор пользователя в downstream-сервисы.
BFF / Application Layer — грубая проверка по роли: этому пользователю вообще доступен этот эндпоинт?
Доменный сервис — тонкая проверка: пользователь — владелец этого конкретного объекта? Статус объекта позволяет операцию? Это нельзя вынести на Gateway — он не знает доменную модель.
Правило: Gateway решает «кто», сервис решает «что можно».
Коротко
- Аутентификация — кто ты, авторизация — что тебе можно. Это разные задачи.
- Session ID инвалидируется мгновенно, JWT проверяется локально без обращения к серверу.
- Для браузера — токены только в
HttpOnlycookie, никогда вlocalStorage. - OAuth2 + PKCE — стандарт для SPA и мобильных приложений: пользователь входит у IdP, приложение пароля не видит.
- Для мобильных — вход через системный браузер (не WebView), токены в Keychain.
- Refresh Token Rotation защищает от повторного использования украденного токена.
- RBAC — роли, ABAC — атрибуты объекта и контекста, Resource-Based — владелец.
- В микросервисах: Gateway проверяет токен, доменный сервис проверяет бизнес-правила.
Что почитать дальше
- Auth Patterns Style Guide — правила для ревью кода с кодами
AUTH-N. - REST API: заголовки и трассировка — заголовок
Authorizationи идемпотентность.