Compare commits

..

2 Commits

Author SHA1 Message Date
Raykov-MS
ae6640796d Merge branch 'auth' of ssh://git.aepif.ru:2222/RSHB/DFiP_Budget_planing into auth 2026-09-03 10:45:45 +03:00
Raykov-MS
9d17afc993 Fix uncorrect token 2026-09-03 10:45:36 +03:00
6 changed files with 108 additions and 33 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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()