Паттерны авторизации
Аутентификация и авторизация для SPA, мобильных приложений и микросервисов: OAuth2, JWT, RBAC, ABAC. Со схемами и Spring Boot примерами.
Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс. Статья описывает подходы к аутентификации и авторизации для двух типов клиентов — веб-приложений (SPA) и мобильных приложений. Для каждого подхода объясняется, почему он подходит или не подходит конкретному типу клиента, с примерами конфигурации и кода на Spring Boot.
Основные понятия
Аутентификация vs Авторизация
- Аутентификация (AuthN) — ответ на вопрос «кто ты?». Пользователь доказывает свою личность: вводит логин/пароль, предъявляет токен, сканирует отпечаток пальца.
- Авторизация (AuthZ) — ответ на вопрос «что тебе можно?». Система проверяет, имеет ли аутентифицированный пользователь доступ к запрошенному ресурсу.
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)
1.1 Session-Based Authentication (cookie + серверная сессия)
Классический подход. Сервер создаёт сессию, хранит её в памяти или Redis, а клиенту отдаёт только идентификатор сессии в HttpOnly cookie.
Безопасность 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 —
HttpOnlycookie недоступна из 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 — при обмене кода на токен.
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
1.3 Token-Based Authentication (JWT в cookie)
Альтернатива: 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 для веба — только вHttpOnlycookie.
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 cookie | Stateless, но сложнее инвалидация и 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
Продвинутый паттерн для мобильных приложений с высокими требованиями к безопасности (банки, платежи). Токен привязан к конкретному устройству, доступ к нему защищён биометрией.
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, что служит сигналом компрометации.
Часть 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. Где проверять авторизацию в микросервисах
В микросервисной архитектуре запрос проходит через несколько слоёв. На каждом проверяются разные аспекты авторизации.
Принцип: 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)
Для мобильного приложения
Модель авторизации
Ссылки
- Структурные паттерны микросервисов — API Gateway, BFF, Service Mesh.
- REST API: заголовки и трассировка — Authorization, Idempotency-Key.
- Кейс: маркетплейс — продуктовый контекст применения.