Compare commits
2 Commits
534aeb9d50
...
ae6640796d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae6640796d | ||
|
|
9d17afc993 |
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user