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

Аутентификация и авторизация для SPA, мобильных приложений и микросервисов: OAuth2, JWT, RBAC, ABAC. Со схемами и Spring Boot примерами.

Статья внедрена в скилл AI-агента ucp-auth-review / ucp-auth-design Паттерны авторизации

Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс. Статья описывает подходы к аутентификации и авторизации для двух типов клиентов — веб-приложений (SPA) и мобильных приложений. Для каждого подхода объясняется, почему он подходит или не подходит конкретному типу клиента, с примерами конфигурации и кода на Spring Boot.

Основные понятия

Аутентификация vs Авторизация

  • Аутентификация (AuthN) — ответ на вопрос «кто ты?». Пользователь доказывает свою личность: вводит логин/пароль, предъявляет токен, сканирует отпечаток пальца.
  • Авторизация (AuthZ) — ответ на вопрос «что тебе можно?». Система проверяет, имеет ли аутентифицированный пользователь доступ к запрошенному ресурсу.
diagram

OAuth 2.0 и OpenID Connect

  • OAuth 2.0 — протокол авторизации. Позволяет приложению получить ограниченный доступ к ресурсам пользователя без передачи пароля. Отвечает на вопрос «что приложению разрешено делать?», но не «кто пользователь?».
  • OpenID Connect (OIDC) — надстройка над OAuth 2.0, добавляющая аутентификацию. Кроме access_token, сервер выдаёт id_token — JWT с информацией о пользователе (имя, email, роли).
ТокенНазначение
access_tokenАвторизация: что можно делать
refresh_tokenОбновление access_token
id_tokenАутентификация: кто пользователь (OIDC)

Типы токенов

  • JWT (JSON Web Token) — self-contained токен. Содержит закодированные данные (claims) и цифровую подпись. Сервис может проверить токен локально, без обращения к серверу авторизации. Структура: header.payload.signature (Base64).
eyJhbGciOiJSUzI1NiJ9.            ← Header:  {"alg": "RS256"}
eyJzdWIiOiI0MiIsInJvbGVzIjpb     ← Payload: {"sub": "42", "roles": ["ADMIN"], "exp": 1710000000}
IkFETUlOIl19.
SflKxwRJSMeKKF2QT4fwpMe...       ← Signature: RSA подпись
  • Opaque Token — непрозрачная строка-идентификатор (a3f8b2c1-4d5e-...). Не содержит данных. Для проверки нужно обращение к серверу авторизации (introspection).
  • Session ID — идентификатор серверной сессии, передаётся в cookie. Данные хранятся на сервере.

Часть 1. Аутентификация для веб-приложений (SPA)

Веб-приложение работает в браузере. Это принципиально менее защищённая среда, чем нативное приложение:

  • JavaScript-код доступен всем (DevTools, view-source)
  • localStorage / sessionStorage уязвимы к XSS — любой скрипт на странице может прочитать данные
  • Браузер не имеет защищённого хранилища (аналога Keychain на iOS)
  • Зато браузер умеет работать с cookie, в том числе HttpOnly (недоступными из JS)

Классический подход. Сервер создаёт сессию, хранит её в памяти или Redis, а клиенту отдаёт только идентификатор сессии в HttpOnly cookie.

diagram

Безопасность cookie:

Set-Cookie: SESSION=abc123;
    HttpOnly;       ← JavaScript не может прочитать
    Secure;         ← только через HTTPS
    SameSite=Lax;   ← защита от CSRF
    Path=/;
    Max-Age=86400   ← 1 день
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400)
public class SessionConfig {

    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer s = new DefaultCookieSerializer();
        s.setCookieName("SESSION");
        s.setCookiePath("/");
        s.setUseHttpOnlyCookie(true);
        s.setUseSecureCookie(true);
        s.setSameSite("Lax");
        return s;
    }
}

Достоинства:

  • Токены не хранятся в JavaScript — HttpOnly cookie недоступна из JS
  • Легко инвалидировать сессию (удалить из Redis)
  • Простая реализация

Недостатки:

  • Требует хранилище сессий (Redis) для горизонтального масштабирования
  • Не подходит для мобильных приложений (нет cookie)

1.2 OAuth2 Authorization Code Flow + PKCE (рекомендуемый для SPA)

Современный стандарт для SPA. Браузер не хранит токены — они живут на сервере (в BFF или backend). Клиент получает только session cookie.

PKCE (Proof Key for Code Exchange) — расширение, защищающее от перехвата authorization code. Клиент генерирует случайный code_verifier, отправляет его хеш (code_challenge) при запросе кода, а оригинальный code_verifier — при обмене кода на токен.

diagram
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
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .oauth2Login(oauth2 -> oauth2
                .authorizationEndpoint(auth -> auth
                    .authorizationRequestResolver(pkceResolver()))
                .successHandler(new RedirectToSpaHandler("/dashboard")))
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessHandler(new RedirectToSpaHandler("/")))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/**").permitAll()
                .anyRequest().authenticated())
            .build();
    }

    private OAuth2AuthorizationRequestResolver pkceResolver() {
        var resolver = new DefaultOAuth2AuthorizationRequestResolver(
            clientRegistrationRepository, "/oauth2/authorization");
        resolver.setAuthorizationRequestCustomizer(
            OAuth2AuthorizationRequestCustomizers.withPkce());
        return resolver;
    }
}

Почему именно этот подход для SPA:

  • Токены никогда не попадают в браузер — защита от XSS
  • PKCE защищает от перехвата authorization code
  • Refresh token хранится на сервере — можно обновлять прозрачно
  • IdP (Keycloak, Okta) берёт на себя логин/пароль, MFA, SSO

Альтернатива: JWT хранится в HttpOnly cookie вместо серверной сессии. Сервер не хранит состояние — JWT содержит всю информацию о пользователе.

@Component
@RequiredArgsConstructor
public class JwtCookieFilter extends OncePerRequestFilter {

    private final JwtDecoder jwtDecoder;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {

        String token = extractTokenFromCookie(req, "TOKEN");
        if (token != null) {
            try {
                Jwt jwt = jwtDecoder.decode(token);
                List<GrantedAuthority> authorities = jwt.getClaimAsStringList("roles").stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                    .toList();
                var auth = new JwtAuthenticationToken(jwt, authorities);
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException e) {
                SecurityContextHolder.clearContext();
            }
        }
        chain.doFilter(req, res);
    }

    private String extractTokenFromCookie(HttpServletRequest req, String name) {
        if (req.getCookies() == null) return null;
        return Arrays.stream(req.getCookies())
            .filter(c -> name.equals(c.getName()))
            .findFirst()
            .map(Cookie::getValue)
            .orElse(null);
    }
}

Достоинства: stateless, не нужен Redis, JWT проверяется локально. Недостатки: JWT нельзя инвалидировать до истечения срока (нужен blacklist), размер cookie ограничен (~4 KB), refresh сложнее.

Важно: JWT в localStorage для веба — антипаттерн. Любой XSS-скрипт прочитает токен. Если используете JWT для веба — только в HttpOnly cookie.

1.4 SPA + Token in localStorage (НЕ рекомендуется)

Этот подход часто встречается в туториалах, но имеет серьёзные проблемы с безопасностью:

  • localStorage доступен любому JavaScript на странице
  • Подключённая сторонняя библиотека (analytics, ad SDK) может содержать вредоносный код
  • Один XSS — и все токены скомпрометированы
  • В отличие от cookie, localStorage не имеет HttpOnly флага

Когда допустимо: внутренние инструменты с контролируемым окружением, прототипы, обучающие проекты.

Сравнение подходов для веба

ПодходКогда
OAuth2 Authorization Code + PKCE через BFFЛучший выбор для SPA с IdP (Keycloak, Okta)
Session-Based (cookie + Redis)Простой вариант без внешнего IdP
JWT в HttpOnly cookieStateless, но сложнее инвалидация и refresh
JWT в localStorage❌ Не рекомендуется

Часть 2. Аутентификация для мобильных приложений

Мобильное приложение — принципиально другая среда:

  • Есть защищённое хранилище (iOS Keychain, Android EncryptedSharedPreferences)
  • Нет cookie (нативный HTTP-клиент не работает с cookie как браузер)
  • Приложение — «доверенный клиент» (код не видим пользователю в DevTools)
  • Токен передаётся в заголовке Authorization: Bearer <token>

2.1 OAuth2 Authorization Code Flow + PKCE (рекомендуемый)

Для мобильных приложений используется тот же Authorization Code Flow + PKCE, но с двумя отличиями:

  • Нет client_secret (мобильное приложение — public client, секрет нельзя безопасно хранить в APK/IPA)
  • Redirect URI использует custom scheme (myapp://callback) или App Links / Universal Links

Важно: мобильное приложение открывает системный браузер (Chrome Custom Tabs, ASWebAuthenticationSession), а не встроенный WebView. WebView позволяет приложению перехватить введённые креденшалы, а системный браузер — нет.

Android (AppAuth SDK):

val serviceConfig = AuthorizationServiceConfiguration(
    Uri.parse("https://keycloak.example.com/realms/my-realm/protocol/openid-connect/auth"),
    Uri.parse("https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token")
)

val authRequest = AuthorizationRequest.Builder(
    serviceConfig,
    "mobile-app",
    ResponseTypeValues.CODE,
    Uri.parse("myapp://callback")
).setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier())
 .setScopes("openid", "profile", "email", "offline_access")
 .build()

val authIntent = authService.getAuthorizationRequestIntent(authRequest)
startActivityForResult(authIntent, RC_AUTH)

iOS (ASWebAuthenticationSession):

let session = ASWebAuthenticationSession(
    url: authURL,
    callbackURLScheme: "myapp"
) { callbackURL, error in
    guard let code = callbackURL?.queryParameters["code"] else { return }

    TokenService.exchange(code: code, codeVerifier: codeVerifier) { tokens in
        KeychainService.save(key: "access_token", value: tokens.accessToken)
        KeychainService.save(key: "refresh_token", value: tokens.refreshToken)
    }
}

session.presentationContextProvider = self
session.start()

2.2 Opaque Token + Token Introspection

Приложение получает opaque token от auth-сервера. Backend при каждом запросе обращается к auth-серверу для проверки токена.

@Component
@RequiredArgsConstructor
public class OpaqueTokenAuthProvider implements AuthenticationProvider {

    private final AuthServerClient authClient;

    @Override
    public Authentication authenticate(Authentication authentication) {
        String token = (String) authentication.getCredentials();
        UserInfo userInfo = authClient.getUserInfo(token);

        if (userInfo == null || userInfo.getSub() == null) {
            throw new BadCredentialsException("Invalid token");
        }

        UserPrincipal principal = UserPrincipal.builder()
            .userId(userInfo.getSub())
            .email(userInfo.getEmail())
            .name(userInfo.getName())
            .build();

        return new OpaqueAuthenticationToken(principal, token, Collections.emptyList());
    }
}

Достоинства: мгновенная инвалидация — auth-сервер может отозвать токен, и следующий запрос вернёт 401.

Недостатки: каждый запрос = дополнительный вызов к auth-серверу (latency), auth-сервер становится single point of failure.

2.3 JWT Bearer Token (самый распространённый)

Приложение получает JWT и передаёт его в каждом запросе. Backend проверяет подпись JWT локально.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer(oauth2 ->
                oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/**").permitAll()
                .anyRequest().authenticated())
            .build();
    }

    private JwtAuthenticationConverter jwtAuthConverter() {
        JwtGrantedAuthoritiesConverter rolesConverter = new JwtGrantedAuthoritiesConverter();
        rolesConverter.setAuthoritiesClaimName("realm_access.roles");
        rolesConverter.setAuthorityPrefix("ROLE_");
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(rolesConverter);
        return converter;
    }
}

Обновление токена на мобильном клиенте:

class TokenInterceptor(
    private val tokenStorage: TokenStorage,
    private val authService: AuthService
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        var accessToken = tokenStorage.getAccessToken()

        if (tokenStorage.isTokenExpiringSoon()) {
            accessToken = refreshToken()
        }

        val request = chain.request().newBuilder()
            .header("Authorization", "Bearer $accessToken")
            .build()
        val response = chain.proceed(request)

        if (response.code == 401) {
            response.close()
            accessToken = refreshToken()
            val retry = chain.request().newBuilder()
                .header("Authorization", "Bearer $accessToken")
                .build()
            return chain.proceed(retry)
        }
        return response
    }

    @Synchronized
    private fun refreshToken(): String {
        val refresh = tokenStorage.getRefreshToken() ?: throw AuthException("No refresh token")
        val tokens = authService.refreshToken(refresh)
        tokenStorage.saveAccessToken(tokens.accessToken)
        tokenStorage.saveRefreshToken(tokens.refreshToken)
        return tokens.accessToken
    }
}

Достоинства: stateless, быстро (локальная проверка), стандарт. Недостатки: JWT нельзя отозвать до истечения, размер токена больше opaque.

2.4 Biometric + Device-Bound Token

Продвинутый паттерн для мобильных приложений с высокими требованиями к безопасности (банки, платежи). Токен привязан к конкретному устройству, доступ к нему защищён биометрией.

diagram

iOS — пример генерации ключа в Secure Enclave:

let accessControl = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
    [.privateKeyUsage, .biometryCurrentSet],
    nil
)!

let attributes: [String: Any] = [
    kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
    kSecAttrKeySizeInBits as String: 256,
    kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
    kSecPrivateKeyAttrs as String: [
        kSecAttrIsPermanent as String: true,
        kSecAttrApplicationTag as String: "com.myapp.auth",
        kSecAttrAccessControl as String: accessControl
    ]
]

var error: Unmanaged<CFError>?
let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error)!
let publicKey = SecKeyCopyPublicKey(privateKey)!

Часть 3. Обновление токенов (Refresh Flow)

access_token обычно живёт 5–15 минут, refresh_token — дни или недели. Когда access_token истекает, клиент использует refresh_token для получения нового.

Refresh Token Rotation — при каждом использовании refresh_token IdP выдаёт новый и инвалидирует старый. Если злоумышленник украл refresh_token и использовал его — оригинальный клиент получит ошибку при следующем refresh, что служит сигналом компрометации.

diagram

Часть 4. Авторизация: что пользователю разрешено

Аутентификация определяет кто пользователь. Авторизация определяет что ему можно.

4.1 RBAC (Role-Based Access Control)

Пользователю назначаются роли, роли определяют доступные операции.

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

    @PreAuthorize("hasAnyRole('ADMIN', 'EDITOR', 'VIEWER')")
    @GetMapping("/articles/{id}")
    public ArticleDto get(@PathVariable Long id) {
        return articleService.get(id);
    }
}

Когда подходит: системы с фиксированным набором ролей. Когда не подходит: нужен контроль на уровне конкретных объектов (пользователь редактирует только свои статьи).

4.2 ABAC (Attribute-Based Access Control)

Решение о доступе принимается на основании атрибутов: пользователя, ресурса, действия и контекста (время, IP, устройство).

Лучше выносить логику в отдельный компонент:

@Component("access")
@RequiredArgsConstructor
public class AccessPolicy {

    private final ArticleRepository articleRepository;

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

    public boolean canDeleteArticle(Long articleId, UserPrincipal user) {
        Article article = articleRepository.findById(articleId).orElse(null);
        if (article == null) return false;

        return user.hasRole("ADMIN")
            || article.getAuthorId().equals(user.getId());
    }
}
@PreAuthorize("@access.canEditArticle(#id, authentication.principal)")
@PutMapping("/articles/{id}")
public ArticleDto update(@PathVariable Long id, @RequestBody UpdateRequest body) { ... }

@PreAuthorize("@access.canDeleteArticle(#id, authentication.principal)")
@DeleteMapping("/articles/{id}")
public void delete(@PathVariable Long id) { ... }

4.3 Resource-Based Authorization (владелец ресурса)

Частный случай ABAC — пользователь может изменять только свои ресурсы.

@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderRepository orderRepository;

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

Часть 5. Где проверять авторизацию в микросервисах

В микросервисной архитектуре запрос проходит через несколько слоёв. На каждом проверяются разные аспекты авторизации.

diagram

Принцип: Gateway проверяет «кто», сервис проверяет «что можно»

API Gateway:

  • Валидация токена (подпись, срок действия)
  • Базовая проверка: есть ли вообще токен
  • Rate limiting
  • Пробрасывание identity (X-User-Id, X-User-Roles) в downstream-сервисы

BFF / Application Layer:

  • RBAC — hasRole('ADMIN') — грубая проверка по роли
  • Фильтрация эндпоинтов по ролям

Доменный сервис:

  • Проверка владельца ресурса (order.userId == currentUserId)
  • Бизнес-правила (article.status == DRAFT && user.department == article.department)
  • Всё, что зависит от данных конкретного объекта

Авторизацию на уровне конкретного объекта нельзя вынести на Gateway — он не знает доменную модель.

Что выбрать

Для веб-приложения (SPA)

diagram

Для мобильного приложения

diagram

Модель авторизации

diagram

Ссылки