auth repair

This commit is contained in:
Raykov-MS 2026-09-02 12:30:57 +03:00
parent 5eb73ddb37
commit 3dcc2f903e
6 changed files with 486 additions and 126 deletions

View File

@ -6,66 +6,89 @@ 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
# if not settings.DEBUG: if not settings.DEBUG:
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency from raisa_fastapi_protected_api import UserInfo, get_user_dependency
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
_PROD_USER_NOT_FOUND = "Пользователь не найден в локальной базе данных"
_PROD_EMAIL_MISSING = (
"Не удалось идентифицировать пользователя: в токене отсутствует поле email"
)
# if settings.DEBUG:
@router.post("/login", response_model=Token) async def _login_via_keycloak_email(db: AsyncSession, user: "UserInfo") -> Token:
async def login( if not getattr(user, "email", None):
login_data: LoginRequest, raise HTTPException(
db: AsyncSession = Depends(get_db), status_code=status.HTTP_401_UNAUTHORIZED,
): detail=_PROD_EMAIL_MISSING,
headers={"WWW-Authenticate": "Bearer"},
)
auth_service = AuthService(db) auth_service = AuthService(db)
token = await auth_service.authenticate_user(login_data.username, login_data.password) token = await auth_service.authenticate_user_via_email(user.email)
if not token: if not token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль", detail=_PROD_USER_NOT_FOUND,
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return token return token
# else:
# @router.post("/login", response_model=Token) if settings.DEBUG:
# async def login(
# login_data: LoginRequest,
# db: AsyncSession = Depends(get_db),
# user: UserInfo = Depends(get_user_dependency),
# ):
# auth_service = AuthService(db)
# token = await auth_service.authenticate_user_via_email(user.email)
# if not token:
# raise HTTPException(
# status_code=status.HTTP_401_UNAUTHORIZED,
# detail="Неверное имя пользователя или пароль",
# headers={"WWW-Authenticate": "Bearer"},
# )
# return token
@router.post("/login", response_model=Token)
@router.post("/login-form", response_model=Token) async def login(
async def login_form(request: Request, db: AsyncSession = Depends(get_db)): login_data: LoginRequest,
form = await request.form() db: AsyncSession = Depends(get_db),
username = form.get("username") ):
password = form.get("password") auth_service = AuthService(db)
if not username or not password: token = await auth_service.authenticate_user(
raise HTTPException( login_data.username, login_data.password
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="username/password required",
) )
auth_service = AuthService(db) if not token:
token = await auth_service.authenticate_user(username, password) raise HTTPException(
if not token: status_code=status.HTTP_401_UNAUTHORIZED,
raise HTTPException( detail="Неверное имя пользователя или пароль",
status_code=status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Bearer"},
detail="Неверное имя пользователя или пароль", )
) return token
return token
@router.post("/login-form", response_model=Token)
async def login_form(request: Request, db: AsyncSession = Depends(get_db)):
form = await request.form()
username = form.get("username")
password = form.get("password")
if not username or not password:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="username/password required",
)
auth_service = AuthService(db)
token = await auth_service.authenticate_user(username, password)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль",
)
return token
else:
@router.post("/login", response_model=Token)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db),
user: UserInfo = Depends(get_user_dependency),
):
return await _login_via_keycloak_email(db, user)
@router.post("/login-form", response_model=Token)
async def login_form(
db: AsyncSession = Depends(get_db),
user: UserInfo = Depends(get_user_dependency),
):
return await _login_via_keycloak_email(db, user)
@router.post("/refresh", response_model=Token) @router.post("/refresh", response_model=Token)

View File

@ -12,8 +12,6 @@ from src.repository.user_repository import UserRepository
security = HTTPBearer() security = HTTPBearer()
# if settings.DEBUG:
async def get_user_by_token( async def get_user_by_token(
token: str, token: str,
db: AsyncSession, db: AsyncSession,
@ -46,28 +44,37 @@ async def get_user_by_token(
) )
return user return user
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> AppUser:
return await get_user_by_token(token=credentials.credentials, db=db)
# else: if settings.DEBUG:
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
# async def get_current_user( async def get_current_user(
# user: UserInfo = Depends(get_user_dependency), credentials: HTTPAuthorizationCredentials = Depends(security),
# db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
# ) -> AppUser: ) -> AppUser:
# user_repo = UserRepository(db) return await get_user_by_token(token=credentials.credentials, db=db)
# db_user = await user_repo.get_by_email(user.email)
# if db_user is None: else:
# raise HTTPException( from raisa_fastapi_protected_api import UserInfo, get_user_dependency
# status_code=status.HTTP_401_UNAUTHORIZED,
# detail="Пользователь не найден", async def get_current_user(
# headers={"WWW-Authenticate": "Bearer"}, user: UserInfo = Depends(get_user_dependency),
# ) db: AsyncSession = Depends(get_db),
# return db_user ) -> AppUser:
if not getattr(user, "email", None):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Не удалось идентифицировать пользователя: в токене отсутствует поле email",
headers={"WWW-Authenticate": "Bearer"},
)
user_repo = UserRepository(db)
db_user = await user_repo.get_by_email(user.email)
if db_user is None or not db_user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Пользователь не найден",
headers={"WWW-Authenticate": "Bearer"},
)
return db_user
async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser: async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:

View File

@ -25,15 +25,15 @@ from src.db.session import create_tables
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
# if not settings.DEBUG: if not settings.DEBUG:
# from raisa_fastapi_protected_api import ( from raisa_fastapi_protected_api import (
# AuthorizationMiddleware, AuthorizationMiddleware,
# OpenEndpoint, OpenEndpoint,
# ProtectedOAuthSettings, ProtectedOAuthSettings,
# ProtectedRolesSettings, ProtectedRolesSettings,
# ProtectedSettings, ProtectedSettings,
# SearchType, SearchType,
# ) )
_AUDIT_CLEANUP_LOCK_KEY = 21987431 _AUDIT_CLEANUP_LOCK_KEY = 21987431
@ -104,13 +104,13 @@ async def lifespan(app: FastAPI):
cleanup_task: asyncio.Task | None = None cleanup_task: asyncio.Task | None = None
if not settings.DEBUG: if not settings.DEBUG:
await create_tables() await create_tables()
# ProtectedSettings( ProtectedSettings(
# ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL), ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
# ProtectedRolesSettings( ProtectedRolesSettings(
# APP_NAMESPACE=settings.APP_NAMESPACE, APP_NAMESPACE=settings.APP_NAMESPACE,
# APP_NAME=settings.APP_NAME, APP_NAME=settings.APP_NAME,
# ), ),
# ) )
cleanup_task = asyncio.create_task(_audit_cleanup_loop()) cleanup_task = asyncio.create_task(_audit_cleanup_loop())
try: try:
yield yield
@ -185,41 +185,61 @@ async def readyz(response: Response):
# if not settings.DEBUG: if not settings.DEBUG:
# app.add_middleware( app.add_middleware(
# AuthorizationMiddleware, AuthorizationMiddleware,
# open_endpoints=[ open_endpoints=[
# OpenEndpoint(path="", type_search=SearchType.ABSOLUTE), OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE), OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE), OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE), OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE), OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
# OpenEndpoint( OpenEndpoint(
# path=settings.ROOT_PATH + "/healthcheck", path=settings.ROOT_PATH + "/healthcheck",
# type_search=SearchType.ABSOLUTE, type_search=SearchType.ABSOLUTE,
# ), ),
# OpenEndpoint(path="/healthz", type_search=SearchType.ABSOLUTE), OpenEndpoint(path="/healthcheck2", type_search=SearchType.ABSOLUTE),
# OpenEndpoint( OpenEndpoint(
# path=settings.ROOT_PATH + "/healthz", path=settings.ROOT_PATH + "/healthcheck2",
# type_search=SearchType.ABSOLUTE, type_search=SearchType.ABSOLUTE,
# ), ),
# OpenEndpoint(path="/readyz", type_search=SearchType.ABSOLUTE), OpenEndpoint(path="/healthz", type_search=SearchType.ABSOLUTE),
# OpenEndpoint( OpenEndpoint(
# path=settings.ROOT_PATH + "/readyz", path=settings.ROOT_PATH + "/healthz",
# type_search=SearchType.ABSOLUTE, type_search=SearchType.ABSOLUTE,
# ), ),
# OpenEndpoint(path="/openapi.json", type_search=SearchType.START), OpenEndpoint(path="/readyz", type_search=SearchType.ABSOLUTE),
# OpenEndpoint( OpenEndpoint(
# path=settings.ROOT_PATH + "/openapi.json", path=settings.ROOT_PATH + "/readyz",
# type_search=SearchType.START, type_search=SearchType.ABSOLUTE,
# ), ),
# OpenEndpoint(path="/docs", type_search=SearchType.START), OpenEndpoint(path="/static", type_search=SearchType.START),
# OpenEndpoint( OpenEndpoint(
# path=settings.ROOT_PATH + "/docs", path=settings.ROOT_PATH + "/static",
# type_search=SearchType.START, type_search=SearchType.START,
# ), ),
# ], OpenEndpoint(path="/back-static", type_search=SearchType.START),
# ) OpenEndpoint(
path=settings.ROOT_PATH + "/back-static",
type_search=SearchType.START,
),
OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
OpenEndpoint(
path=settings.ROOT_PATH + "/openapi.json",
type_search=SearchType.START,
),
OpenEndpoint(path="/docs", type_search=SearchType.START),
OpenEndpoint(
path=settings.ROOT_PATH + "/docs",
type_search=SearchType.START,
),
OpenEndpoint(path="/docs-local", type_search=SearchType.START),
OpenEndpoint(
path=settings.ROOT_PATH + "/docs-local",
type_search=SearchType.START,
),
],
)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@ -26,10 +26,10 @@ class AuthService:
async def authenticate_user_via_email(self, email: str) -> Optional[Token]: async def authenticate_user_via_email(self, email: str) -> Optional[Token]:
user = await self.user_repo.authenticate_via_email(email) user = await self.user_repo.authenticate_via_email(email)
if not user: if not user or not user.is_active:
return None return None
access_token = create_access_token(data={"sub": user.email}) access_token = create_access_token(data={"sub": user.username})
refresh_token = create_refresh_token(data={"sub": user.email}) refresh_token = create_refresh_token(data={"sub": user.username})
return Token( return Token(
access_token=access_token, access_token=access_token,
refresh_token=refresh_token, refresh_token=refresh_token,
@ -41,16 +41,19 @@ class AuthService:
if payload is None: if payload is None:
return None return None
username: str | None = payload.get("sub") subject: str | None = payload.get("sub")
if username is None: if subject is None:
return None return None
user = await self.user_repo.get_by_username(username) user = await self.user_repo.get_by_username(subject)
if not user: if user is None:
user = await self.user_repo.get_by_email(subject)
if user is None or not user.is_active:
return None return None
access_token = create_access_token(data={"sub": user.username}) token_sub = user.email if subject == user.email else user.username
new_refresh_token = create_refresh_token(data={"sub": user.username}) access_token = create_access_token(data={"sub": token_sub})
new_refresh_token = create_refresh_token(data={"sub": token_sub})
return Token( return Token(
access_token=access_token, access_token=access_token,
refresh_token=new_refresh_token, refresh_token=new_refresh_token,

View File

@ -0,0 +1,232 @@
import importlib
import sys
import types
from unittest.mock import AsyncMock, MagicMock
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.config import settings
from src.core.exception_handlers import register_exception_handlers
from src.db.session import get_db
def _prepare_raisa_stub(email: str = "prod-user@example.com"):
module = types.ModuleType("raisa_fastapi_protected_api")
class UserInfo:
def __init__(self, email: str):
self.email = email
async def get_user_dependency():
return UserInfo(email)
module.UserInfo = UserInfo
module.get_user_dependency = get_user_dependency
return module
def _reload_with_prod_auth(monkeypatch, module_name: str, email: str = "prod-user@example.com"):
original_debug = settings.DEBUG
previous = sys.modules.get("raisa_fastapi_protected_api")
monkeypatch.setattr(settings, "DEBUG", False)
monkeypatch.setitem(sys.modules, "raisa_fastapi_protected_api", _prepare_raisa_stub(email))
module = importlib.import_module(module_name)
importlib.reload(module)
return original_debug, previous, module
def _restore_module(monkeypatch, module_name: str, original_debug, previous):
monkeypatch.setattr(settings, "DEBUG", original_debug)
if previous is None:
sys.modules.pop("raisa_fastapi_protected_api", None)
else:
sys.modules["raisa_fastapi_protected_api"] = previous
importlib.reload(importlib.import_module(module_name))
def test_auth_login_non_debug_branch_success_and_unauthorized(monkeypatch):
original_debug, previous, auth_module = _reload_with_prod_auth(
monkeypatch, "src.api.v1.auth"
)
try:
mock_async_session = AsyncMock(spec=AsyncSession)
mock_auth_service = MagicMock()
app = FastAPI()
register_exception_handlers(app)
app.include_router(auth_module.router)
async def mock_get_db():
return mock_async_session
app.dependency_overrides = {get_db: mock_get_db}
with monkeypatch.context() as m:
m.setattr(
"src.api.v1.auth.AuthService",
MagicMock(return_value=mock_auth_service),
)
client = TestClient(app)
mock_auth_service.authenticate_user_via_email = AsyncMock(
return_value={
"access_token": "a",
"refresh_token": "r",
"token_type": "bearer",
}
)
response = client.post(
"/auth/login",
json={"username": "ignored", "password": "ignored"},
)
assert response.status_code == 200
assert response.json()["access_token"] == "a"
mock_auth_service.authenticate_user_via_email.assert_awaited_once_with(
"prod-user@example.com"
)
mock_auth_service.authenticate_user_via_email = AsyncMock(return_value=None)
response = client.post(
"/auth/login",
json={"username": "ignored", "password": "ignored"},
)
assert response.status_code == 401
assert response.json()["message"] == (
"Пользователь не найден в локальной базе данных"
)
finally:
_restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous)
def test_auth_login_form_non_debug_uses_keycloak_email(monkeypatch):
original_debug, previous, auth_module = _reload_with_prod_auth(
monkeypatch, "src.api.v1.auth"
)
try:
mock_auth_service = MagicMock()
mock_auth_service.authenticate_user = AsyncMock(return_value=None)
mock_auth_service.authenticate_user_via_email = AsyncMock(
return_value={
"access_token": "a",
"refresh_token": "r",
"token_type": "bearer",
}
)
app = FastAPI()
register_exception_handlers(app)
app.include_router(auth_module.router)
async def mock_get_db():
return AsyncMock(spec=AsyncSession)
app.dependency_overrides = {get_db: mock_get_db}
with monkeypatch.context() as m:
m.setattr(
"src.api.v1.auth.AuthService",
MagicMock(return_value=mock_auth_service),
)
client = TestClient(app)
response = client.post("/auth/login-form")
assert response.status_code == 200
mock_auth_service.authenticate_user_via_email.assert_awaited_once_with(
"prod-user@example.com"
)
mock_auth_service.authenticate_user.assert_not_awaited()
finally:
_restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous)
def test_auth_login_non_debug_rejects_missing_email(monkeypatch):
original_debug, previous, auth_module = _reload_with_prod_auth(
monkeypatch, "src.api.v1.auth", email=""
)
try:
app = FastAPI()
register_exception_handlers(app)
app.include_router(auth_module.router)
async def mock_get_db():
return AsyncMock(spec=AsyncSession)
app.dependency_overrides = {get_db: mock_get_db}
client = TestClient(app)
response = client.post(
"/auth/login",
json={"username": "ignored", "password": "ignored"},
)
assert response.status_code == 401
assert "email" in response.json()["message"]
finally:
_restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous)
def test_get_current_user_non_debug_maps_email(monkeypatch):
original_debug, previous, deps_module = _reload_with_prod_auth(
monkeypatch, "src.api.v1.deps"
)
try:
db_user = MagicMock(email="prod-user@example.com", is_active=True)
mock_repo = MagicMock()
mock_repo.get_by_email = AsyncMock(return_value=db_user)
app = FastAPI()
register_exception_handlers(app)
@app.get("/me")
async def me(current_user=Depends(deps_module.get_current_user)):
return {"email": current_user.email}
async def mock_get_db():
return AsyncMock(spec=AsyncSession)
app.dependency_overrides = {get_db: mock_get_db}
with monkeypatch.context() as m:
m.setattr(deps_module, "UserRepository", MagicMock(return_value=mock_repo))
client = TestClient(app)
response = client.get("/me")
assert response.status_code == 200
assert response.json()["email"] == "prod-user@example.com"
mock_repo.get_by_email.assert_awaited_once_with("prod-user@example.com")
finally:
_restore_module(monkeypatch, "src.api.v1.deps", original_debug, previous)
def test_get_current_user_non_debug_rejects_inactive(monkeypatch):
original_debug, previous, deps_module = _reload_with_prod_auth(
monkeypatch, "src.api.v1.deps"
)
try:
db_user = MagicMock(email="prod-user@example.com", is_active=False)
mock_repo = MagicMock()
mock_repo.get_by_email = AsyncMock(return_value=db_user)
app = FastAPI()
register_exception_handlers(app)
@app.get("/me")
async def me(current_user=Depends(deps_module.get_current_user)):
return {"email": current_user.email}
async def mock_get_db():
return AsyncMock(spec=AsyncSession)
app.dependency_overrides = {get_db: mock_get_db}
with monkeypatch.context() as m:
m.setattr(deps_module, "UserRepository", MagicMock(return_value=mock_repo))
client = TestClient(app)
response = client.get("/me")
assert response.status_code == 401
assert response.json()["message"] == "Пользователь не найден"
finally:
_restore_module(monkeypatch, "src.api.v1.deps", original_debug, previous)

View File

@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from src.core.security import create_refresh_token, verify_token
from src.services.auth_service import AuthService from src.services.auth_service import AuthService
@ -30,3 +31,77 @@ async def test_refresh_token_returns_none_for_invalid():
token = await service.refresh_token("bad-token") token = await service.refresh_token("bad-token")
assert token is None assert token is None
@pytest.mark.asyncio
async def test_refresh_token_resolves_user_by_email():
db = AsyncMock()
service = AuthService(db)
service.user_repo = MagicMock()
service.user_repo.get_by_username = AsyncMock(return_value=None)
user = MagicMock(username="admin", email="admin@example.com", is_active=True)
service.user_repo.get_by_email = AsyncMock(return_value=user)
token = await service.refresh_token(
create_refresh_token(data={"sub": "admin@example.com"})
)
assert token is not None
assert token.token_type == "bearer"
payload = verify_token(token.refresh_token)
assert payload is not None
assert payload["sub"] == "admin@example.com"
service.user_repo.get_by_email.assert_awaited_once_with("admin@example.com")
@pytest.mark.asyncio
async def test_refresh_token_resolves_user_by_username():
db = AsyncMock()
service = AuthService(db)
service.user_repo = MagicMock()
user = MagicMock(username="admin", email="admin@example.com", is_active=True)
service.user_repo.get_by_username = AsyncMock(return_value=user)
service.user_repo.get_by_email = AsyncMock(return_value=None)
token = await service.refresh_token(create_refresh_token(data={"sub": "admin"}))
assert token is not None
payload = verify_token(token.refresh_token)
assert payload is not None
assert payload["sub"] == "admin"
service.user_repo.get_by_email.assert_not_awaited()
@pytest.mark.asyncio
async def test_authenticate_user_via_email_uses_username_subject():
db = AsyncMock()
service = AuthService(db)
service.user_repo = MagicMock()
service.user_repo.authenticate_via_email = AsyncMock(
return_value=MagicMock(
username="admin", email="admin@example.com", is_active=True
)
)
token = await service.authenticate_user_via_email("admin@example.com")
assert token is not None
payload = verify_token(token.access_token)
assert payload is not None
assert payload["sub"] == "admin"
@pytest.mark.asyncio
async def test_authenticate_user_via_email_rejects_inactive():
db = AsyncMock()
service = AuthService(db)
service.user_repo = MagicMock()
service.user_repo.authenticate_via_email = AsyncMock(
return_value=MagicMock(
username="admin", email="admin@example.com", is_active=False
)
)
token = await service.authenticate_user_via_email("admin@example.com")
assert token is None