Fix uncorrect token
This commit is contained in:
parent
57290a8d03
commit
9d17afc993
@ -2,7 +2,11 @@ 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 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.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
|
||||||
@ -62,27 +66,28 @@ else:
|
|||||||
login_data: LoginRequest,
|
login_data: LoginRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if not login_data.token:
|
raw_token = normalize_bearer_token(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(login_data.token)
|
payload = decode_keycloak_token(raw_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 payload.get("email"):
|
if not email_from_keycloak_payload(payload):
|
||||||
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(login_data.token)
|
token = await auth_service.authenticate_with_keycloak_token(raw_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, 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.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,9 +58,7 @@ 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)
|
||||||
if payload is None:
|
email = email_from_keycloak_payload(payload)
|
||||||
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,7 +78,11 @@ 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",
|
||||||
alias="JWKS_URL",
|
validation_alias=AliasChoices(
|
||||||
|
"JWKS_URL",
|
||||||
|
"OPENBAO__AUTH__JWKS_URL",
|
||||||
|
f"{prefix}__JWKS_URL",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
APP_NAMESPACE: str = Field(
|
APP_NAMESPACE: str = Field(
|
||||||
default="dfip",
|
default="dfip",
|
||||||
|
|||||||
@ -1,16 +1,23 @@
|
|||||||
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 JWTError, jwt
|
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 = {
|
||||||
|
"verify_aud": False,
|
||||||
|
"verify_at_hash": False,
|
||||||
|
"leeway": 60,
|
||||||
|
}
|
||||||
_jwks_cache: dict | None = None
|
_jwks_cache: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -64,6 +71,25 @@ 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:
|
||||||
@ -73,25 +99,26 @@ 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."""
|
||||||
try:
|
raw = normalize_bearer_token(token)
|
||||||
header = jwt.get_unverified_header(token)
|
if raw is None:
|
||||||
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):
|
|
||||||
return 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_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
|
||||||
@ -43,9 +44,7 @@ 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)
|
||||||
if payload is None:
|
email = email_from_keycloak_payload(payload)
|
||||||
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,6 +62,48 @@ 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