This commit is contained in:
Raykov-MS 2026-09-03 12:15:28 +03:00
parent fa4cfc9db8
commit 1d341162aa
4 changed files with 16 additions and 48 deletions

View File

@ -3,8 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.core.config import settings from src.core.config import settings
from src.core.security import ( from src.core.security import (
decode_keycloak_token,
email_from_keycloak_payload, email_from_keycloak_payload,
inspect_keycloak_token,
normalize_bearer_token, normalize_bearer_token,
) )
from src.db.session import get_db from src.db.session import get_db
@ -73,14 +73,11 @@ else:
detail=_PROD_TOKEN_REQUIRED, detail=_PROD_TOKEN_REQUIRED,
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
payload, reason = inspect_keycloak_token(raw_token) payload = decode_keycloak_token(raw_token)
if payload is None: if payload is None:
detail = _PROD_TOKEN_INVALID
if reason:
detail = f"{_PROD_TOKEN_INVALID}: {reason}"
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail, detail=_PROD_TOKEN_INVALID,
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not email_from_keycloak_payload(payload): if not email_from_keycloak_payload(payload):

View File

@ -1,5 +1,4 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import logging
from typing import Optional from typing import Optional
import httpx import httpx
@ -10,7 +9,6 @@ from jose import JOSEError, JWTError, jwt
from src.core.config import settings from src.core.config import settings
pwd_context = PasswordHasher() pwd_context = PasswordHasher()
logger = logging.getLogger(__name__)
_KEYCLOAK_ALGORITHMS = ["RS256"] _KEYCLOAK_ALGORITHMS = ["RS256"]
_KEYCLOAK_DECODE_OPTIONS = { _KEYCLOAK_DECODE_OPTIONS = {
@ -112,33 +110,13 @@ def _decode_with_jwks(token: str, jwks: dict) -> dict:
def decode_keycloak_token(token: str) -> Optional[dict]: def decode_keycloak_token(token: str) -> Optional[dict]:
"""Проверяет Keycloak JWT по JWKS и возвращает payload.""" """Проверяет Keycloak JWT по JWKS и возвращает payload."""
payload, _reason = inspect_keycloak_token(token)
return payload
def inspect_keycloak_token(token: str) -> tuple[Optional[dict], str | None]:
"""Как decode_keycloak_token, плюс текст причины отказа (для отладки логина)."""
raw = normalize_bearer_token(token) raw = normalize_bearer_token(token)
if raw is None: if raw is None:
return None, "empty token" return None
alg = None
kid = None
try: try:
header = jwt.get_unverified_header(raw) return _decode_with_jwks(raw, _get_jwks())
alg = header.get("alg") except (JOSEError, httpx.HTTPError, ValueError, TypeError, KeyError):
kid = header.get("kid")
except Exception as exc:
return None, f"malformed jwt: {exc}"
try: try:
return _decode_with_jwks(raw, _get_jwks()), None return _decode_with_jwks(raw, _get_jwks(force_refresh=True))
except (JOSEError, httpx.HTTPError, ValueError, TypeError, KeyError) as exc: except (JOSEError, httpx.HTTPError, ValueError, TypeError, KeyError):
logger.warning("Keycloak JWT rejected: %s", exc) return None
try:
return _decode_with_jwks(raw, _get_jwks(force_refresh=True)), None
except (JOSEError, httpx.HTTPError, ValueError, TypeError, KeyError) as retry_exc:
logger.warning("Keycloak JWT rejected after JWKS refresh: %s", retry_exc)
reason = (
f"{retry_exc.__class__.__name__}: {retry_exc}; "
f"alg={alg}; kid={kid}; jwks_url={settings.JWKS_URL}"
)
return None, reason

View File

@ -72,8 +72,8 @@ def test_auth_login_non_debug_branch_success_and_unauthorized(monkeypatch):
) )
m.setattr( m.setattr(
auth_module, auth_module,
"inspect_keycloak_token", "decode_keycloak_token",
lambda _token: ({"email": "prod-user@example.com"}, None), lambda _token: {"email": "prod-user@example.com"},
) )
client = TestClient(app) client = TestClient(app)
@ -144,8 +144,8 @@ def test_auth_login_non_debug_rejects_missing_email(monkeypatch):
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr( m.setattr(
auth_module, auth_module,
"inspect_keycloak_token", "decode_keycloak_token",
lambda _token: ({"sub": "x"}, None), lambda _token: {"sub": "x"},
) )
client = TestClient(app) client = TestClient(app)
response = client.post("/auth/login", json={"token": "kc-token"}) response = client.post("/auth/login", json={"token": "kc-token"})
@ -172,16 +172,13 @@ def test_auth_login_non_debug_rejects_invalid_token(monkeypatch):
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr( m.setattr(
auth_module, auth_module,
"inspect_keycloak_token", "decode_keycloak_token",
lambda _token: (None, "Signature verification failed; alg=RS256"), lambda _token: None,
) )
client = TestClient(app) client = TestClient(app)
response = client.post("/auth/login", json={"token": "bad"}) response = client.post("/auth/login", json={"token": "bad"})
assert response.status_code == 401 assert response.status_code == 401
message = response.json()["message"] assert response.json()["message"] == "Недействительный токен Keycloak"
assert message.startswith("Недействительный токен Keycloak")
assert "Signature verification failed" in message
assert "alg=RS256" in message
finally: finally:
_restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous) _restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous)

View File

@ -10,7 +10,6 @@ from src.core.security import (
clear_jwks_cache, clear_jwks_cache,
create_refresh_token, create_refresh_token,
decode_keycloak_token, decode_keycloak_token,
inspect_keycloak_token,
verify_token, verify_token,
) )
from src.services.auth_service import AuthService from src.services.auth_service import AuthService
@ -25,9 +24,6 @@ def test_decode_keycloak_token_rejects_garbage(monkeypatch):
clear_jwks_cache() clear_jwks_cache()
monkeypatch.setattr("src.core.security._get_jwks", lambda **_kwargs: {"keys": []}) monkeypatch.setattr("src.core.security._get_jwks", lambda **_kwargs: {"keys": []})
assert decode_keycloak_token("not-a-jwt") is None assert decode_keycloak_token("not-a-jwt") is None
_payload, reason = inspect_keycloak_token("not-a-jwt")
assert _payload is None
assert reason
def test_decode_keycloak_token_accepts_valid_rs256(monkeypatch): def test_decode_keycloak_token_accepts_valid_rs256(monkeypatch):