MidПрактика26 min

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

OAuth 2.0, OIDC, JWT, session-based auth, SSO и PHP-реализация JWT-аутентификации

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

Аутентификация (AuthN) Авторизация (AuthZ)
Вопрос «Кто ты?» «Что тебе можно?»
Когда Первый шаг После аутентификации
Пример Логин/пароль, OAuth RBAC, ACL, policies
Результат Идентичность Разрешение/запрет

Методы аутентификации

Метод Stateful/Stateless Подходит для
Session-based Stateful Монолитные веб-приложения
JWT (token-based) Stateless API, микросервисы
OAuth 2.0 Зависит Third-party авторизация
API Keys Stateless Server-to-server
mTLS Stateless Внутренние сервисы

Session-based Authentication

Классический подход: сервер хранит состояние сессии, клиент получает session ID в cookie.

1. User sends credentials → Server
2. Server validates → Creates session in storage
3. Server returns Set-Cookie: SESSION_ID=abc123
4. Browser sends Cookie: SESSION_ID=abc123 with every request
5. Server looks up session → identifies user
<?php

declare(strict_types=1);

namespace App\Auth;

final class SessionAuthenticator
{
    /**
     * Hash of a value nobody can supply, built once per process.
     * @see self::login() for why it exists.
     */
    private static ?string $dummyHash = null;

    public function __construct(
        private readonly UserRepository $users,
        private readonly \Redis $redis,
        private readonly int $sessionTtl = 3600,
    ) {}

    public function login(string $email, string $password): ?string
    {
        $user = $this->users->findByEmail($email);

        // Always run a password verification, even when the email is unknown.
        // Returning early would make "no such user" measurably faster than
        // "wrong password", and that timing difference tells an attacker which
        // e-mails are registered (user enumeration).
        $passwordValid = password_verify(
            $password,
            $user?->getPasswordHash() ?? self::dummyHash(),
        );

        if ($user === null || !$passwordValid) {
            return null;
        }

        // Regenerate session to prevent fixation
        session_regenerate_id(true);

        $sessionId = session_id();

        // Store session data in Redis
        $this->redis->setex(
            "session:{$sessionId}",
            $this->sessionTtl,
            json_encode([
                'user_id' => $user->getId(),
                'roles' => $user->getRoles(),
                'created_at' => time(),
            ]),
        );

        return $sessionId;
    }

    public function getCurrentUser(): ?AuthenticatedUser
    {
        $sessionId = session_id();
        $data = $this->redis->get("session:{$sessionId}");

        if ($data === false) {
            return null;
        }

        $session = json_decode($data, true);

        return new AuthenticatedUser(
            id: $session['user_id'],
            roles: $session['roles'],
        );
    }

    public function logout(): void
    {
        $sessionId = session_id();
        $this->redis->del("session:{$sessionId}");
        session_destroy();
    }

    /**
     * A hash of an unguessable random value, used as the comparison target
     * when the e-mail does not exist. It costs the same as a real verification,
     * which is exactly the point.
     */
    private static function dummyHash(): string
    {
        return self::$dummyHash ??= password_hash(bin2hex(random_bytes(32)), PASSWORD_DEFAULT);
    }
}
## JWT Authentication

JWT (JSON Web Token) — самодостаточный токен, содержащий claims о пользователе. Сервер не хранит состояние — валидность проверяется подписью.

Структура JWT

Header.Payload.Signature

Header:  {"alg":"HS256","typ":"JWT"}
Payload: {"sub":"user123","role":"admin","exp":1700000000}
Signature: HMAC-SHA256(base64(header).base64(payload), secret)

Реализация JWT

<?php

declare(strict_types=1);

namespace App\Auth\Jwt;

final readonly class JwtManager
{
    /** The one algorithm this manager issues and accepts. */
    private const string ALGORITHM = 'HS256';

    /** Tolerance for clock drift between issuer and verifier. */
    private const int LEEWAY_SECONDS = 30;

    public function __construct(
        private string $secretKey,
        private string $issuer,
        private string $audience,
        private int $accessTokenTtl = 900,     // 15 minutes
        private int $refreshTokenTtl = 604800, // 7 days
    ) {}

    /**
     * Generate an access token for a user.
     *
     * @param array<string> $roles
     */
    public function generateAccessToken(string $userId, array $roles = []): string
    {
        return $this->encode($userId, 'access', $this->accessTokenTtl, $roles);
    }

    /**
     * Generate a refresh token.
     */
    public function generateRefreshToken(string $userId): string
    {
        return $this->encode($userId, 'refresh', $this->refreshTokenTtl, []);
    }

    /**
     * Validate and decode a JWT token.
     *
     * @return array<string, mixed> Decoded payload
     * @throws InvalidTokenException
     */
    public function validate(string $token): array
    {
        $parts = explode('.', $token);

        if (count($parts) !== 3) {
            throw new InvalidTokenException('Invalid token format');
        }

        [$headerB64, $payloadB64, $signatureB64] = $parts;

        $header = $this->decodeSegment($headerB64);

        // Pin the algorithm before touching the signature. Trusting the token's
        // own "alg" is what enables "alg: none" and HS/RS confusion attacks:
        // the attacker picks the algorithm, so the attacker picks the key.
        if (($header['alg'] ?? null) !== self::ALGORITHM) {
            throw new InvalidTokenException('Unexpected signing algorithm');
        }

        // Compare in constant time — a byte-by-byte compare leaks the signature
        // one byte at a time to an attacker who can measure the response.
        $expectedSignature = $this->sign("{$headerB64}.{$payloadB64}");

        if (!hash_equals($expectedSignature, $this->base64UrlDecode($signatureB64))) {
            throw new InvalidTokenException('Invalid signature');
        }

        $payload = $this->decodeSegment($payloadB64);

        // Registered claims are required, not optional: a token without "exp"
        // would otherwise be valid forever.
        foreach (['sub', 'iat', 'exp', 'iss', 'aud', 'type'] as $claim) {
            if (!isset($payload[$claim])) {
                throw new InvalidTokenException('Invalid token claims');
            }
        }

        $now = time();

        if ((int) $payload['exp'] < $now - self::LEEWAY_SECONDS) {
            throw new InvalidTokenException('Token has expired');
        }

        if ((int) $payload['iat'] > $now + self::LEEWAY_SECONDS) {
            throw new InvalidTokenException('Token is not yet valid');
        }

        // Without issuer and audience checks a token minted by (or for) another
        // service with the same secret would be accepted here.
        if (!hash_equals($this->issuer, (string) $payload['iss'])
            || !hash_equals($this->audience, (string) $payload['aud'])
        ) {
            throw new InvalidTokenException('Invalid token claims');
        }

        return $payload;
    }

    /**
     * @param array<string> $roles
     */
    private function encode(string $userId, string $type, int $ttl, array $roles): string
    {
        $now = time();

        $payload = [
            'sub' => $userId,
            'roles' => $roles,
            'iat' => $now,
            'nbf' => $now,
            'exp' => $now + $ttl,
            'type' => $type,
            'iss' => $this->issuer,
            'aud' => $this->audience,
            // Unique token ID, so individual tokens can be revoked.
            'jti' => bin2hex(random_bytes(16)),
        ];

        $header = $this->base64UrlEncode(json_encode([
            'alg' => self::ALGORITHM,
            'typ' => 'JWT',
        ], JSON_THROW_ON_ERROR));

        $payloadEncoded = $this->base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR));
        $signature = $this->base64UrlEncode($this->sign("{$header}.{$payloadEncoded}"));

        return "{$header}.{$payloadEncoded}.{$signature}";
    }

    /**
     * @return array<string, mixed>
     * @throws InvalidTokenException
     */
    private function decodeSegment(string $segment): array
    {
        $decoded = json_decode($this->base64UrlDecode($segment), true);

        if (!is_array($decoded)) {
            throw new InvalidTokenException('Invalid token format');
        }

        return $decoded;
    }

    private function sign(string $data): string
    {
        return hash_hmac('sha256', $data, $this->secretKey, true);
    }

    private function base64UrlEncode(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }

    private function base64UrlDecode(string $data): string
    {
        // Strict mode: silently ignoring invalid characters would let two
        // different strings decode to the same bytes.
        return base64_decode(strtr($data, '-_', '+/'), true) ?: '';
    }
}
### JWT Middleware
<?php

declare(strict_types=1);

namespace App\Auth\Jwt;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;

final readonly class JwtAuthMiddleware
{
    private const EXCLUDED_PATHS = ['/api/auth/login', '/api/auth/register', '/api/health'];

    public function __construct(
        private JwtManager $jwt,
    ) {}

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        if (!$event->isMainRequest() || $this->isExcluded($request)) {
            return;
        }

        $token = $this->extractToken($request);

        if ($token === null) {
            $event->setResponse(new JsonResponse(
                ['error' => 'Authentication required'],
                401,
            ));
            return;
        }

        try {
            $payload = $this->jwt->validate($token);

            // A refresh token must never grant access to protected endpoints.
            if ($payload['type'] !== 'access') {
                throw new InvalidTokenException('Expected access token');
            }

            // Attach user info to request for downstream use
            $request->attributes->set('auth_user_id', $payload['sub']);
            $request->attributes->set('auth_roles', $payload['roles'] ?? []);
        } catch (InvalidTokenException) {
            // The reason is deliberately not echoed back to the caller:
            // "bad signature" vs "expired" tells an attacker how far they got.
            $event->setResponse(new JsonResponse(
                ['error' => 'Invalid or expired token'],
                401,
            ));
        }
    }

    private function extractToken(Request $request): ?string
    {
        $header = $request->headers->get('Authorization', '');

        if (str_starts_with($header, 'Bearer ')) {
            return substr($header, 7);
        }

        return null;
    }

    private function isExcluded(Request $request): bool
    {
        return in_array($request->getPathInfo(), self::EXCLUDED_PATHS, true);
    }
}
## OAuth 2.0

OAuth 2.0 — протокол авторизации, позволяющий приложению получить ограниченный доступ к ресурсам пользователя без передачи пароля.

Роли OAuth 2.0

Роль Описание
Resource Owner Пользователь, владелец данных
Client Приложение, запрашивающее доступ
Authorization Server Выдаёт токены (Google, GitHub)
Resource Server API, хранящий защищённые данные

Authorization Code Flow (самый безопасный)

1. Client → redirect → Auth Server (/authorize?response_type=code&client_id=...)
2. User logs in on Auth Server
3. Auth Server → redirect → Client (/callback?code=xyz)
4. Client → POST → Auth Server (/token, code=xyz, client_secret=...)
5. Auth Server → returns access_token + refresh_token
6. Client → GET → Resource Server (Authorization: Bearer <token>)

OIDC (OpenID Connect)

OIDC — это надстройка над OAuth 2.0, добавляющая аутентификацию. OAuth 2.0 — только авторизация, OIDC — ещё и идентичность.

OAuth 2.0 OIDC
Access Token Access Token + ID Token
Scopes: read, write Scopes: openid, profile, email
Авторизация Аутентификация + авторизация

Token Refresh Flow

<?php

declare(strict_types=1);

namespace App\Auth;

final class TokenReuseDetectedException extends \RuntimeException
{
}

final readonly class TokenRefreshService
{
    private const int REFRESH_TOKEN_TTL = 604800; // 7 days

    public function __construct(
        private Jwt\JwtManager $jwt,
        private UserRepository $users,
        private \Redis $redis,
    ) {}

    /**
     * Refresh access token using a refresh token.
     * Implements refresh token rotation for security.
     */
    public function refresh(string $refreshToken): TokenPair
    {
        $payload = $this->jwt->validate($refreshToken);

        if ($payload['type'] !== 'refresh') {
            throw new \InvalidArgumentException('Expected refresh token');
        }

        $jti = $payload['jti'];
        $userId = $payload['sub'];

        // SET NX marks the token used atomically and tells us whether we were
        // first. A get-then-set would leave a window in which two concurrent
        // requests both read "unused" and both get a fresh token pair.
        $firstUse = $this->redis->set(
            "used_refresh:{$jti}",
            '1',
            ['nx', 'ex' => self::REFRESH_TOKEN_TTL],
        );

        if ($firstUse === false) {
            // Replay means the token likely leaked: revoke everything for this user.
            $this->revokeAllTokens($userId);
            throw new TokenReuseDetectedException('Refresh token reuse detected');
        }

        // Issue new token pair
        $user = $this->users->findById($userId)
            ?? throw new \RuntimeException('User not found');

        return new TokenPair(
            accessToken: $this->jwt->generateAccessToken($user->getId(), $user->getRoles()),
            refreshToken: $this->jwt->generateRefreshToken($user->getId()),
        );
    }

    private function revokeAllTokens(string $userId): void
    {
        // TTL matches the refresh token lifetime: after that no token issued
        // before the revocation can still be presented.
        $this->redis->setex("user_revoked:{$userId}", self::REFRESH_TOKEN_TTL, (string) time());
    }
}

final readonly class TokenPair
{
    public function __construct(
        public string $accessToken,
        public string $refreshToken,
    ) {}
}
## Session vs JWT: когда что использовать
Критерий Session JWT
Stateful Да (сервер хранит) Нет (самодостаточный)
Масштабирование Нужен shared storage Легко масштабируется
Revocation Мгновенная (удалить из store) Сложная (blacklist/wait for expiry)
Размер Cookie ~32 bytes Token ~500+ bytes
CSRF Уязвим (нужна защита) Не уязвим (если не в cookie)
XSS Cookie: httpOnly защищает localStorage: уязвим
Подходит Браузерные приложения API, микросервисы

Рекомендация: Для веб-приложений с сервером — session + cookie. Для API и микросервисов — JWT. Для гибридных — JWT с коротким TTL + refresh token rotation.

Итоги

Концепция Суть
Session auth Stateful, сервер хранит состояние
JWT Stateless, самодостаточный токен
OAuth 2.0 Делегированная авторизация
OIDC OAuth 2.0 + аутентификация
Token rotation Новый refresh token при каждом refresh
Secure defaults httpOnly, Secure, SameSite cookies