Классический подход: сервер хранит состояние сессии, клиент получает 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);
}
}
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
// dummyHash is a bcrypt hash of an unguessable random value, built once per
// process. It is the comparison target when the e-mail does not exist, and it
// costs the same as a real verification — which is exactly the point.
var dummyHash = mustBuildDummyHash()
func mustBuildDummyHash() []byte {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("auth: cannot seed dummy password hash: " + err.Error())
}
h, err := bcrypt.GenerateFromPassword([]byte(hex.EncodeToString(b)), bcrypt.DefaultCost)
if err != nil {
panic("auth: cannot build dummy password hash: " + err.Error())
}
return h
}
// SessionAuthenticator handles session-based authentication.
type SessionAuthenticator struct {
users UserRepository
rdb *redis.Client
sessionTTL time.Duration
}
// Login validates credentials and creates a session.
func (a *SessionAuthenticator) Login(ctx context.Context, email, password string) (string, error) {
user, lookupErr := a.users.FindByEmail(ctx, email)
// Always run the bcrypt comparison, even when the e-mail 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).
hash := dummyHash
if lookupErr == nil && user != nil {
hash = []byte(user.PasswordHash)
}
compareErr := bcrypt.CompareHashAndPassword(hash, []byte(password))
if lookupErr != nil || user == nil || compareErr != nil {
return "", ErrInvalidCredentials
}
// A brand-new random identifier replaces any pre-existing one,
// which is what prevents session fixation.
sessionID, err := generateSessionID()
if err != nil {
return "", fmt.Errorf("generate session id: %w", err)
}
data, err := json.Marshal(map[string]any{
"user_id": user.ID,
"roles": user.Roles,
"created_at": time.Now().Unix(),
})
if err != nil {
return "", fmt.Errorf("encode session payload: %w", err)
}
if err := a.rdb.Set(ctx, "session:"+sessionID, data, a.sessionTTL).Err(); err != nil {
return "", fmt.Errorf("store session: %w", err)
}
return sessionID, nil
}
// GetCurrentUser retrieves the user from the session.
func (a *SessionAuthenticator) GetCurrentUser(ctx context.Context, sessionID string) (*AuthenticatedUser, error) {
data, err := a.rdb.Get(ctx, "session:"+sessionID).Bytes()
if err != nil {
return nil, ErrSessionNotFound
}
// A malformed payload is treated as no session at all: silently accepting
// it would hand the caller a zero-valued identity.
var session struct {
UserID string `json:"user_id"`
Roles []string `json:"roles"`
}
if err := json.Unmarshal(data, &session); err != nil || session.UserID == "" {
return nil, ErrSessionNotFound
}
return &AuthenticatedUser{
ID: session.UserID,
Roles: session.Roles,
}, nil
}
// Logout destroys the session.
func (a *SessionAuthenticator) Logout(ctx context.Context, sessionID string) error {
if err := a.rdb.Del(ctx, "session:"+sessionID).Err(); err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
func generateSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using Microsoft.AspNetCore.Identity;
using StackExchange.Redis;
namespace App.Auth;
public sealed record AuthenticatedUser(string Id, IReadOnlyList<string> Roles);
public sealed class SessionAuthenticator(
IUserRepository users,
IPasswordHasher<User> passwordHasher,
IConnectionMultiplexer redis)
{
private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1);
// A placeholder principal: PasswordHasher does not read the user, but the
// IPasswordHasher<T> interface requires one.
private static readonly User DummyUser = new();
private static string? _dummyPasswordHash;
/// <summary>
/// Hash of an unguessable random value, computed once per process.
/// It costs the same to verify as a real hash — which is exactly the point.
/// </summary>
private string DummyPasswordHash => LazyInitializer.EnsureInitialized(
ref _dummyPasswordHash,
() => passwordHasher.HashPassword(
DummyUser,
Convert.ToHexString(RandomNumberGenerator.GetBytes(32))));
public async Task<string?> LoginAsync(string email, string password)
{
User? user = await users.FindByEmailAsync(email);
// Always run a password verification, even when the e-mail 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).
PasswordVerificationResult verification = passwordHasher.VerifyHashedPassword(
user ?? DummyUser,
user?.PasswordHash ?? DummyPasswordHash,
password);
// The dummy hash can never match, but the unknown-user case is still
// rejected explicitly: the verification result is never allowed to
// decide the outcome on its own.
if (user is null || verification == PasswordVerificationResult.Failed)
{
return null;
}
// A brand-new random identifier replaces any pre-existing one,
// which is what prevents session fixation.
string sessionId = Convert.ToHexString(RandomNumberGenerator.GetBytes(32))
.ToLowerInvariant();
string payload = JsonSerializer.Serialize(new
{
user_id = user.Id,
roles = user.Roles,
created_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
});
await redis.GetDatabase().StringSetAsync($"session:{sessionId}", payload, SessionTtl);
return sessionId;
}
public async Task<AuthenticatedUser?> GetCurrentUserAsync(string sessionId)
{
RedisValue data = await redis.GetDatabase().StringGetAsync($"session:{sessionId}");
if (data.IsNullOrEmpty)
{
return null;
}
using JsonDocument document = JsonDocument.Parse(data!);
JsonElement root = document.RootElement;
return new AuthenticatedUser(
root.GetProperty("user_id").GetString()!,
root.GetProperty("roles").EnumerateArray().Select(r => r.GetString()!).ToList());
}
public Task LogoutAsync(string sessionId)
=> redis.GetDatabase().KeyDeleteAsync($"session:{sessionId}");
}
import json
import secrets
import time
from dataclasses import dataclass
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, VerifyMismatchError
from redis.asyncio import Redis
SESSION_TTL_SECONDS = 3600
_hasher = PasswordHasher(memory_cost=65536, time_cost=4, parallelism=3)
# Hash of an unguessable random value, computed once at import time.
# It costs the same to verify as a real hash — which is exactly the point,
# see SessionAuthenticator.login below.
_DUMMY_HASH = _hasher.hash(secrets.token_hex(32))
@dataclass(frozen=True, slots=True)
class AuthenticatedUser:
id: str
roles: list[str]
class SessionAuthenticator:
def __init__(self, users: UserRepository, redis: Redis) -> None:
self._users = users
self._redis = redis
async def login(self, email: str, password: str) -> str | None:
user = await self._users.find_by_email(email)
# Always run the argon2 verification, even when the e-mail 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).
try:
_hasher.verify(
user.password_hash if user is not None else _DUMMY_HASH,
password,
)
except (VerifyMismatchError, VerificationError):
return None
# The dummy hash can never match, but the unknown-user case is still
# rejected explicitly: a successful verification must never be able to
# decide the outcome on its own.
if user is None:
return None
# A brand-new random identifier replaces any pre-existing one,
# which is what prevents session fixation.
session_id = secrets.token_hex(32)
await self._redis.setex(
f"session:{session_id}",
SESSION_TTL_SECONDS,
json.dumps(
{
"user_id": user.id,
"roles": user.roles,
"created_at": int(time.time()),
}
),
)
return session_id
async def get_current_user(self, session_id: str) -> AuthenticatedUser | None:
data = await self._redis.get(f"session:{session_id}")
if data is None:
return None
session = json.loads(data)
return AuthenticatedUser(id=session["user_id"], roles=session["roles"])
async def logout(self, session_id: str) -> None:
await self._redis.delete(f"session:{session_id}")
## JWT Authentication
JWT (JSON Web Token) — самодостаточный токен, содержащий claims о пользователе. Сервер не хранит состояние — валидность проверяется подписью.
<?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) ?: '';
}
}
package jwt
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// algorithm is the one algorithm this manager issues and accepts.
const algorithm = "HS256"
// leeway tolerates clock drift between issuer and verifier.
const leeway = 30 * time.Second
// ErrInvalidToken wraps every validation failure, so callers cannot branch on
// the exact reason a token was rejected.
var ErrInvalidToken = errors.New("invalid token")
// Manager handles JWT token generation and validation.
type Manager struct {
secretKey []byte
issuer string
audience string
accessTokenTTL time.Duration
refreshTTL time.Duration
}
// NewManager creates a JWT manager.
func NewManager(secret, issuer, audience string) *Manager {
return &Manager{
secretKey: []byte(secret),
issuer: issuer,
audience: audience,
accessTokenTTL: 15 * time.Minute,
refreshTTL: 7 * 24 * time.Hour,
}
}
// GenerateAccessToken creates a signed access token.
func (m *Manager) GenerateAccessToken(userID string, roles []string) (string, error) {
return m.encode(userID, "access", m.accessTokenTTL, roles)
}
// GenerateRefreshToken creates a signed refresh token.
func (m *Manager) GenerateRefreshToken(userID string) (string, error) {
return m.encode(userID, "refresh", m.refreshTTL, nil)
}
// Validate verifies and decodes a JWT token.
func (m *Manager) Validate(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("%w: malformed", ErrInvalidToken)
}
// 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.
var header struct {
Alg string `json:"alg"`
}
if err := decodeSegment(parts[0], &header); err != nil || header.Alg != algorithm {
return nil, fmt.Errorf("%w: unexpected signing algorithm", ErrInvalidToken)
}
gotSig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("%w: malformed signature", ErrInvalidToken)
}
// hmac.Equal is constant time — a byte-by-byte compare leaks the signature
// one byte at a time to an attacker who can measure the response.
if !hmac.Equal(m.sign(parts[0]+"."+parts[1]), gotSig) {
return nil, fmt.Errorf("%w: bad signature", ErrInvalidToken)
}
var payload map[string]any
if err := decodeSegment(parts[1], &payload); err != nil {
return nil, fmt.Errorf("%w: malformed payload", ErrInvalidToken)
}
if err := m.checkClaims(payload); err != nil {
return nil, err
}
return payload, nil
}
// checkClaims enforces the registered claims. They are required, not optional:
// a token without "exp" would otherwise be valid forever, and one without
// "iss"/"aud" could have been minted by another service sharing the secret.
func (m *Manager) checkClaims(payload map[string]any) error {
now := time.Now()
exp, ok := payload["exp"].(float64)
if !ok {
return fmt.Errorf("%w: missing exp", ErrInvalidToken)
}
if time.Unix(int64(exp), 0).Before(now.Add(-leeway)) {
return fmt.Errorf("%w: expired", ErrInvalidToken)
}
iat, ok := payload["iat"].(float64)
if !ok {
return fmt.Errorf("%w: missing iat", ErrInvalidToken)
}
if time.Unix(int64(iat), 0).After(now.Add(leeway)) {
return fmt.Errorf("%w: not yet valid", ErrInvalidToken)
}
if sub, ok := payload["sub"].(string); !ok || sub == "" {
return fmt.Errorf("%w: missing sub", ErrInvalidToken)
}
if iss, ok := payload["iss"].(string); !ok || iss != m.issuer {
return fmt.Errorf("%w: wrong issuer", ErrInvalidToken)
}
if aud, ok := payload["aud"].(string); !ok || aud != m.audience {
return fmt.Errorf("%w: wrong audience", ErrInvalidToken)
}
if _, ok := payload["type"].(string); !ok {
return fmt.Errorf("%w: missing type", ErrInvalidToken)
}
return nil
}
func (m *Manager) encode(userID, tokenType string, ttl time.Duration, roles []string) (string, error) {
jti := make([]byte, 16)
if _, err := rand.Read(jti); err != nil {
return "", fmt.Errorf("generate jti: %w", err)
}
now := time.Now()
payload := map[string]any{
"sub": userID,
"roles": roles,
"iat": now.Unix(),
"nbf": now.Unix(),
"exp": now.Add(ttl).Unix(),
"type": tokenType,
"iss": m.issuer,
"aud": m.audience,
// Unique token ID, so individual tokens can be revoked.
"jti": hex.EncodeToString(jti),
}
header, err := json.Marshal(map[string]string{"alg": algorithm, "typ": "JWT"})
if err != nil {
return "", fmt.Errorf("encode header: %w", err)
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("encode payload: %w", err)
}
h := base64.RawURLEncoding.EncodeToString(header)
p := base64.RawURLEncoding.EncodeToString(body)
sig := base64.RawURLEncoding.EncodeToString(m.sign(h + "." + p))
return h + "." + p + "." + sig, nil
}
func (m *Manager) sign(data string) []byte {
mac := hmac.New(sha256.New, m.secretKey)
mac.Write([]byte(data))
return mac.Sum(nil)
}
func decodeSegment(segment string, target any) error {
raw, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
return err
}
return json.Unmarshal(raw, target)
}
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace App.Auth.Jwt;
public sealed class InvalidTokenException(string message) : Exception(message);
// Unlike the PHP/Go versions, .NET ships a vetted JWT implementation.
// Hand-rolling base64url and HMAC is unnecessary and easy to get wrong.
public sealed class JwtManager
{
private const string Algorithm = SecurityAlgorithms.HmacSha256;
private static readonly TimeSpan AccessTokenTtl = TimeSpan.FromMinutes(15);
private static readonly TimeSpan RefreshTokenTtl = TimeSpan.FromDays(7);
private readonly SigningCredentials _credentials;
private readonly TokenValidationParameters _validationParameters;
private readonly JsonWebTokenHandler _handler = new();
public JwtManager(string secretKey, string issuer, string audience)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
_credentials = new SigningCredentials(key, Algorithm);
_validationParameters = new TokenValidationParameters
{
IssuerSigningKey = key,
ValidIssuer = issuer,
ValidAudience = audience,
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
// Pinning the algorithm blocks "alg: none" and HS/RS confusion attacks.
ValidAlgorithms = [Algorithm],
ClockSkew = TimeSpan.FromSeconds(30),
};
}
public string GenerateAccessToken(string userId, IEnumerable<string> roles)
=> Generate(userId, "access", AccessTokenTtl, roles);
public string GenerateRefreshToken(string userId)
=> Generate(userId, "refresh", RefreshTokenTtl, roles: []);
// Validate a token and return its claims.
public async Task<ClaimsIdentity> ValidateAsync(string token)
{
TokenValidationResult result = await _handler.ValidateTokenAsync(token, _validationParameters);
if (!result.IsValid)
{
throw new InvalidTokenException(result.Exception?.Message ?? "Invalid token");
}
return result.ClaimsIdentity;
}
private string Generate(string userId, string type, TimeSpan ttl, IEnumerable<string> roles)
{
DateTime now = DateTime.UtcNow;
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId),
new("type", type),
// Unique token ID, so individual tokens can be revoked.
new(JwtRegisteredClaimNames.Jti, Convert.ToHexString(RandomNumberGenerator.GetBytes(16))),
};
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
IssuedAt = now,
NotBefore = now,
Expires = now.Add(ttl),
SigningCredentials = _credentials,
};
return _handler.CreateToken(descriptor);
}
}
import secrets
import time
from typing import Any
import jwt # PyJWT
ALGORITHM = "HS256"
ACCESS_TOKEN_TTL_SECONDS = 900 # 15 minutes
REFRESH_TOKEN_TTL_SECONDS = 604800 # 7 days
class InvalidTokenError(Exception):
"""Raised when a token fails signature, claim, or expiry validation."""
class JwtManager:
"""PyJWT handles base64url and HMAC; hand-rolling them is error-prone."""
def __init__(self, secret_key: str, issuer: str, audience: str) -> None:
self._secret_key = secret_key
self._issuer = issuer
self._audience = audience
def generate_access_token(self, user_id: str, roles: list[str] | None = None) -> str:
return self._encode(user_id, "access", ACCESS_TOKEN_TTL_SECONDS, roles or [])
def generate_refresh_token(self, user_id: str) -> str:
return self._encode(user_id, "refresh", REFRESH_TOKEN_TTL_SECONDS, roles=[])
def validate(self, token: str) -> dict[str, Any]:
"""Validate a token and return its claims."""
try:
return jwt.decode(
token,
self._secret_key,
# Pinning the algorithm blocks "alg: none" and HS/RS confusion attacks.
algorithms=[ALGORITHM],
issuer=self._issuer,
audience=self._audience,
options={"require": ["exp", "iat", "sub"]},
leeway=30,
)
except jwt.PyJWTError as exc:
raise InvalidTokenError(str(exc)) from exc
def _encode(self, user_id: str, token_type: str, ttl: int, roles: list[str]) -> str:
now = int(time.time())
payload: dict[str, Any] = {
"sub": user_id,
"roles": roles,
"iat": now,
"nbf": now,
"exp": now + ttl,
"type": token_type,
"iss": self._issuer,
"aud": self._audience,
# Unique token ID, so individual tokens can be revoked.
"jti": secrets.token_hex(16),
}
return jwt.encode(payload, self._secret_key, algorithm=ALGORITHM)
### 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);
}
}
package middleware
import (
"context"
"encoding/json"
"net/http"
"strings"
"myapp/jwt"
)
var excludedPaths = map[string]bool{
"/api/auth/login": true,
"/api/auth/register": true,
"/api/health": true,
}
// ctxKey is an unexported type, so no other package can overwrite the identity
// we put on the context — a plain string key is collision-prone.
type ctxKey string
const (
ctxKeyUserID ctxKey = "user_id"
ctxKeyRoles ctxKey = "roles"
)
// JWTAuthMiddleware validates JWT tokens on incoming requests.
func JWTAuthMiddleware(jwtMgr *jwt.Manager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if excludedPaths[r.URL.Path] {
next.ServeHTTP(w, r)
return
}
token := extractBearerToken(r)
if token == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Authentication required"})
return
}
payload, err := jwtMgr.Validate(token)
if err != nil {
// The reason is deliberately not echoed back to the caller:
// "bad signature" vs "expired" tells an attacker how far they got.
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
return
}
// A refresh token must never grant access to protected endpoints.
if payload["type"] != "access" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Expected access token"})
return
}
ctx := context.WithValue(r.Context(), ctxKeyUserID, payload["sub"])
ctx = context.WithValue(ctx, ctxKeyRoles, payload["roles"])
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func extractBearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return h[7:]
}
return ""
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
namespace App.Auth.Jwt;
// In ASP.NET Core the authentication scheme is normally registered with
// AddJwtBearer and applied via [Authorize]; this middleware shows the same
// flow explicitly, matching the PHP/Go examples.
public sealed class JwtAuthMiddleware(RequestDelegate next, JwtManager jwt)
{
private static readonly HashSet<string> ExcludedPaths = new(StringComparer.Ordinal)
{
"/api/auth/login",
"/api/auth/register",
"/api/health",
};
public async Task InvokeAsync(HttpContext context)
{
if (ExcludedPaths.Contains(context.Request.Path.Value ?? string.Empty))
{
await next(context);
return;
}
string? token = ExtractBearerToken(context.Request);
if (token is null)
{
await WriteErrorAsync(context, "Authentication required");
return;
}
ClaimsIdentity identity;
try
{
identity = await jwt.ValidateAsync(token);
}
catch (InvalidTokenException)
{
// The reason is deliberately not echoed back to the caller.
await WriteErrorAsync(context, "Invalid or expired token");
return;
}
// A refresh token must never grant access to protected endpoints.
if (identity.FindFirst("type")?.Value != "access")
{
await WriteErrorAsync(context, "Expected access token");
return;
}
context.User = new ClaimsPrincipal(identity);
await next(context);
}
private static string? ExtractBearerToken(HttpRequest request)
{
string header = request.Headers.Authorization.ToString();
return header.StartsWith("Bearer ", StringComparison.Ordinal)
? header[7..]
: null;
}
private static Task WriteErrorAsync(HttpContext context, string message)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return context.Response.WriteAsJsonAsync(new { error = message });
}
}
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
EXCLUDED_PATHS = frozenset({"/api/auth/login", "/api/auth/register", "/api/health"})
class JwtAuthMiddleware(BaseHTTPMiddleware):
def __init__(self, app, jwt_manager: JwtManager) -> None:
super().__init__(app)
self._jwt = jwt_manager
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.url.path in EXCLUDED_PATHS:
return await call_next(request)
token = _extract_bearer_token(request)
if token is None:
return _unauthorized("Authentication required")
try:
payload = self._jwt.validate(token)
except InvalidTokenError:
# The reason is deliberately not echoed back to the caller.
return _unauthorized("Invalid or expired token")
# A refresh token must never grant access to protected endpoints.
if payload.get("type") != "access":
return _unauthorized("Expected access token")
# Attach identity to the request for downstream handlers.
request.state.user_id = payload["sub"]
request.state.roles = payload.get("roles", [])
return await call_next(request)
def _extract_bearer_token(request: Request) -> str | None:
header = request.headers.get("authorization", "")
return header[7:] if header.startswith("Bearer ") else None
def _unauthorized(message: str) -> JSONResponse:
return JSONResponse({"error": message}, status_code=401)
## 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,
) {}
}
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"myapp/jwt"
)
// TokenPair holds an access and refresh token pair.
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
const refreshTokenTTL = 7 * 24 * time.Hour
// ErrTokenReuseDetected is returned when a refresh token is presented twice.
var ErrTokenReuseDetected = errors.New("refresh token reuse detected")
// TokenRefreshService handles refresh token rotation.
type TokenRefreshService struct {
jwt *jwt.Manager
users UserRepository
rdb *redis.Client
}
// Refresh exchanges a refresh token for a new token pair.
func (s *TokenRefreshService) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
payload, err := s.jwt.Validate(refreshToken)
if err != nil {
return nil, fmt.Errorf("invalid refresh token: %w", err)
}
if payload["type"] != "refresh" {
return nil, errors.New("expected refresh token")
}
jti, ok := payload["jti"].(string)
if !ok {
return nil, errors.New("refresh token has no jti")
}
userID, ok := payload["sub"].(string)
if !ok {
return nil, errors.New("refresh token has no sub")
}
// SetNX 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, err := s.rdb.SetNX(ctx, "used_refresh:"+jti, "1", refreshTokenTTL).Result()
if err != nil {
return nil, fmt.Errorf("mark refresh token used: %w", err)
}
if !firstUse {
// Replay means the token likely leaked: revoke everything for this user.
// TTL matches the refresh token lifetime, so no token issued before the
// revocation can still be presented afterwards.
if err := s.rdb.Set(ctx, "user_revoked:"+userID, time.Now().Unix(), refreshTokenTTL).Err(); err != nil {
return nil, fmt.Errorf("revoke user tokens: %w", err)
}
return nil, ErrTokenReuseDetected
}
user, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("load user: %w", err)
}
access, err := s.jwt.GenerateAccessToken(user.ID, user.Roles)
if err != nil {
return nil, fmt.Errorf("generate access token: %w", err)
}
refresh, err := s.jwt.GenerateRefreshToken(user.ID)
if err != nil {
return nil, fmt.Errorf("generate refresh token: %w", err)
}
return &TokenPair{AccessToken: access, RefreshToken: refresh}, nil
}
using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using StackExchange.Redis;
namespace App.Auth;
public sealed record TokenPair(string AccessToken, string RefreshToken);
public sealed class TokenReuseDetectedException(string message) : Exception(message);
public sealed class TokenRefreshService(
Jwt.JwtManager jwt,
IUserRepository users,
IConnectionMultiplexer redis)
{
private static readonly TimeSpan RefreshTokenTtl = TimeSpan.FromDays(7);
// Refresh an access token, rotating the refresh token in the process.
public async Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct = default)
{
ClaimsIdentity identity = await jwt.ValidateAsync(refreshToken);
if (identity.FindFirst("type")?.Value != "refresh")
{
throw new ArgumentException("Expected refresh token", nameof(refreshToken));
}
string jti = identity.FindFirst(JwtRegisteredClaimNames.Jti)?.Value
?? throw new ArgumentException("Refresh token has no jti", nameof(refreshToken));
string userId = identity.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
?? throw new ArgumentException("Refresh token has no sub", nameof(refreshToken));
IDatabase db = redis.GetDatabase();
// StringSet with When.NotExists marks the token used atomically —
// a plain get-then-set would race two concurrent refreshes.
bool firstUse = await db.StringSetAsync(
$"used_refresh:{jti}",
"1",
RefreshTokenTtl,
When.NotExists);
if (!firstUse)
{
// Replay means the token likely leaked: revoke everything for this user.
await db.StringSetAsync(
$"user_revoked:{userId}",
DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
RefreshTokenTtl);
throw new TokenReuseDetectedException("Refresh token reuse detected");
}
User user = await users.FindByIdAsync(userId, ct)
?? throw new InvalidOperationException("User not found");
return new TokenPair(
jwt.GenerateAccessToken(user.Id, user.Roles),
jwt.GenerateRefreshToken(user.Id));
}
}
import time
from dataclasses import dataclass
from redis.asyncio import Redis
REFRESH_TOKEN_TTL_SECONDS = 604800 # 7 days
@dataclass(frozen=True, slots=True)
class TokenPair:
access_token: str
refresh_token: str
class TokenReuseDetectedError(Exception):
"""Raised when a refresh token is presented more than once."""
class TokenRefreshService:
def __init__(self, jwt_manager: JwtManager, users: UserRepository, redis: Redis) -> None:
self._jwt = jwt_manager
self._users = users
self._redis = redis
async def refresh(self, refresh_token: str) -> TokenPair:
"""Refresh an access token, rotating the refresh token in the process."""
payload = self._jwt.validate(refresh_token)
if payload.get("type") != "refresh":
raise ValueError("expected refresh token")
jti = payload["jti"]
user_id = payload["sub"]
# SET NX marks the token used atomically — a plain get-then-set
# would race two concurrent refreshes.
first_use = await self._redis.set(
f"used_refresh:{jti}", "1", ex=REFRESH_TOKEN_TTL_SECONDS, nx=True
)
if not first_use:
# Replay means the token likely leaked: revoke everything for this user.
await self._redis.set(
f"user_revoked:{user_id}",
int(time.time()),
ex=REFRESH_TOKEN_TTL_SECONDS,
)
raise TokenReuseDetectedError("refresh token reuse detected")
user = await self._users.find_by_id(user_id)
if user is None:
raise LookupError("user not found")
return TokenPair(
access_token=self._jwt.generate_access_token(user.id, user.roles),
refresh_token=self._jwt.generate_refresh_token(user.id),
)
## 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.