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

Это обучающий гайд. Если ищешь свод правил для ревью кода с кодами AUTH-N — открой Auth Patterns Style Guide, на него опирается скилл ucp-auth-review.

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

Аутентификация 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 день

Конфигурация серверной стороны: настраивается поставщик сессий (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 — 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

Конфигурация 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

Альтернатива: 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 для веба — только в 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-серверу для проверки токена.

Провайдер аутентификации вызывает 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

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

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);
    }
}
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. Где проверять авторизацию в микросервисах

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

diagram

Принцип: 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)

diagram

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

diagram

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

diagram

Ссылки