Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс. Статья описывает подходы к аутентификации и авторизации для двух типов клиентов — веб-приложений (SPA) и мобильных приложений. Для каждого подхода объясняется, почему он подходит или не подходит конкретному типу клиента, с примерами конфигурации и кода.
Это обучающий гайд. Если ищешь свод правил для ревью кода с кодами
AUTH-N— открой Auth Patterns Style Guide, на него опирается скиллucp-auth-review.
Основные понятия
Аутентификация 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 день
Конфигурация серверной стороны: настраивается поставщик сессий (Redis или совместимое хранилище) и сериализатор cookie с флагами HttpOnly, Secure, SameSite=Lax.
@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;
}
}
// go.sum: github.com/gorilla/sessions, github.com/rbcervilla/redisstore/v9
var store *redisstore.RedisStore
func initSessionStore(rdb *redis.Client) {
var err error
store, err = redisstore.NewRedisStore(context.Background(), rdb)
if err != nil {
log.Fatal(err)
}
store.Options(sessions.Options{
Path: "/",
MaxAge: 86400,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "SESSION")
session.Values["userId"] = 42
session.Values["roles"] = []string{"ADMIN"}
session.Save(r, w)
w.WriteHeader(http.StatusOK)
}
// npm: express-session, connect-redis, ioredis
import session from "express-session";
import RedisStore from "connect-redis";
import { Redis } from "ioredis";
const redisClient = new Redis(process.env.REDIS_URL);
app.use(
session({
store: new RedisStore({ client: redisClient }),
name: "SESSION",
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 86_400_000,
path: "/",
},
})
);
app.post("/login", (req, res) => {
req.session.userId = 42;
req.session.roles = ["ADMIN"];
res.sendStatus(200);
});
# pip: fastapi, redis, itsdangerous (через starlette SessionMiddleware)
from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import Request
import redis.asyncio as aioredis
# SessionMiddleware хранит сессию в подписанной cookie;
# для Redis-бэкенда используют starsessions или подобное.
app.add_middleware(
SessionMiddleware,
secret_key=settings.SESSION_SECRET,
session_cookie="SESSION",
max_age=86_400,
https_only=True,
same_site="lax",
)
@app.post("/login")
async def login(request: Request):
request.session["userId"] = 42
request.session["roles"] = ["ADMIN"]
return {"ok": True}
Достоинства:
- Токены не хранятся в 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 — при обмене кода на токен.
Конфигурация IdP — пример для Spring Boot (другие фреймворки используют .env / config.yaml с аналогичными параметрами: client-id, client-secret, scope, issuer-uri):
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
Подключение PKCE и обработка redirect после успешного входа:
@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;
}
}
// go.sum: golang.org/x/oauth2, github.com/gorilla/sessions
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"golang.org/x/oauth2"
)
var oauthConfig = &oauth2.Config{
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("CLIENT_SECRET"),
Scopes: []string{"openid", "profile", "email", "offline_access"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/auth",
TokenURL: "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token",
},
RedirectURL: "https://app.example.com/callback",
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
verifier := generateVerifier()
challenge := s256Challenge(verifier)
session, _ := store.Get(r, "SESSION")
session.Values["pkce_verifier"] = verifier
session.Save(r, w)
url := oauthConfig.AuthCodeURL("state",
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
http.Redirect(w, r, url, http.StatusFound)
}
func callbackHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "SESSION")
verifier := session.Values["pkce_verifier"].(string)
token, _ := oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"),
oauth2.SetAuthURLParam("code_verifier", verifier))
session.Values["access_token"] = token.AccessToken
session.Values["refresh_token"] = token.RefreshToken
session.Save(r, w)
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
func generateVerifier() string {
b := make([]byte, 32)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func s256Challenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
// npm: openid-client@6, express-session
import {
discovery,
buildAuthorizationUrl,
authorizationCodeGrant,
randomPKCECodeVerifier,
calculatePKCECodeChallenge,
randomState,
type Configuration,
} from "openid-client";
import { Router } from "express";
const router = Router();
let oidcConfig: Configuration;
async function initOidcClient() {
oidcConfig = await discovery(
new URL("https://keycloak.example.com/realms/my-realm"),
process.env.CLIENT_ID!,
process.env.CLIENT_SECRET!
);
}
router.get("/oauth2/login", async (req, res) => {
const codeVerifier = randomPKCECodeVerifier();
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
const state = randomState();
req.session.codeVerifier = codeVerifier;
req.session.state = state;
const url = buildAuthorizationUrl(oidcConfig, {
redirect_uri: "https://app.example.com/callback",
scope: "openid profile email offline_access",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
});
res.redirect(url.href);
});
router.get("/callback", async (req, res) => {
const tokens = await authorizationCodeGrant(
oidcConfig,
new URL(`https://app.example.com${req.url}`),
{ pkceCodeVerifier: req.session.codeVerifier, expectedState: req.session.state }
);
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
res.redirect("/dashboard");
});
# pip: authlib, httpx, fastapi, itsdangerous
from authlib.integrations.starlette_client import OAuth
from starlette.requests import Request
from starlette.responses import RedirectResponse
oauth = OAuth()
oauth.register(
name="keycloak",
client_id=settings.CLIENT_ID,
client_secret=settings.CLIENT_SECRET,
server_metadata_url=(
"https://keycloak.example.com/realms/my-realm"
"/.well-known/openid-configuration"
),
client_kwargs={"scope": "openid profile email offline_access"},
)
@app.get("/oauth2/login")
async def login(request: Request):
redirect_uri = request.url_for("callback")
return await oauth.keycloak.authorize_redirect(
request, redirect_uri, code_challenge_method="S256"
)
@app.get("/callback")
async def callback(request: Request):
token = await oauth.keycloak.authorize_access_token(request)
request.session["access_token"] = token["access_token"]
request.session["refresh_token"] = token.get("refresh_token")
return RedirectResponse("/dashboard")
Почему именно этот подход для SPA:
- Токены никогда не попадают в браузер — защита от XSS
- PKCE защищает от перехвата authorization code
- Refresh token хранится на сервере — можно обновлять прозрачно
- IdP (Keycloak, Okta) берёт на себя логин/пароль, MFA, SSO
1.3 Token-Based Authentication (JWT в cookie)
Альтернатива: JWT хранится в HttpOnly cookie вместо серверной сессии. Сервер не хранит состояние — JWT содержит всю информацию о пользователе.
Middleware/filter извлекает JWT из cookie, проверяет подпись локально и устанавливает контекст текущего пользователя:
@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);
}
}
// go.sum: github.com/golang-jwt/jwt/v5
import (
"net/http"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Sub string `json:"sub"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
func JwtCookieMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("TOKEN")
if err != nil {
next.ServeHTTP(w, r)
return
}
claims := &Claims{}
_, err = jwt.ParseWithClaims(cookie.Value, claims, func(t *jwt.Token) (any, error) {
return jwtPublicKey, nil
})
if err != nil {
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), principalKey{}, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// npm: jsonwebtoken, @types/jsonwebtoken
import { RequestHandler } from "express";
import jwt from "jsonwebtoken";
interface JwtPayload {
sub: string;
roles: string[];
}
export const jwtCookieMiddleware: RequestHandler = (req, res, next) => {
const token: string | undefined = req.cookies?.TOKEN;
if (!token) return next();
try {
const payload = jwt.verify(token, process.env.JWT_PUBLIC_KEY!, {
algorithms: ["RS256"],
}) as JwtPayload;
(req as any).principal = { userId: payload.sub, roles: payload.roles };
} catch {
// невалидный токен — продолжаем без авторизации
}
next();
};
# pip: python-jose[cryptography], fastapi
from jose import jwt, JWTError
from fastapi import Request
from dataclasses import dataclass
@dataclass
class Principal:
user_id: str
roles: list[str]
async def jwt_cookie_middleware(request: Request, call_next):
token = request.cookies.get("TOKEN")
if token:
try:
payload = jwt.decode(
token,
settings.JWT_PUBLIC_KEY,
algorithms=["RS256"],
)
request.state.principal = Principal(
user_id=payload["sub"],
roles=payload.get("roles", []),
)
except JWTError:
pass
return await call_next(request)
Достоинства: 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-серверу для проверки токена.
Провайдер аутентификации вызывает auth-сервер (introspection endpoint), получает UserInfo и строит principal для текущего запроса:
@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());
}
}
// go.sum: net/http (stdlib)
type UserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
type UserPrincipal struct {
UserID string
Email string
Name string
}
func AuthenticateOpaqueToken(ctx context.Context, token string, authServerURL string) (*UserPrincipal, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, authServerURL+"/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("invalid token")
}
var info UserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, err
}
if info.Sub == "" {
return nil, errors.New("missing sub claim")
}
return &UserPrincipal{UserID: info.Sub, Email: info.Email, Name: info.Name}, nil
}
// npm: node-fetch или встроенный fetch (Node 18+)
interface UserInfo {
sub: string;
email: string;
name: string;
}
interface UserPrincipal {
userId: string;
email: string;
name: string;
}
async function authenticateOpaqueToken(
token: string,
authServerUrl: string
): Promise<UserPrincipal> {
const res = await fetch(`${authServerUrl}/userinfo`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Invalid token");
const info: UserInfo = await res.json();
if (!info.sub) throw new Error("Missing sub claim");
return { userId: info.sub, email: info.email, name: info.name };
}
// Middleware
export const opaqueTokenMiddleware: RequestHandler = async (req, res, next) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.sendStatus(401);
try {
(req as any).principal = await authenticateOpaqueToken(
token,
process.env.AUTH_SERVER_URL!
);
next();
} catch {
res.sendStatus(401);
}
};
# pip: httpx, fastapi
import httpx
from dataclasses import dataclass
from fastapi import Request, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@dataclass
class UserPrincipal:
user_id: str
email: str
name: str
bearer = HTTPBearer()
async def authenticate_opaque_token(
credentials: HTTPAuthorizationCredentials = Depends(bearer),
) -> UserPrincipal:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{settings.AUTH_SERVER_URL}/userinfo",
headers={"Authorization": f"Bearer {credentials.credentials}"},
)
if resp.status_code != 200:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
info = resp.json()
if not info.get("sub"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return UserPrincipal(
user_id=info["sub"], email=info["email"], name=info["name"]
)
Достоинства: мгновенная инвалидация — auth-сервер может отозвать токен, и следующий запрос вернёт 401.
Недостатки: каждый запрос = дополнительный вызов к auth-серверу (задержка), auth-сервер становится единой точкой отказа.
2.3 JWT Bearer Token (самый распространённый)
Приложение получает JWT и передаёт его в каждом запросе. Backend проверяет подпись JWT локально по JWKS-ключам IdP.
Пример конфигурации для Spring Boot (другие фреймворки принимают те же параметры через .env / config.yaml):
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/my-realm
Настройка resource server в каждом языке:
@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;
}
}
// go.sum: github.com/golang-jwt/jwt/v5, net/http (stdlib для JWKS)
import (
"github.com/golang-jwt/jwt/v5"
"github.com/MicahParks/keyfunc/v3"
)
var jwks *keyfunc.JWKS
func initJwks(jwksURL string) {
var err error
jwks, err = keyfunc.NewDefault([]string{jwksURL})
if err != nil {
log.Fatal(err)
}
}
type Claims struct {
Sub string `json:"sub"`
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
jwt.RegisteredClaims
}
func JwtBearerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
claims := &Claims{}
_, err := jwt.ParseWithClaims(tokenStr, claims, jwks.Keyfunc)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), principalKey{}, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// npm: jose
import { createRemoteJWKSet, jwtVerify } from "jose";
import { RequestHandler } from "express";
const JWKS = createRemoteJWKSet(
new URL(
"https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs"
)
);
export const jwtBearerMiddleware: RequestHandler = async (req, res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) return res.sendStatus(401);
try {
const { payload } = await jwtVerify(header.slice(7), JWKS, {
issuer: "https://keycloak.example.com/realms/my-realm",
});
(req as any).principal = {
userId: payload.sub,
roles: (payload as any).realm_access?.roles ?? [],
};
next();
} catch {
res.sendStatus(401);
}
};
# pip: PyJWT[crypto], fastapi
import jwt
from jwt import PyJWKClient, PyJWTError
from functools import lru_cache
JWKS_URL = (
"https://keycloak.example.com/realms/my-realm"
"/protocol/openid-connect/certs"
)
@lru_cache
def jwks_client() -> PyJWKClient:
return PyJWKClient(JWKS_URL)
async def jwt_bearer(request: Request) -> UserPrincipal:
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
raise HTTPException(status_code=401)
token = header[7:]
try:
signing_key = jwks_client().get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="account",
)
except PyJWTError:
raise HTTPException(status_code=401)
roles = payload.get("realm_access", {}).get("roles", [])
return UserPrincipal(
user_id=payload["sub"],
email=payload.get("email", ""),
roles=roles,
)
Обновление токена на мобильном клиенте (пример на Kotlin — платформо-специфичный код):
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);
}
}
func RequireRole(roles ...string) func(http.Handler) http.Handler {
set := make(map[string]struct{}, len(roles))
for _, r := range roles {
set[r] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := r.Context().Value(principalKey{}).(*Claims)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
for _, role := range principal.RealmAccess.Roles {
if _, found := set[role]; found {
next.ServeHTTP(w, r)
return
}
}
http.Error(w, "Forbidden", http.StatusForbidden)
})
}
}
// Подключение:
// router.With(RequireRole("ADMIN")).Delete("/articles/{id}", deleteHandler)
// router.With(RequireRole("ADMIN", "EDITOR")).Put("/articles/{id}", updateHandler)
// router.With(RequireRole("ADMIN", "EDITOR", "VIEWER")).Get("/articles/{id}", getHandler)
import { RequestHandler } from "express";
function requireRole(...roles: string[]): RequestHandler {
const allowed = new Set(roles);
return (req, res, next) => {
const principal = (req as any).principal;
if (!principal) return res.sendStatus(401);
const hasRole = (principal.roles as string[]).some((r) => allowed.has(r));
if (!hasRole) return res.sendStatus(403);
next();
};
}
// router.delete("/articles/:id", requireRole("ADMIN"), deleteHandler);
// router.put("/articles/:id", requireRole("ADMIN", "EDITOR"), updateHandler);
// router.get("/articles/:id", requireRole("ADMIN", "EDITOR", "VIEWER"), getHandler);
from fastapi import Depends, HTTPException, status
from functools import wraps
def require_role(*roles: str):
allowed = set(roles)
def dependency(principal: UserPrincipal = Depends(jwt_bearer)) -> UserPrincipal:
if not allowed.intersection(principal.roles):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return principal
return dependency
# Подключение:
# @app.delete("/articles/{article_id}")
# async def delete_article(
# article_id: int,
# principal: UserPrincipal = Depends(require_role("ADMIN")),
# ): ...
Когда подходит: системы с фиксированным набором ролей. Когда не подходит: нужен контроль на уровне конкретных объектов (пользователь редактирует только свои статьи).
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) { ... }
type ArticlePolicy struct {
repo ArticleRepository
}
func (p *ArticlePolicy) CanEdit(ctx context.Context, articleID int64, user *Claims) (bool, error) {
article, err := p.repo.FindByID(ctx, articleID)
if err != nil {
return false, err
}
hasRole := slices.Contains(user.RealmAccess.Roles, "EDITOR")
return hasRole &&
article.Department == user.Department &&
article.Status == StatusDraft, nil
}
func (p *ArticlePolicy) CanDelete(ctx context.Context, articleID int64, user *Claims) (bool, error) {
article, err := p.repo.FindByID(ctx, articleID)
if err != nil {
return false, err
}
isAdmin := slices.Contains(user.RealmAccess.Roles, "ADMIN")
return isAdmin || article.AuthorID == user.Sub, nil
}
func (h *ArticleHandler) Update(w http.ResponseWriter, r *http.Request) {
id := pathParamInt64(r, "id")
user := principalFromCtx(r.Context())
ok, err := h.policy.CanEdit(r.Context(), id, user)
if err != nil || !ok {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// ... бизнес-логика
}
import { ArticleRepository } from "../repository/article.repository";
class ArticlePolicy {
constructor(private readonly repo: ArticleRepository) {}
async canEdit(articleId: number, user: Principal): Promise<boolean> {
const article = await this.repo.findById(articleId);
if (!article) return false;
return (
user.roles.includes("EDITOR") &&
article.department === user.department &&
article.status === "DRAFT"
);
}
async canDelete(articleId: number, user: Principal): Promise<boolean> {
const article = await this.repo.findById(articleId);
if (!article) return false;
return user.roles.includes("ADMIN") || article.authorId === user.userId;
}
}
// В обработчике:
// const ok = await policy.canEdit(+req.params.id, req.principal);
// if (!ok) return res.sendStatus(403);
from dataclasses import dataclass
@dataclass
class ArticlePolicy:
repo: ArticleRepository
async def can_edit(self, article_id: int, user: UserPrincipal) -> bool:
article = await self.repo.find_by_id(article_id)
if not article:
return False
return (
"EDITOR" in user.roles
and article.department == user.department
and article.status == "DRAFT"
)
async def can_delete(self, article_id: int, user: UserPrincipal) -> bool:
article = await self.repo.find_by_id(article_id)
if not article:
return False
return "ADMIN" in user.roles or article.author_id == user.user_id
# В обработчике:
# if not await policy.can_edit(article_id, principal):
# raise HTTPException(status_code=403)
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);
}
}
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
orderID := pathParamInt64(r, "id")
user := principalFromCtx(r.Context())
order, err := h.repo.FindByID(r.Context(), orderID)
if errors.Is(err, ErrNotFound) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if order.CustomerID != user.Sub {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
writeJSON(w, toOrderDTO(order))
}
app.get("/orders/:id", jwtBearerMiddleware, async (req, res) => {
const order = await orderRepository.findById(Number(req.params.id));
if (!order) return res.sendStatus(404);
const principal: Principal = (req as any).principal;
if (order.customerId !== principal.userId) return res.sendStatus(403);
res.json(toOrderDTO(order));
});
@app.get("/orders/{order_id}")
async def get_order(
order_id: int,
principal: UserPrincipal = Depends(jwt_bearer),
repo: OrderRepository = Depends(get_order_repo),
) -> OrderDTO:
order = await repo.find_by_id(order_id)
if order is None:
raise HTTPException(status_code=404)
if order.customer_id != principal.user_id:
raise HTTPException(status_code=403)
return to_order_dto(order)
Часть 5. Где проверять авторизацию в микросервисах
В микросервисной архитектуре запрос проходит через несколько слоёв. На каждом проверяются разные аспекты авторизации.
Принцип: Gateway проверяет «кто», сервис проверяет «что можно»
API Gateway:
- Валидация токена (подпись, срок действия)
- Базовая проверка: есть ли вообще токен
- Rate limiting
- Пробрасывание identity (
X-User-Id,X-User-Roles) в downstream-сервисы
BFF / Application Layer:
- RBAC — грубая проверка по роли
- Фильтрация эндпоинтов по ролям
Доменный сервис:
- Проверка владельца ресурса (
order.customerId == currentUserId) - Бизнес-правила (
article.status == DRAFT && user.department == article.department) - Всё, что зависит от данных конкретного объекта
Авторизацию на уровне конкретного объекта нельзя вынести на Gateway — он не знает доменную модель.
Что выбрать
Для веб-приложения (SPA)
Для мобильного приложения
Модель авторизации
Ссылки
- Структурные паттерны микросервисов — API Gateway, BFF, Service Mesh.
- REST API: заголовки и трассировка — Authorization, Idempotency-Key.
- Кейс: маркетплейс — продуктовый контекст применения.