Аутентификация и авторизация
Аутентификация 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
{
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);
if ($user === null || !password_verify($password, $user->getPasswordHash())) {
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();
}
}
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
// 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, err := a.users.FindByEmail(ctx, email)
if err != nil {
return "", ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return "", ErrInvalidCredentials
}
sessionID, _ := generateSessionID()
data, _ := json.Marshal(map[string]any{
"user_id": user.ID,
"roles": user.Roles,
"created_at": time.Now().Unix(),
})
a.rdb.Set(ctx, "session:"+sessionID, data, a.sessionTTL)
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
}
var session map[string]any
json.Unmarshal(data, &session)
return &AuthenticatedUser{
ID: session["user_id"].(string),
Roles: toStringSlice(session["roles"]),
}, nil
}
// Logout destroys the session.
func (a *SessionAuthenticator) Logout(ctx context.Context, sessionID string) {
a.rdb.Del(ctx, "session:"+sessionID)
}
func generateSessionID() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
return hex.EncodeToString(b), err
}
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)
PHP: реализация JWT
<?php
declare(strict_types=1);
namespace App\Auth\Jwt;
final readonly class JwtManager
{
public function __construct(
private string $secretKey,
private string $algorithm = 'HS256',
private int $accessTokenTtl = 900, // 15 minutes
private int $refreshTokenTtl = 604800, // 7 days
) {}
/**
* Generate an access token for a user.
*/
public function generateAccessToken(string $userId, array $roles = []): string
{
$now = time();
$payload = [
'sub' => $userId,
'roles' => $roles,
'iat' => $now,
'exp' => $now + $this->accessTokenTtl,
'type' => 'access',
'jti' => bin2hex(random_bytes(16)), // Unique token ID
];
return $this->encode($payload);
}
/**
* Generate a refresh token.
*/
public function generateRefreshToken(string $userId): string
{
$now = time();
$payload = [
'sub' => $userId,
'iat' => $now,
'exp' => $now + $this->refreshTokenTtl,
'type' => 'refresh',
'jti' => bin2hex(random_bytes(16)),
];
return $this->encode($payload);
}
/**
* 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;
// Verify signature
$expectedSignature = $this->sign("{$headerB64}.{$payloadB64}");
if (!hash_equals($expectedSignature, $this->base64UrlDecode($signatureB64))) {
throw new InvalidTokenException('Invalid signature');
}
$payload = json_decode($this->base64UrlDecode($payloadB64), true);
if ($payload === null) {
throw new InvalidTokenException('Invalid payload');
}
// Check expiration
if (isset($payload['exp']) && $payload['exp'] < time()) {
throw new InvalidTokenException('Token has expired');
}
return $payload;
}
private function encode(array $payload): string
{
$header = $this->base64UrlEncode(json_encode([
'alg' => $this->algorithm,
'typ' => 'JWT',
]));
$payloadEncoded = $this->base64UrlEncode(json_encode($payload));
$signature = $this->base64UrlEncode($this->sign("{$header}.{$payloadEncoded}"));
return "{$header}.{$payloadEncoded}.{$signature}";
}
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
{
return base64_decode(strtr($data, '-_', '+/'));
}
}
package jwt
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// Manager handles JWT token generation and validation.
type Manager struct {
secretKey []byte
accessTokenTTL time.Duration
refreshTTL time.Duration
}
// NewManager creates a JWT manager.
func NewManager(secret string) *Manager {
return &Manager{
secretKey: []byte(secret),
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) {
jti := make([]byte, 16)
rand.Read(jti)
payload := map[string]any{
"sub": userID,
"roles": roles,
"iat": time.Now().Unix(),
"exp": time.Now().Add(m.accessTokenTTL).Unix(),
"type": "access",
"jti": hex.EncodeToString(jti),
}
return m.encode(payload)
}
// GenerateRefreshToken creates a signed refresh token.
func (m *Manager) GenerateRefreshToken(userID string) (string, error) {
jti := make([]byte, 16)
rand.Read(jti)
payload := map[string]any{
"sub": userID,
"iat": time.Now().Unix(),
"exp": time.Now().Add(m.refreshTTL).Unix(),
"type": "refresh",
"jti": hex.EncodeToString(jti),
}
return m.encode(payload)
}
// 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, errors.New("invalid token format")
}
sig := m.sign(parts[0] + "." + parts[1])
gotSig, _ := base64.RawURLEncoding.DecodeString(parts[2])
if !hmac.Equal(sig, gotSig) {
return nil, errors.New("invalid signature")
}
payloadBytes, _ := base64.RawURLEncoding.DecodeString(parts[1])
var payload map[string]any
json.Unmarshal(payloadBytes, &payload)
if exp, ok := payload["exp"].(float64); ok && int64(exp) < time.Now().Unix() {
return nil, errors.New("token has expired")
}
return payload, nil
}
func (m *Manager) encode(payload map[string]any) (string, error) {
header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
body, _ := json.Marshal(payload)
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)
}
<?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);
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 $e) {
$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,
}
// 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 {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
return
}
if payload["type"] != "access" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Expected access token"})
return
}
ctx := context.WithValue(r.Context(), "user_id", payload["sub"])
ctx = context.WithValue(ctx, "roles", 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)
}
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 readonly class TokenRefreshService
{
public function __construct(
private Jwt\JwtManager $jwt,
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'];
// Check if refresh token was already used (token rotation)
if ($this->redis->get("used_refresh:{$jti}") !== false) {
// Possible token theft — invalidate all tokens for this user
$this->revokeAllTokens($payload['sub']);
throw new \RuntimeException('Refresh token reuse detected');
}
// Mark current refresh token as used
$this->redis->setex("used_refresh:{$jti}", 604800, '1');
// Issue new token pair
$user = $this->loadUser($payload['sub']);
return new TokenPair(
accessToken: $this->jwt->generateAccessToken($user->getId(), $user->getRoles()),
refreshToken: $this->jwt->generateRefreshToken($user->getId()),
);
}
private function revokeAllTokens(string $userId): void
{
$this->redis->set("user_revoked:{$userId}", time());
}
}
final readonly class TokenPair
{
public function __construct(
public string $accessToken,
public string $refreshToken,
) {}
}
package auth
import (
"context"
"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"`
}
// TokenRefreshService handles refresh token rotation.
type TokenRefreshService struct {
jwt *jwt.Manager
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, fmt.Errorf("expected refresh token")
}
jti := payload["jti"].(string)
// Check if refresh token was already used (rotation)
used, _ := s.rdb.Get(ctx, "used_refresh:"+jti).Result()
if used != "" {
// Possible token theft -- revoke all tokens
userID := payload["sub"].(string)
s.rdb.Set(ctx, "user_revoked:"+userID, time.Now().Unix(), 7*24*time.Hour)
return nil, fmt.Errorf("refresh token reuse detected")
}
// Mark current refresh token as used
s.rdb.Set(ctx, "used_refresh:"+jti, "1", 7*24*time.Hour)
userID := payload["sub"].(string)
access, _ := s.jwt.GenerateAccessToken(userID, nil)
refresh, _ := s.jwt.GenerateRefreshToken(userID)
return &TokenPair{AccessToken: access, RefreshToken: refresh}, nil
}
| Критерий | 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 |