Mid💻Практика10 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
PHPGo
<?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 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)

PHP: реализация JWT

PHPGo
<?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)
}
### JWT Middleware
PHPGo
<?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 — протокол авторизации, позволяющий приложению получить ограниченный доступ к ресурсам пользователя без передачи пароля.

Роли 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

PHPGo
<?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 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