From 9d17afc9937cb50bcc248d2b5fc3120c8c61ee9f Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Thu, 3 Sep 2026 10:45:36 +0300 Subject: [PATCH] Fix uncorrect token --- api/src/api/v1/auth.py | 15 ++++--- api/src/api/v1/deps.py | 6 +-- api/src/core/config.py | 6 ++- api/src/core/security.py | 67 ++++++++++++++++++++--------- api/src/services/auth_service.py | 5 +-- api/tests/unit/test_auth_service.py | 42 ++++++++++++++++++ 6 files changed, 108 insertions(+), 33 deletions(-) diff --git a/api/src/api/v1/auth.py b/api/src/api/v1/auth.py index f78f020..a5b8d2e 100644 --- a/api/src/api/v1/auth.py +++ b/api/src/api/v1/auth.py @@ -2,7 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from src.core.config import settings -from src.core.security import decode_keycloak_token +from src.core.security import ( + decode_keycloak_token, + email_from_keycloak_payload, + normalize_bearer_token, +) from src.db.session import get_db from src.domain.schemas import LoginRequest, RefreshRequest, Token from src.services.auth_service import AuthService @@ -62,27 +66,28 @@ else: login_data: LoginRequest, db: AsyncSession = Depends(get_db), ): - if not login_data.token: + raw_token = normalize_bearer_token(login_data.token) + if not raw_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=_PROD_TOKEN_REQUIRED, headers={"WWW-Authenticate": "Bearer"}, ) - payload = decode_keycloak_token(login_data.token) + payload = decode_keycloak_token(raw_token) if payload is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=_PROD_TOKEN_INVALID, headers={"WWW-Authenticate": "Bearer"}, ) - if not payload.get("email"): + if not email_from_keycloak_payload(payload): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=_PROD_EMAIL_MISSING, headers={"WWW-Authenticate": "Bearer"}, ) auth_service = AuthService(db) - token = await auth_service.authenticate_with_keycloak_token(login_data.token) + token = await auth_service.authenticate_with_keycloak_token(raw_token) if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/api/src/api/v1/deps.py b/api/src/api/v1/deps.py index e7ba8bf..826e4ea 100644 --- a/api/src/api/v1/deps.py +++ b/api/src/api/v1/deps.py @@ -3,7 +3,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession from src.core.config import settings -from src.core.security import decode_keycloak_token, verify_token +from src.core.security import decode_keycloak_token, email_from_keycloak_payload, verify_token from src.db.session import get_db from src.db.models.app_user import AppUser from src.db.models.role import UserRoleEnum @@ -58,9 +58,7 @@ async def resolve_user_from_access_token( except HTTPException: return None payload = decode_keycloak_token(token) - if payload is None: - return None - email = payload.get("email") + email = email_from_keycloak_payload(payload) if not email: return None user_repo = UserRepository(db) diff --git a/api/src/core/config.py b/api/src/core/config.py index dc8e269..8c974c4 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -78,7 +78,11 @@ class Settings(BaseSettings): JWKS_URL: str = Field( default="https://keycloak.raisa.go.rshbank.ru/realms/datalab/protocol/openid-connect/certs", description="URL для получения JWKS", - alias="JWKS_URL", + validation_alias=AliasChoices( + "JWKS_URL", + "OPENBAO__AUTH__JWKS_URL", + f"{prefix}__JWKS_URL", + ), ) APP_NAMESPACE: str = Field( default="dfip", diff --git a/api/src/core/security.py b/api/src/core/security.py index db9218b..b08feec 100644 --- a/api/src/core/security.py +++ b/api/src/core/security.py @@ -1,16 +1,23 @@ from datetime import datetime, timedelta, timezone +import logging from typing import Optional import httpx from argon2 import PasswordHasher from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError -from jose import JWTError, jwt +from jose import JOSEError, JWTError, jwt from src.core.config import settings pwd_context = PasswordHasher() +logger = logging.getLogger(__name__) _KEYCLOAK_ALGORITHMS = ["RS256"] +_KEYCLOAK_DECODE_OPTIONS = { + "verify_aud": False, + "verify_at_hash": False, + "leeway": 60, +} _jwks_cache: dict | None = None @@ -64,6 +71,25 @@ def clear_jwks_cache() -> None: _jwks_cache = None +def normalize_bearer_token(token: str | None) -> str | None: + if not token: + return None + value = token.strip() + if value.lower().startswith("bearer "): + value = value[7:].strip() + return value or None + + +def email_from_keycloak_payload(payload: dict | None) -> str | None: + if not payload: + return None + for key in ("email", "preferred_username", "upn"): + value = payload.get(key) + if isinstance(value, str) and "@" in value: + return value + return None + + def _get_jwks(*, force_refresh: bool = False) -> dict: global _jwks_cache if _jwks_cache is None or force_refresh: @@ -73,25 +99,26 @@ def _get_jwks(*, force_refresh: bool = False) -> dict: return _jwks_cache +def _decode_with_jwks(token: str, jwks: dict) -> dict: + return jwt.decode( + token, + jwks, + algorithms=_KEYCLOAK_ALGORITHMS, + options=_KEYCLOAK_DECODE_OPTIONS, + ) + + def decode_keycloak_token(token: str) -> Optional[dict]: """Проверяет Keycloak JWT по JWKS и возвращает payload.""" - try: - header = jwt.get_unverified_header(token) - kid = header.get("kid") - jwks = _get_jwks() - keys = jwks.get("keys") or [] - key_data = next((item for item in keys if item.get("kid") == kid), None) - if key_data is None: - jwks = _get_jwks(force_refresh=True) - keys = jwks.get("keys") or [] - key_data = next((item for item in keys if item.get("kid") == kid), None) - if key_data is None: - return None - return jwt.decode( - token, - key_data, - algorithms=_KEYCLOAK_ALGORITHMS, - options={"verify_aud": False}, - ) - except (JWTError, httpx.HTTPError, ValueError, KeyError, StopIteration): + raw = normalize_bearer_token(token) + if raw is None: return None + try: + return _decode_with_jwks(raw, _get_jwks()) + except (JOSEError, httpx.HTTPError, ValueError, TypeError, KeyError) as exc: + logger.warning("Keycloak JWT rejected: %s", exc) + try: + return _decode_with_jwks(raw, _get_jwks(force_refresh=True)) + except (JOSEError, httpx.HTTPError, ValueError, TypeError, KeyError) as retry_exc: + logger.warning("Keycloak JWT rejected after JWKS refresh: %s", retry_exc) + return None diff --git a/api/src/services/auth_service.py b/api/src/services/auth_service.py index 82a80f1..1a1b06c 100644 --- a/api/src/services/auth_service.py +++ b/api/src/services/auth_service.py @@ -6,6 +6,7 @@ from src.core.security import ( create_access_token, create_refresh_token, decode_keycloak_token, + email_from_keycloak_payload, verify_token, ) from src.domain.schemas import Token @@ -43,9 +44,7 @@ class AuthService: async def authenticate_with_keycloak_token(self, token: str) -> Optional[Token]: payload = decode_keycloak_token(token) - if payload is None: - return None - email = payload.get("email") + email = email_from_keycloak_payload(payload) if not email: return None user = await self.user_repo.authenticate_via_email(email) diff --git a/api/tests/unit/test_auth_service.py b/api/tests/unit/test_auth_service.py index e62ac85..0c0a478 100644 --- a/api/tests/unit/test_auth_service.py +++ b/api/tests/unit/test_auth_service.py @@ -62,6 +62,48 @@ def test_decode_keycloak_token_accepts_valid_rs256(monkeypatch): assert payload["email"] == "admin@example.com" +def test_decode_keycloak_token_strips_bearer_and_ignores_at_hash(monkeypatch): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + numbers = private_key.public_key().public_numbers() + kid = "test-key" + jwks = { + "keys": [ + { + "kty": "RSA", + "kid": kid, + "use": "sig", + "alg": "RS256", + "n": _b64u_int(numbers.n), + "e": _b64u_int(numbers.e), + } + ] + } + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + token = jose_jwt.encode( + { + "preferred_username": "admin@example.com", + "at_hash": "not-a-real-hash", + "sub": "admin", + }, + pem, + algorithm="RS256", + headers={"kid": kid}, + ) + clear_jwks_cache() + monkeypatch.setattr("src.core.security._get_jwks", lambda **_kwargs: jwks) + + payload = decode_keycloak_token(f"Bearer {token}") + + assert payload is not None + from src.core.security import email_from_keycloak_payload + + assert email_from_keycloak_payload(payload) == "admin@example.com" + + @pytest.mark.asyncio async def test_authenticate_user_returns_token(monkeypatch): db = AsyncMock()