Compare commits
No commits in common. "ae6640796d88e485f2d206d8f9aed474375e1dbb" and "534aeb9d50761972d2d23d7a80e01ad41ed7c91b" have entirely different histories.
ae6640796d
...
534aeb9d50
@ -2,11 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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
|
||||||
decode_keycloak_token,
|
|
||||||
email_from_keycloak_payload,
|
|
||||||
normalize_bearer_token,
|
|
||||||
)
|
|
||||||
from src.db.session import get_db
|
from src.db.session import get_db
|
||||||
from src.domain.schemas import LoginRequest, RefreshRequest, Token
|
from src.domain.schemas import LoginRequest, RefreshRequest, Token
|
||||||
from src.services.auth_service import AuthService
|
from src.services.auth_service import AuthService
|
||||||
@ -66,28 +62,27 @@ else:
|
|||||||
login_data: LoginRequest,
|
login_data: LoginRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
raw_token = normalize_bearer_token(login_data.token)
|
if not login_data.token:
|
||||||
if not raw_token:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail=_PROD_TOKEN_REQUIRED,
|
detail=_PROD_TOKEN_REQUIRED,
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
payload = decode_keycloak_token(raw_token)
|
payload = decode_keycloak_token(login_data.token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail=_PROD_TOKEN_INVALID,
|
detail=_PROD_TOKEN_INVALID,
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
if not email_from_keycloak_payload(payload):
|
if not payload.get("email"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail=_PROD_EMAIL_MISSING,
|
detail=_PROD_EMAIL_MISSING,
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
auth_service = AuthService(db)
|
auth_service = AuthService(db)
|
||||||
token = await auth_service.authenticate_with_keycloak_token(raw_token)
|
token = await auth_service.authenticate_with_keycloak_token(login_data.token)
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.core.security import decode_keycloak_token, email_from_keycloak_payload, verify_token
|
from src.core.security import decode_keycloak_token, verify_token
|
||||||
from src.db.session import get_db
|
from src.db.session import get_db
|
||||||
from src.db.models.app_user import AppUser
|
from src.db.models.app_user import AppUser
|
||||||
from src.db.models.role import UserRoleEnum
|
from src.db.models.role import UserRoleEnum
|
||||||
@ -58,7 +58,9 @@ async def resolve_user_from_access_token(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
return None
|
return None
|
||||||
payload = decode_keycloak_token(token)
|
payload = decode_keycloak_token(token)
|
||||||
email = email_from_keycloak_payload(payload)
|
if payload is None:
|
||||||
|
return None
|
||||||
|
email = payload.get("email")
|
||||||
if not email:
|
if not email:
|
||||||
return None
|
return None
|
||||||
user_repo = UserRepository(db)
|
user_repo = UserRepository(db)
|
||||||
|
|||||||
@ -78,11 +78,7 @@ class Settings(BaseSettings):
|
|||||||
JWKS_URL: str = Field(
|
JWKS_URL: str = Field(
|
||||||
default="https://keycloak.raisa.go.rshbank.ru/realms/datalab/protocol/openid-connect/certs",
|
default="https://keycloak.raisa.go.rshbank.ru/realms/datalab/protocol/openid-connect/certs",
|
||||||
description="URL для получения JWKS",
|
description="URL для получения JWKS",
|
||||||
validation_alias=AliasChoices(
|
alias="JWKS_URL",
|
||||||
"JWKS_URL",
|
|
||||||
"OPENBAO__AUTH__JWKS_URL",
|
|
||||||
f"{prefix}__JWKS_URL",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
APP_NAMESPACE: str = Field(
|
APP_NAMESPACE: str = Field(
|
||||||
default="dfip",
|
default="dfip",
|
||||||
|
|||||||
@ -1,23 +1,16 @@
|
|||||||
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
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||||
from jose import JOSEError, JWTError, jwt
|
from jose import 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 = {
|
|
||||||
"verify_aud": False,
|
|
||||||
"verify_at_hash": False,
|
|
||||||
"leeway": 60,
|
|
||||||
}
|
|
||||||
_jwks_cache: dict | None = None
|
_jwks_cache: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -71,25 +64,6 @@ def clear_jwks_cache() -> None:
|
|||||||
_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:
|
def _get_jwks(*, force_refresh: bool = False) -> dict:
|
||||||
global _jwks_cache
|
global _jwks_cache
|
||||||
if _jwks_cache is None or force_refresh:
|
if _jwks_cache is None or force_refresh:
|
||||||
@ -99,26 +73,25 @@ def _get_jwks(*, force_refresh: bool = False) -> dict:
|
|||||||
return _jwks_cache
|
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]:
|
def decode_keycloak_token(token: str) -> Optional[dict]:
|
||||||
"""Проверяет Keycloak JWT по JWKS и возвращает payload."""
|
"""Проверяет Keycloak JWT по JWKS и возвращает payload."""
|
||||||
raw = normalize_bearer_token(token)
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
return _decode_with_jwks(raw, _get_jwks())
|
header = jwt.get_unverified_header(token)
|
||||||
except (JOSEError, httpx.HTTPError, ValueError, TypeError, KeyError) as exc:
|
kid = header.get("kid")
|
||||||
logger.warning("Keycloak JWT rejected: %s", exc)
|
jwks = _get_jwks()
|
||||||
try:
|
keys = jwks.get("keys") or []
|
||||||
return _decode_with_jwks(raw, _get_jwks(force_refresh=True))
|
key_data = next((item for item in keys if item.get("kid") == kid), None)
|
||||||
except (JOSEError, httpx.HTTPError, ValueError, TypeError, KeyError) as retry_exc:
|
if key_data is None:
|
||||||
logger.warning("Keycloak JWT rejected after JWKS refresh: %s", retry_exc)
|
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 None
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
key_data,
|
||||||
|
algorithms=_KEYCLOAK_ALGORITHMS,
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
|
except (JWTError, httpx.HTTPError, ValueError, KeyError, StopIteration):
|
||||||
|
return None
|
||||||
|
|||||||
@ -6,7 +6,6 @@ from src.core.security import (
|
|||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
decode_keycloak_token,
|
decode_keycloak_token,
|
||||||
email_from_keycloak_payload,
|
|
||||||
verify_token,
|
verify_token,
|
||||||
)
|
)
|
||||||
from src.domain.schemas import Token
|
from src.domain.schemas import Token
|
||||||
@ -44,7 +43,9 @@ class AuthService:
|
|||||||
|
|
||||||
async def authenticate_with_keycloak_token(self, token: str) -> Optional[Token]:
|
async def authenticate_with_keycloak_token(self, token: str) -> Optional[Token]:
|
||||||
payload = decode_keycloak_token(token)
|
payload = decode_keycloak_token(token)
|
||||||
email = email_from_keycloak_payload(payload)
|
if payload is None:
|
||||||
|
return None
|
||||||
|
email = payload.get("email")
|
||||||
if not email:
|
if not email:
|
||||||
return None
|
return None
|
||||||
user = await self.user_repo.authenticate_via_email(email)
|
user = await self.user_repo.authenticate_via_email(email)
|
||||||
|
|||||||
@ -62,48 +62,6 @@ def test_decode_keycloak_token_accepts_valid_rs256(monkeypatch):
|
|||||||
assert payload["email"] == "admin@example.com"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_authenticate_user_returns_token(monkeypatch):
|
async def test_authenticate_user_returns_token(monkeypatch):
|
||||||
db = AsyncMock()
|
db = AsyncMock()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user