feat: ядро аутентификации (Claims, TokenVerifier, DevHs256Verifier, JwksVerifier, build_verifier)
Dev HS256 включается только при AUTH_DEV_ISSUER и DEBUG одновременно; РАИСА — RS256/ES256 по публичному ключу или JWKS с кэшем. PyJWT[crypto] добавлен в requirements.txt как прод-зависимость. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
7429240f01
commit
70b47a097d
@ -44,6 +44,7 @@ pydantic-extra-types==2.11.0
|
||||
pydantic-settings==2.14.2
|
||||
pydantic_core==2.46.4
|
||||
Pygments==2.20.0
|
||||
PyJWT[crypto]==2.15.1
|
||||
PyNaCl==1.5.0
|
||||
python-dotenv==1.2.2
|
||||
python-multipart==0.0.32
|
||||
|
||||
250
api/src/core/auth.py
Normal file
250
api/src/core/auth.py
Normal file
@ -0,0 +1,250 @@
|
||||
"""Аутентификация: проверка JWT и фабрика проверяющего по настройкам (D-5).
|
||||
|
||||
Модуль ничего не знает про HTTP и БД: он превращает строку токена в :class:`Claims` или бросает :class:`AuthError`
|
||||
(сообщение по-русски, пригодно для ``{"detail": ...}`` ответа 401). Поиск ``app_user`` по email и права — зависимости
|
||||
``src/api/dependencies/auth.py`` (задача 17).
|
||||
|
||||
Проверяющие:
|
||||
|
||||
* :class:`DevHs256Verifier` — только для разработки: HS256 с общим секретом ``AUTH_DEV_SECRET``; выпускает токены для
|
||||
``POST /api/v1/dev/token``. Создаётся фабрикой при ``AUTH_DEV_ISSUER=true`` **и** ``DEBUG=true``.
|
||||
* :class:`JwksVerifier` — каркас под токены РАИСА (параметры JWT РАИСА неизвестны, решение #5 design.md): подпись
|
||||
RS256/ES256 по публичному ключу ``AUTH_PUBLIC_KEY`` (PEM) либо по ключам из ``AUTH_JWKS_URL`` (с кэшем).
|
||||
|
||||
Фабрика :func:`build_verifier` возвращает ``None``, если ни dev-режим, ни ключи РАИСА не настроены: в этом случае
|
||||
токены не принимаются вовсе (зависимость ``current_user`` отвечает 401 «аутентификация не настроена»).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
from jwt import PyJWKClient
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.core.config import Settings
|
||||
|
||||
# Алгоритмы РАИСА-проверяющего: асимметричные, явно. ``none`` и HS* не допускаются никогда (атака подмены алгоритма).
|
||||
JWKS_ALGORITHMS: tuple[str, ...] = ("RS256", "ES256")
|
||||
DEV_ALGORITHM = "HS256"
|
||||
# Допуск на расхождение часов с эмитентом токена
|
||||
JWKS_LEEWAY_SECONDS = 10
|
||||
# Срок жизни dev-токена (требование спеки authn-authz)
|
||||
DEV_TOKEN_TTL = timedelta(hours=8)
|
||||
# Время жизни кэша набора ключей JWKS (PyJWKClient), секунды
|
||||
JWKS_CACHE_LIFESPAN_SECONDS = 300
|
||||
JWKS_FETCH_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Токен не принят. Текст сообщения — по-русски, без внутренностей; годится для тела 401."""
|
||||
|
||||
|
||||
class TokenExpiredError(AuthError):
|
||||
"""Срок действия токена истёк."""
|
||||
|
||||
|
||||
class TokenSignatureError(AuthError):
|
||||
"""Подпись токена не проходит проверку."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Claims:
|
||||
"""Проверенные данные токена: ``sub``, email пользователя и момент окончания срока (UTC)."""
|
||||
|
||||
subject: str
|
||||
email: str
|
||||
exp: datetime
|
||||
|
||||
|
||||
class TokenVerifier(Protocol):
|
||||
"""Проверяющий токены. ``verify`` асинхронный: JWKS-проверяющий может ходить за ключами по сети."""
|
||||
|
||||
async def verify(self, token: str) -> Claims:
|
||||
"""Возвращает claims проверенного токена или бросает :class:`AuthError`."""
|
||||
...
|
||||
|
||||
|
||||
class _SigningKeyProvider(Protocol):
|
||||
"""Источник ключа проверки подписи по токену (интерфейс :class:`jwt.PyJWKClient`)."""
|
||||
|
||||
def get_signing_key_from_jwt(self, token: str) -> Any: ...
|
||||
|
||||
|
||||
# Порядок важен: более конкретные классы исключений PyJWT — раньше общих
|
||||
_ERROR_MESSAGES: tuple[tuple[type[jwt.PyJWTError], type[AuthError], str], ...] = (
|
||||
(jwt.ExpiredSignatureError, TokenExpiredError, "срок действия токена истёк"),
|
||||
(jwt.InvalidSignatureError, TokenSignatureError, "недействительная подпись токена"),
|
||||
(jwt.InvalidAlgorithmError, AuthError, "недопустимый алгоритм подписи токена"),
|
||||
(jwt.InvalidIssuerError, AuthError, "токен выпущен неизвестным эмитентом"),
|
||||
(jwt.InvalidAudienceError, AuthError, "токен предназначен для другого сервиса"),
|
||||
(jwt.ImmatureSignatureError, AuthError, "токен ещё не действителен"),
|
||||
(jwt.DecodeError, AuthError, "некорректный токен"),
|
||||
)
|
||||
|
||||
|
||||
def _to_auth_error(exc: jwt.PyJWTError) -> AuthError:
|
||||
"""Переводит ошибку PyJWT в :class:`AuthError` с русским сообщением; детали библиотеки наружу не отдаются."""
|
||||
for exc_type, auth_type, message in _ERROR_MESSAGES:
|
||||
if isinstance(exc, exc_type):
|
||||
return auth_type(message)
|
||||
if isinstance(exc, jwt.MissingRequiredClaimError):
|
||||
return AuthError(f"в токене нет обязательного поля «{exc.claim}»")
|
||||
if isinstance(exc, jwt.PyJWKClientError):
|
||||
# сбой получения ключей (сеть, неизвестный kid) — деталь только в лог, не в ответ
|
||||
logger.warning("Не удалось получить ключ проверки подписи JWT: {}", exc)
|
||||
return AuthError("не удалось получить ключ проверки подписи токена")
|
||||
return AuthError("недействительный токен")
|
||||
|
||||
|
||||
def _claims_from_payload(payload: dict[str, Any], email_claim: str) -> Claims:
|
||||
"""Собирает :class:`Claims` из проверенной полезной нагрузки; ``exp`` и ``sub`` уже проверены на наличие."""
|
||||
email = payload.get(email_claim)
|
||||
if not isinstance(email, str) or not email.strip():
|
||||
raise AuthError("в токене нет email")
|
||||
return Claims(
|
||||
subject=str(payload["sub"]),
|
||||
email=email.strip(),
|
||||
exp=datetime.fromtimestamp(payload["exp"], tz=UTC),
|
||||
)
|
||||
|
||||
|
||||
class DevHs256Verifier:
|
||||
"""Проверяющий и эмитент dev-токенов: HS256 с общим секретом. Только для разработки (``DEBUG=true``)."""
|
||||
|
||||
def __init__(self, secret: str, *, email_claim: str = "email") -> None:
|
||||
if not secret:
|
||||
raise ValueError("секрет dev-эмитента не задан")
|
||||
self._secret = secret
|
||||
self._email_claim = email_claim
|
||||
|
||||
async def verify(self, token: str) -> Claims:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self._secret,
|
||||
algorithms=[DEV_ALGORITHM],
|
||||
options={"require": ["exp", "sub"], "verify_aud": False},
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise _to_auth_error(exc) from exc
|
||||
return _claims_from_payload(payload, self._email_claim)
|
||||
|
||||
def issue_token(self, email: str, *, ttl: timedelta = DEV_TOKEN_TTL) -> str:
|
||||
"""Выпускает HS256 JWT для ``POST /api/v1/dev/token``: ``sub`` = email, email в настроенном claim."""
|
||||
now = datetime.now(UTC)
|
||||
payload = {"sub": email, self._email_claim: email, "iat": now, "exp": now + ttl}
|
||||
return jwt.encode(payload, self._secret, algorithm=DEV_ALGORITHM)
|
||||
|
||||
|
||||
def _load_static_key(pem: str) -> tuple[Any, tuple[str, ...]]:
|
||||
"""Загружает публичный ключ из PEM и выводит допустимый алгоритм из его типа (RSA → RS256, EC P-256 → ES256)."""
|
||||
# в .env PEM удобно хранить одной строкой с литеральными «\n»
|
||||
try:
|
||||
key = serialization.load_pem_public_key(pem.replace("\\n", "\n").strip().encode())
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("AUTH_PUBLIC_KEY не является публичным ключом в формате PEM") from exc
|
||||
if isinstance(key, rsa.RSAPublicKey):
|
||||
return key, ("RS256",)
|
||||
if isinstance(key, ec.EllipticCurvePublicKey) and key.curve.name == "secp256r1":
|
||||
return key, ("ES256",)
|
||||
raise ValueError("AUTH_PUBLIC_KEY: поддерживаются ключи RSA и EC P-256 (алгоритмы RS256 и ES256)")
|
||||
|
||||
|
||||
class JwksVerifier:
|
||||
"""Проверяющий токены РАИСА: RS256/ES256 по статическому публичному ключу либо по JWKS (кэш ключей).
|
||||
|
||||
``iss`` проверяется, только если задан ``issuer``; ``aud`` — только если задан ``audience``. ``exp`` и ``sub``
|
||||
обязательны всегда. ``jwk_client`` — внедряемый источник ключей (тесты); по умолчанию :class:`jwt.PyJWKClient`.
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913 - все параметры именованные и соответствуют настройкам AUTH_*
|
||||
self,
|
||||
*,
|
||||
jwks_url: str = "",
|
||||
public_key: str = "",
|
||||
issuer: str = "",
|
||||
audience: str = "",
|
||||
email_claim: str = "email",
|
||||
leeway: int = JWKS_LEEWAY_SECONDS,
|
||||
jwk_client: _SigningKeyProvider | None = None,
|
||||
) -> None:
|
||||
sources = [bool(jwks_url), bool(public_key), jwk_client is not None]
|
||||
if sum(sources) == 0:
|
||||
raise ValueError("не задан ключ проверки подписи: нужен AUTH_JWKS_URL или AUTH_PUBLIC_KEY")
|
||||
if sum(sources) > 1:
|
||||
raise ValueError("AUTH_JWKS_URL и AUTH_PUBLIC_KEY заданы одновременно: оставьте один источник ключа")
|
||||
self._static_key: Any = None
|
||||
self._static_algorithms: tuple[str, ...] = ()
|
||||
self._client: _SigningKeyProvider | None = jwk_client
|
||||
if public_key:
|
||||
self._static_key, self._static_algorithms = _load_static_key(public_key)
|
||||
elif jwks_url:
|
||||
self._client = PyJWKClient(
|
||||
jwks_url,
|
||||
cache_keys=True,
|
||||
cache_jwk_set=True,
|
||||
lifespan=JWKS_CACHE_LIFESPAN_SECONDS,
|
||||
timeout=JWKS_FETCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._issuer = issuer or None
|
||||
self._audience = audience or None
|
||||
self._email_claim = email_claim
|
||||
self._leeway = leeway
|
||||
|
||||
async def _signing_key(self, token: str) -> tuple[Any, list[str]]:
|
||||
if self._client is None:
|
||||
return self._static_key, list(self._static_algorithms)
|
||||
# PyJWKClient ходит в сеть блокирующе — выносим из цикла событий
|
||||
signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token)
|
||||
return signing_key.key, list(JWKS_ALGORITHMS)
|
||||
|
||||
async def verify(self, token: str) -> Claims:
|
||||
try:
|
||||
key, algorithms = await self._signing_key(token)
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=algorithms,
|
||||
issuer=self._issuer,
|
||||
audience=self._audience,
|
||||
leeway=self._leeway,
|
||||
options={"require": ["exp", "sub"], "verify_aud": self._audience is not None},
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise _to_auth_error(exc) from exc
|
||||
return _claims_from_payload(payload, self._email_claim)
|
||||
|
||||
|
||||
def build_verifier(settings: Settings) -> TokenVerifier | None:
|
||||
"""Собирает проверяющего токены по настройкам.
|
||||
|
||||
* ``AUTH_DEV_ISSUER`` **и** ``DEBUG`` → :class:`DevHs256Verifier` (только он; JWKS в этом режиме не используется);
|
||||
пустой ``AUTH_DEV_SECRET`` — ошибка конфигурации (``RuntimeError``).
|
||||
* иначе, если задан ``AUTH_JWKS_URL`` или ``AUTH_PUBLIC_KEY`` → :class:`JwksVerifier`.
|
||||
* иначе ``None``: проверять нечем, токены не принимаются. ``AUTH_DEV_ISSUER`` без ``DEBUG`` dev-проверяющего
|
||||
не включает никогда, даже при заданном секрете.
|
||||
"""
|
||||
if settings.AUTH_DEV_ISSUER and settings.DEBUG:
|
||||
if not settings.AUTH_DEV_SECRET:
|
||||
raise RuntimeError("AUTH_DEV_ISSUER=true и DEBUG=true, но AUTH_DEV_SECRET не задан")
|
||||
logger.warning("Включён dev-эмитент токенов (AUTH_DEV_ISSUER и DEBUG): только для разработки")
|
||||
return DevHs256Verifier(settings.AUTH_DEV_SECRET, email_claim=settings.AUTH_EMAIL_CLAIM)
|
||||
if settings.AUTH_DEV_ISSUER:
|
||||
logger.warning("AUTH_DEV_ISSUER=true проигнорирован: dev-эмитент работает только при DEBUG=true")
|
||||
if settings.AUTH_JWKS_URL or settings.AUTH_PUBLIC_KEY:
|
||||
return JwksVerifier(
|
||||
jwks_url=settings.AUTH_JWKS_URL,
|
||||
public_key=settings.AUTH_PUBLIC_KEY,
|
||||
issuer=settings.AUTH_ISSUER,
|
||||
audience=settings.AUTH_AUDIENCE,
|
||||
email_claim=settings.AUTH_EMAIL_CLAIM,
|
||||
)
|
||||
return None
|
||||
554
api/tests/test_auth_core.py
Normal file
554
api/tests/test_auth_core.py
Normal file
@ -0,0 +1,554 @@
|
||||
"""Ядро аутентификации: проверяющие токены и фабрика по настройкам (без БД, без внешней сети)."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
from jwt.exceptions import PyJWKClientConnectionError
|
||||
|
||||
from src.core.auth import (
|
||||
AuthError,
|
||||
Claims,
|
||||
DevHs256Verifier,
|
||||
JwksVerifier,
|
||||
TokenExpiredError,
|
||||
TokenSignatureError,
|
||||
build_verifier,
|
||||
)
|
||||
from src.core.config import Settings
|
||||
|
||||
DEV_SECRET = "dev-secret-for-unit-tests-0123456789abcdef"
|
||||
OTHER_SECRET = "another-secret-for-unit-tests-0123456789"
|
||||
EMAIL = "ka_rf@armka.local"
|
||||
|
||||
|
||||
class _LogSpy:
|
||||
"""Подмена logger модуля auth: собирает сообщения, не пишет в stderr."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[str] = []
|
||||
|
||||
def warning(self, message: str, *args: Any) -> None:
|
||||
self.messages.append(message.format(*args))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def log_messages(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
"""Перехватывает логи модуля auth (шум не попадает в вывод тестов) и отдаёт их для проверок."""
|
||||
spy = _LogSpy()
|
||||
monkeypatch.setattr("src.core.auth.logger", spy)
|
||||
return spy.messages
|
||||
|
||||
|
||||
def _payload(**overrides: Any) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
data: dict[str, Any] = {"sub": EMAIL, "email": EMAIL, "iat": now, "exp": now + timedelta(hours=1)}
|
||||
data.update(overrides)
|
||||
return {k: v for k, v in data.items() if v is not None}
|
||||
|
||||
|
||||
def _pem_public(key: Any) -> str:
|
||||
return (
|
||||
key.public_key()
|
||||
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
.decode()
|
||||
)
|
||||
|
||||
|
||||
def _pem_private(key: Any) -> str:
|
||||
return key.private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
|
||||
).decode()
|
||||
|
||||
|
||||
def _b64(data: bytes) -> bytes:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=")
|
||||
|
||||
|
||||
def _forge_hs(payload: dict[str, Any], secret: bytes, alg: str = "HS256") -> str:
|
||||
"""Собирает HS-токен вручную (PyJWT не даст подписать HMAC асимметричным PEM — атаки подмены ключа)."""
|
||||
header = _b64(json.dumps({"alg": alg, "typ": "JWT"}).encode())
|
||||
body = _b64(json.dumps(payload, default=lambda v: int(v.timestamp())).encode())
|
||||
digest = {"HS256": hashlib.sha256, "HS384": hashlib.sha384}[alg]
|
||||
sig = _b64(hmac.new(secret, header + b"." + body, digest).digest())
|
||||
return (header + b"." + body + b"." + sig).decode()
|
||||
|
||||
|
||||
def _unsigned(payload: dict[str, Any]) -> str:
|
||||
header = _b64(json.dumps({"alg": "none", "typ": "JWT"}).encode())
|
||||
body = _b64(json.dumps(payload, default=lambda v: int(v.timestamp())).encode())
|
||||
return (header + b"." + body + b".").decode()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_key() -> rsa.RSAPrivateKey:
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def other_rsa_key() -> rsa.RSAPrivateKey:
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ec_key() -> ec.EllipticCurvePrivateKey:
|
||||
return ec.generate_private_key(ec.SECP256R1())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def other_ec_key() -> ec.EllipticCurvePrivateKey:
|
||||
return ec.generate_private_key(ec.SECP256R1())
|
||||
|
||||
|
||||
# --- DevHs256Verifier -------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dev() -> DevHs256Verifier:
|
||||
return DevHs256Verifier(DEV_SECRET)
|
||||
|
||||
|
||||
async def test_dev_valid_token(dev: DevHs256Verifier) -> None:
|
||||
exp = datetime.now(UTC) + timedelta(hours=2)
|
||||
token = jwt.encode(_payload(exp=exp), DEV_SECRET, algorithm="HS256")
|
||||
claims = await dev.verify(token)
|
||||
assert isinstance(claims, Claims)
|
||||
assert claims.subject == EMAIL
|
||||
assert claims.email == EMAIL
|
||||
assert claims.exp == exp.replace(microsecond=0)
|
||||
assert claims.exp.tzinfo is not None
|
||||
|
||||
|
||||
async def test_dev_expired(dev: DevHs256Verifier) -> None:
|
||||
token = jwt.encode(_payload(exp=datetime.now(UTC) - timedelta(minutes=5)), DEV_SECRET, algorithm="HS256")
|
||||
with pytest.raises(TokenExpiredError, match="срок действия"):
|
||||
await dev.verify(token)
|
||||
|
||||
|
||||
async def test_dev_foreign_secret(dev: DevHs256Verifier) -> None:
|
||||
token = jwt.encode(_payload(), OTHER_SECRET, algorithm="HS256")
|
||||
with pytest.raises(TokenSignatureError, match="подпись"):
|
||||
await dev.verify(token)
|
||||
|
||||
|
||||
async def test_dev_no_email(dev: DevHs256Verifier) -> None:
|
||||
token = jwt.encode(_payload(email=None), DEV_SECRET, algorithm="HS256")
|
||||
with pytest.raises(AuthError, match="нет email"):
|
||||
await dev.verify(token)
|
||||
|
||||
|
||||
async def test_dev_empty_email(dev: DevHs256Verifier) -> None:
|
||||
token = jwt.encode(_payload(email=" "), DEV_SECRET, algorithm="HS256")
|
||||
with pytest.raises(AuthError, match="нет email"):
|
||||
await dev.verify(token)
|
||||
|
||||
|
||||
async def test_dev_no_exp(dev: DevHs256Verifier) -> None:
|
||||
token = jwt.encode(_payload(exp=None), DEV_SECRET, algorithm="HS256")
|
||||
with pytest.raises(AuthError, match="exp"):
|
||||
await dev.verify(token)
|
||||
|
||||
|
||||
async def test_dev_no_sub(dev: DevHs256Verifier) -> None:
|
||||
token = jwt.encode(_payload(sub=None), DEV_SECRET, algorithm="HS256")
|
||||
with pytest.raises(AuthError, match="sub"):
|
||||
await dev.verify(token)
|
||||
|
||||
|
||||
async def test_dev_alg_none(dev: DevHs256Verifier) -> None:
|
||||
with pytest.raises(AuthError):
|
||||
await dev.verify(_unsigned(_payload()))
|
||||
|
||||
|
||||
async def test_dev_alg_hs384(dev: DevHs256Verifier) -> None:
|
||||
"""Токен с корректной подписью, но другим алгоритмом (HS384) не принимается: алгоритм зафиксирован."""
|
||||
with pytest.raises(AuthError, match="алгоритм"):
|
||||
await dev.verify(_forge_hs(_payload(), DEV_SECRET.encode(), alg="HS384"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", ["", "abc", "a.b.c", "не-токен"])
|
||||
async def test_dev_garbage(dev: DevHs256Verifier, token: str) -> None:
|
||||
with pytest.raises(AuthError):
|
||||
await dev.verify(token)
|
||||
|
||||
|
||||
async def test_dev_issue_token_roundtrip(dev: DevHs256Verifier) -> None:
|
||||
before = datetime.now(UTC)
|
||||
token = dev.issue_token(EMAIL)
|
||||
claims = await dev.verify(token)
|
||||
assert claims.subject == EMAIL
|
||||
assert claims.email == EMAIL
|
||||
assert timedelta(hours=8) - timedelta(seconds=5) <= claims.exp - before <= timedelta(hours=8, seconds=5)
|
||||
header = jwt.get_unverified_header(token)
|
||||
assert header["alg"] == "HS256"
|
||||
assert "iat" in jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
|
||||
async def test_dev_issue_token_custom_ttl_and_claim() -> None:
|
||||
dev = DevHs256Verifier(DEV_SECRET, email_claim="mail")
|
||||
token = dev.issue_token(EMAIL, ttl=timedelta(minutes=10))
|
||||
assert jwt.decode(token, options={"verify_signature": False})["mail"] == EMAIL
|
||||
claims = await dev.verify(token)
|
||||
assert claims.email == EMAIL
|
||||
assert claims.exp - datetime.now(UTC) < timedelta(minutes=11)
|
||||
|
||||
|
||||
def test_dev_empty_secret_forbidden() -> None:
|
||||
with pytest.raises(ValueError, match="секрет"):
|
||||
DevHs256Verifier("")
|
||||
|
||||
|
||||
# --- JwksVerifier (статический публичный ключ) -------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(params=["rs256", "es256"])
|
||||
def asym(
|
||||
request: pytest.FixtureRequest,
|
||||
rsa_key: rsa.RSAPrivateKey,
|
||||
other_rsa_key: rsa.RSAPrivateKey,
|
||||
ec_key: ec.EllipticCurvePrivateKey,
|
||||
other_ec_key: ec.EllipticCurvePrivateKey,
|
||||
) -> SimpleNamespace:
|
||||
"""Пара алгоритм/ключи: подписывающий, публичный PEM для проверяющего и чужой подписывающий ключ."""
|
||||
if request.param == "rs256":
|
||||
return SimpleNamespace(
|
||||
alg="RS256", priv=_pem_private(rsa_key), pub=_pem_public(rsa_key), other=_pem_private(other_rsa_key)
|
||||
)
|
||||
return SimpleNamespace(
|
||||
alg="ES256", priv=_pem_private(ec_key), pub=_pem_public(ec_key), other=_pem_private(other_ec_key)
|
||||
)
|
||||
|
||||
|
||||
async def test_jwks_static_valid(asym: SimpleNamespace) -> None:
|
||||
exp = datetime.now(UTC) + timedelta(hours=1)
|
||||
token = jwt.encode(_payload(exp=exp), asym.priv, algorithm=asym.alg)
|
||||
claims = await JwksVerifier(public_key=asym.pub).verify(token)
|
||||
assert (claims.subject, claims.email) == (EMAIL, EMAIL)
|
||||
assert claims.exp == exp.replace(microsecond=0)
|
||||
|
||||
|
||||
async def test_jwks_static_pem_with_escaped_newlines(asym: SimpleNamespace) -> None:
|
||||
"""В .env PEM удобно хранить одной строкой с литеральными \\n."""
|
||||
token = jwt.encode(_payload(), asym.priv, algorithm=asym.alg)
|
||||
claims = await JwksVerifier(public_key=asym.pub.replace("\n", "\\n")).verify(token)
|
||||
assert claims.email == EMAIL
|
||||
|
||||
|
||||
async def test_jwks_expired(asym: SimpleNamespace) -> None:
|
||||
token = jwt.encode(_payload(exp=datetime.now(UTC) - timedelta(minutes=5)), asym.priv, algorithm=asym.alg)
|
||||
with pytest.raises(TokenExpiredError):
|
||||
await JwksVerifier(public_key=asym.pub).verify(token)
|
||||
|
||||
|
||||
async def test_jwks_leeway_tolerates_small_clock_skew(asym: SimpleNamespace) -> None:
|
||||
token = jwt.encode(_payload(exp=datetime.now(UTC) - timedelta(seconds=3)), asym.priv, algorithm=asym.alg)
|
||||
claims = await JwksVerifier(public_key=asym.pub).verify(token)
|
||||
assert claims.email == EMAIL
|
||||
|
||||
|
||||
async def test_jwks_foreign_key(asym: SimpleNamespace) -> None:
|
||||
token = jwt.encode(_payload(), asym.other, algorithm=asym.alg)
|
||||
with pytest.raises(TokenSignatureError):
|
||||
await JwksVerifier(public_key=asym.pub).verify(token)
|
||||
|
||||
|
||||
async def test_jwks_no_exp(asym: SimpleNamespace) -> None:
|
||||
token = jwt.encode(_payload(exp=None), asym.priv, algorithm=asym.alg)
|
||||
with pytest.raises(AuthError, match="exp"):
|
||||
await JwksVerifier(public_key=asym.pub).verify(token)
|
||||
|
||||
|
||||
async def test_jwks_no_sub(asym: SimpleNamespace) -> None:
|
||||
token = jwt.encode(_payload(sub=None), asym.priv, algorithm=asym.alg)
|
||||
with pytest.raises(AuthError, match="sub"):
|
||||
await JwksVerifier(public_key=asym.pub).verify(token)
|
||||
|
||||
|
||||
async def test_jwks_issuer_checked_when_configured(asym: SimpleNamespace) -> None:
|
||||
verifier = JwksVerifier(public_key=asym.pub, issuer="https://sso.raisa")
|
||||
ok = jwt.encode(_payload(iss="https://sso.raisa"), asym.priv, algorithm=asym.alg)
|
||||
assert (await verifier.verify(ok)).email == EMAIL
|
||||
for bad in (_payload(iss="https://evil"), _payload()):
|
||||
with pytest.raises(AuthError):
|
||||
await verifier.verify(jwt.encode(bad, asym.priv, algorithm=asym.alg))
|
||||
|
||||
|
||||
async def test_jwks_issuer_ignored_when_not_configured(asym: SimpleNamespace) -> None:
|
||||
token = jwt.encode(_payload(iss="whatever"), asym.priv, algorithm=asym.alg)
|
||||
assert (await JwksVerifier(public_key=asym.pub).verify(token)).email == EMAIL
|
||||
|
||||
|
||||
async def test_jwks_audience_checked_when_configured(asym: SimpleNamespace) -> None:
|
||||
verifier = JwksVerifier(public_key=asym.pub, audience="arm-core")
|
||||
ok = jwt.encode(_payload(aud="arm-core"), asym.priv, algorithm=asym.alg)
|
||||
assert (await verifier.verify(ok)).email == EMAIL
|
||||
for bad in (_payload(aud="other"), _payload()):
|
||||
with pytest.raises(AuthError):
|
||||
await verifier.verify(jwt.encode(bad, asym.priv, algorithm=asym.alg))
|
||||
|
||||
|
||||
async def test_jwks_audience_in_token_ignored_when_not_configured(asym: SimpleNamespace) -> None:
|
||||
token = jwt.encode(_payload(aud="anything"), asym.priv, algorithm=asym.alg)
|
||||
assert (await JwksVerifier(public_key=asym.pub).verify(token)).email == EMAIL
|
||||
|
||||
|
||||
async def test_jwks_custom_email_claim(asym: SimpleNamespace) -> None:
|
||||
verifier = JwksVerifier(public_key=asym.pub, email_claim="preferred_username")
|
||||
token = jwt.encode(_payload(preferred_username="ivan@rshb.ru"), asym.priv, algorithm=asym.alg)
|
||||
assert (await verifier.verify(token)).email == "ivan@rshb.ru"
|
||||
# claim «email» при настроенном другом имени не подходит
|
||||
with pytest.raises(AuthError, match="нет email"):
|
||||
await verifier.verify(jwt.encode(_payload(), asym.priv, algorithm=asym.alg))
|
||||
|
||||
|
||||
async def test_jwks_alg_none(asym: SimpleNamespace) -> None:
|
||||
with pytest.raises(AuthError):
|
||||
await JwksVerifier(public_key=asym.pub).verify(_unsigned(_payload()))
|
||||
|
||||
|
||||
async def test_jwks_key_confusion_hs256_signed_with_public_key(asym: SimpleNamespace) -> None:
|
||||
"""Атака подмены алгоритма: HS256, подписанный байтами публичного ключа как секретом, не принимается."""
|
||||
for secret in (asym.pub.encode(), asym.pub.strip().encode()):
|
||||
with pytest.raises(AuthError):
|
||||
await JwksVerifier(public_key=asym.pub).verify(_forge_hs(_payload(), secret))
|
||||
|
||||
|
||||
async def test_jwks_rejects_dev_token(asym: SimpleNamespace) -> None:
|
||||
token = jwt.encode(_payload(), DEV_SECRET, algorithm="HS256")
|
||||
with pytest.raises(AuthError):
|
||||
await JwksVerifier(public_key=asym.pub).verify(token)
|
||||
|
||||
|
||||
async def test_jwks_rs256_key_does_not_verify_es256_token(
|
||||
rsa_key: rsa.RSAPrivateKey, ec_key: ec.EllipticCurvePrivateKey
|
||||
) -> None:
|
||||
"""Алгоритм выводится из типа ключа: RSA-ключ принимает только RS256, EC-ключ — только ES256."""
|
||||
es_token = jwt.encode(_payload(), _pem_private(ec_key), algorithm="ES256")
|
||||
rs_token = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256")
|
||||
with pytest.raises(AuthError):
|
||||
await JwksVerifier(public_key=_pem_public(rsa_key)).verify(es_token)
|
||||
with pytest.raises(AuthError):
|
||||
await JwksVerifier(public_key=_pem_public(ec_key)).verify(rs_token)
|
||||
|
||||
|
||||
def test_jwks_requires_a_key_source() -> None:
|
||||
with pytest.raises(ValueError, match="AUTH_JWKS_URL|ключ"):
|
||||
JwksVerifier()
|
||||
|
||||
|
||||
def test_jwks_bad_pem() -> None:
|
||||
with pytest.raises(ValueError, match="PEM"):
|
||||
JwksVerifier(public_key="not a pem")
|
||||
|
||||
|
||||
def test_jwks_rejects_both_sources(rsa_key: rsa.RSAPrivateKey) -> None:
|
||||
with pytest.raises(ValueError, match="одновременно"):
|
||||
JwksVerifier(public_key=_pem_public(rsa_key), jwks_url="http://127.0.0.1:1/jwks")
|
||||
|
||||
|
||||
# --- JwksVerifier (JWKS: заглушка клиента и реальный PyJWKClient через локальный HTTP-сервер) --------------------
|
||||
|
||||
|
||||
class _FakeJwkClient:
|
||||
def __init__(self, key: Any = None, error: Exception | None = None) -> None:
|
||||
self._key, self._error, self.calls = key, error, 0
|
||||
|
||||
def get_signing_key_from_jwt(self, token: str) -> Any:
|
||||
self.calls += 1
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return SimpleNamespace(key=self._key)
|
||||
|
||||
|
||||
async def test_jwks_client_valid(rsa_key: rsa.RSAPrivateKey) -> None:
|
||||
client = _FakeJwkClient(key=rsa_key.public_key())
|
||||
verifier = JwksVerifier(jwk_client=client)
|
||||
token = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256")
|
||||
assert (await verifier.verify(token)).email == EMAIL
|
||||
assert client.calls == 1
|
||||
|
||||
|
||||
async def test_jwks_client_foreign_key(rsa_key: rsa.RSAPrivateKey, other_rsa_key: rsa.RSAPrivateKey) -> None:
|
||||
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key()))
|
||||
token = jwt.encode(_payload(), _pem_private(other_rsa_key), algorithm="RS256")
|
||||
with pytest.raises(TokenSignatureError):
|
||||
await verifier.verify(token)
|
||||
|
||||
|
||||
async def test_jwks_client_es256(ec_key: ec.EllipticCurvePrivateKey) -> None:
|
||||
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=ec_key.public_key()))
|
||||
token = jwt.encode(_payload(), _pem_private(ec_key), algorithm="ES256")
|
||||
assert (await verifier.verify(token)).email == EMAIL
|
||||
|
||||
|
||||
async def test_jwks_client_key_confusion(rsa_key: rsa.RSAPrivateKey) -> None:
|
||||
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key()))
|
||||
with pytest.raises(AuthError):
|
||||
await verifier.verify(_forge_hs(_payload(), _pem_public(rsa_key).encode()))
|
||||
|
||||
|
||||
async def test_jwks_client_failure_is_auth_error_without_internals(log_messages: list[str]) -> None:
|
||||
secret_detail = "connection to http://internal-host:8443/keys refused"
|
||||
verifier = JwksVerifier(jwk_client=_FakeJwkClient(error=PyJWKClientConnectionError(secret_detail)))
|
||||
token = jwt.encode(_payload(), DEV_SECRET, algorithm="HS256")
|
||||
with pytest.raises(AuthError) as exc:
|
||||
await verifier.verify(token)
|
||||
assert "internal-host" not in str(exc.value)
|
||||
assert "ключ" in str(exc.value)
|
||||
assert any("internal-host" in m for m in log_messages) # деталь — только в лог
|
||||
|
||||
|
||||
class _JwksHandler(BaseHTTPRequestHandler):
|
||||
body = b""
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(self.body)
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - сигнатура базового класса
|
||||
return
|
||||
|
||||
|
||||
async def test_jwks_real_pyjwkclient_over_loopback(rsa_key: rsa.RSAPrivateKey) -> None:
|
||||
"""Реальный PyJWKClient: выбор ключа по kid из JWKS, отдаваемого локальным сервером (только loopback)."""
|
||||
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(rsa_key.public_key()))
|
||||
jwk.update(kid="key-1", use="sig", alg="RS256")
|
||||
_JwksHandler.body = json.dumps({"keys": [jwk]}).encode()
|
||||
server = HTTPServer(("127.0.0.1", 0), _JwksHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
url = f"http://127.0.0.1:{server.server_port}/jwks"
|
||||
verifier = JwksVerifier(jwks_url=url)
|
||||
good = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "key-1"})
|
||||
assert (await verifier.verify(good)).email == EMAIL
|
||||
unknown_kid = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "nope"})
|
||||
with pytest.raises(AuthError, match="ключ"):
|
||||
await verifier.verify(unknown_kid)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
# --- Фабрика --------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _settings(monkeypatch: pytest.MonkeyPatch, **auth: Any) -> Settings:
|
||||
"""Settings без env-файла и без AUTH_*/DEBUG из окружения процесса; обязательные OPENBAO__* — заглушки."""
|
||||
for key in list(os.environ):
|
||||
if key.startswith(("OPENBAO__", "AUTH_")) or key == "DEBUG":
|
||||
monkeypatch.delenv(key)
|
||||
required = {
|
||||
"OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d",
|
||||
"OPENBAO__MINIO__MINIO_URL": "localhost:9000",
|
||||
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login",
|
||||
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password",
|
||||
"OPENBAO__MINIO__MINIO_BUCKET": "bucket",
|
||||
"OPENBAO__NATS__NATS_URL": "nats://localhost:4222",
|
||||
"OPENBAO__SMB__SMB_HOST": "host",
|
||||
"OPENBAO__SMB__SMB_PATH": "path",
|
||||
"OPENBAO__SMB__SMB_LOGIN": "login",
|
||||
"OPENBAO__SMB__SMB_PASSWORD": "password",
|
||||
}
|
||||
for key, value in required.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
return Settings(_env_file=None, **auth) # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_factory_dev_off_and_no_jwks_gives_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = _settings(monkeypatch, AUTH_DEV_ISSUER=False, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=True)
|
||||
assert build_verifier(s) is None
|
||||
|
||||
|
||||
def test_factory_dev_flag_without_debug_gives_no_dev(monkeypatch: pytest.MonkeyPatch, log_messages: list[str]) -> None:
|
||||
s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=False)
|
||||
assert build_verifier(s) is None
|
||||
assert any("AUTH_DEV_ISSUER=true проигнорирован" in m for m in log_messages)
|
||||
|
||||
|
||||
def test_factory_dev_flag_without_debug_falls_back_to_jwks(
|
||||
monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey
|
||||
) -> None:
|
||||
s = _settings(
|
||||
monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=False, AUTH_PUBLIC_KEY=_pem_public(rsa_key)
|
||||
)
|
||||
verifier = build_verifier(s)
|
||||
assert isinstance(verifier, JwksVerifier)
|
||||
assert not isinstance(verifier, DevHs256Verifier)
|
||||
|
||||
|
||||
def test_factory_both_flags_gives_dev(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=True, AUTH_EMAIL_CLAIM="mail")
|
||||
verifier = build_verifier(s)
|
||||
assert isinstance(verifier, DevHs256Verifier)
|
||||
token = verifier.issue_token(EMAIL)
|
||||
assert jwt.decode(token, options={"verify_signature": False})["mail"] == EMAIL
|
||||
|
||||
|
||||
def test_factory_dev_wins_over_jwks_when_both_flags(
|
||||
monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey
|
||||
) -> None:
|
||||
s = _settings(
|
||||
monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=True, AUTH_PUBLIC_KEY=_pem_public(rsa_key)
|
||||
)
|
||||
assert isinstance(build_verifier(s), DevHs256Verifier)
|
||||
|
||||
|
||||
def test_factory_dev_flags_without_secret_is_config_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, DEBUG=True)
|
||||
with pytest.raises(RuntimeError, match="AUTH_DEV_SECRET"):
|
||||
build_verifier(s)
|
||||
|
||||
|
||||
def test_factory_public_key_gives_jwks(monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey) -> None:
|
||||
s = _settings(monkeypatch, AUTH_PUBLIC_KEY=_pem_public(rsa_key))
|
||||
assert isinstance(build_verifier(s), JwksVerifier)
|
||||
|
||||
|
||||
def test_factory_jwks_url_gives_jwks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = _settings(monkeypatch, AUTH_JWKS_URL="https://sso.example/.well-known/jwks.json")
|
||||
assert isinstance(build_verifier(s), JwksVerifier)
|
||||
|
||||
|
||||
async def test_factory_passes_issuer_audience_and_email_claim(
|
||||
monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey
|
||||
) -> None:
|
||||
s = _settings(
|
||||
monkeypatch,
|
||||
AUTH_PUBLIC_KEY=_pem_public(rsa_key),
|
||||
AUTH_ISSUER="https://sso.raisa",
|
||||
AUTH_AUDIENCE="arm-core",
|
||||
AUTH_EMAIL_CLAIM="mail",
|
||||
)
|
||||
verifier = build_verifier(s)
|
||||
assert verifier is not None
|
||||
priv = _pem_private(rsa_key)
|
||||
ok = jwt.encode(_payload(iss="https://sso.raisa", aud="arm-core", mail=EMAIL), priv, algorithm="RS256")
|
||||
assert (await verifier.verify(ok)).email == EMAIL
|
||||
with pytest.raises(AuthError):
|
||||
await verifier.verify(jwt.encode(_payload(mail=EMAIL), priv, algorithm="RS256"))
|
||||
|
||||
|
||||
async def test_dev_token_rejected_when_debug_off(monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey) -> None:
|
||||
"""Токен, подписанный dev-секретом, не принимается ни пустой конфигурацией, ни JWKS-проверяющим (DEBUG=false)."""
|
||||
token = DevHs256Verifier(DEV_SECRET).issue_token(EMAIL)
|
||||
prod = _settings(
|
||||
monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=False, AUTH_PUBLIC_KEY=_pem_public(rsa_key)
|
||||
)
|
||||
verifier = build_verifier(prod)
|
||||
assert verifier is not None
|
||||
with pytest.raises(AuthError):
|
||||
await verifier.verify(token)
|
||||
Loading…
x
Reference in New Issue
Block a user