Specialization Frontend (React + TypeScript) — foundation ready. Open the frontend section →

Паттерны аутентификации и авторизации

Простыми словами: аутентификация против авторизации, 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.

Самый простой вариант. Пользователь вводит логин и пароль, сервер проверяет их, создаёт сессию в 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 ничего не получит.

Упрощённо поток выглядит так:

  1. SPA обращается к backend — нет cookie → 401.
  2. Backend перенаправляет браузер на страницу входа IdP.
  3. Пользователь вводит пароль на сайте IdP (не приложения).
  4. IdP возвращает code в callback.
  5. Backend обменивает code + code_verifier на токены у IdP.
  6. Токены сохраняются на сервере (Redis), браузер получает HttpOnly cookie.
  7. Дальнейшие запросы идут с 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

Альтернатива, когда внешний 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 cookieStateless, но сложнее инвалидация
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 + ABACRBAC на уровне API Gateway/BFF, ABAC внутри сервиса

Авторизация в микросервисах

В микросервисной архитектуре запрос проходит через несколько уровней, и на каждом проверяется своё:

API Gateway — технический контроль: токен есть, подпись верна, срок не истёк, лимиты не превышены. Пробрасывает идентификатор пользователя в downstream-сервисы.

BFF / Application Layer — грубая проверка по роли: этому пользователю вообще доступен этот эндпоинт?

Доменный сервис — тонкая проверка: пользователь — владелец этого конкретного объекта? Статус объекта позволяет операцию? Это нельзя вынести на Gateway — он не знает доменную модель.

Правило: Gateway решает «кто», сервис решает «что можно».

Коротко

  • Аутентификация — кто ты, авторизация — что тебе можно. Это разные задачи.
  • Session ID инвалидируется мгновенно, JWT проверяется локально без обращения к серверу.
  • Для браузера — токены только в HttpOnly cookie, никогда в localStorage.
  • OAuth2 + PKCE — стандарт для SPA и мобильных приложений: пользователь входит у IdP, приложение пароля не видит.
  • Для мобильных — вход через системный браузер (не WebView), токены в Keychain.
  • Refresh Token Rotation защищает от повторного использования украденного токена.
  • RBAC — роли, ABAC — атрибуты объекта и контекста, Resource-Based — владелец.
  • В микросервисах: Gateway проверяет токен, доменный сервис проверяет бизнес-правила.

Что почитать дальше