diff --git a/api/src/api/v1/auth.py b/api/src/api/v1/auth.py index 998ceaf..ae8e5db 100644 --- a/api/src/api/v1/auth.py +++ b/api/src/api/v1/auth.py @@ -6,66 +6,89 @@ from src.db.session import get_db from src.domain.schemas import LoginRequest, RefreshRequest, Token from src.services.auth_service import AuthService -# if not settings.DEBUG: -# from raisa_fastapi_protected_api import UserInfo, get_user_dependency +if not settings.DEBUG: + from raisa_fastapi_protected_api import UserInfo, get_user_dependency 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( - login_data: LoginRequest, - db: AsyncSession = Depends(get_db), -): +async def _login_via_keycloak_email(db: AsyncSession, user: "UserInfo") -> Token: + if not getattr(user, "email", None): + 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_user(login_data.username, login_data.password) + token = await auth_service.authenticate_user_via_email(user.email) if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Неверное имя пользователя или пароль", + detail=_PROD_USER_NOT_FOUND, headers={"WWW-Authenticate": "Bearer"}, ) 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), -# ): -# 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 +if settings.DEBUG: - -@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", + @router.post("/login", response_model=Token) + async def login( + login_data: LoginRequest, + db: AsyncSession = Depends(get_db), + ): + auth_service = AuthService(db) + token = await auth_service.authenticate_user( + login_data.username, login_data.password ) - 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 + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверное имя пользователя или пароль", + headers={"WWW-Authenticate": "Bearer"}, + ) + 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) diff --git a/api/src/api/v1/deps.py b/api/src/api/v1/deps.py index 886297f..ce2f3e9 100644 --- a/api/src/api/v1/deps.py +++ b/api/src/api/v1/deps.py @@ -12,8 +12,6 @@ from src.repository.user_repository import UserRepository security = HTTPBearer() -# if settings.DEBUG: - async def get_user_by_token( token: str, db: AsyncSession, @@ -46,28 +44,37 @@ async def get_user_by_token( ) 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: -# from raisa_fastapi_protected_api import UserInfo, get_user_dependency +if settings.DEBUG: -# async def get_current_user( -# user: UserInfo = Depends(get_user_dependency), -# db: AsyncSession = Depends(get_db), -# ) -> AppUser: -# user_repo = UserRepository(db) -# db_user = await user_repo.get_by_email(user.email) -# if db_user is None: -# raise HTTPException( -# status_code=status.HTTP_401_UNAUTHORIZED, -# detail="Пользователь не найден", -# headers={"WWW-Authenticate": "Bearer"}, -# ) -# return db_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: + from raisa_fastapi_protected_api import UserInfo, get_user_dependency + + async def get_current_user( + user: UserInfo = Depends(get_user_dependency), + db: AsyncSession = Depends(get_db), + ) -> 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: diff --git a/api/src/main.py b/api/src/main.py index 1fc02e3..c6aebda 100644 --- a/api/src/main.py +++ b/api/src/main.py @@ -25,15 +25,15 @@ from src.db.session import create_tables logging.basicConfig(level=logging.INFO) -# if not settings.DEBUG: -# from raisa_fastapi_protected_api import ( -# AuthorizationMiddleware, -# OpenEndpoint, -# ProtectedOAuthSettings, -# ProtectedRolesSettings, -# ProtectedSettings, -# SearchType, -# ) +if not settings.DEBUG: + from raisa_fastapi_protected_api import ( + AuthorizationMiddleware, + OpenEndpoint, + ProtectedOAuthSettings, + ProtectedRolesSettings, + ProtectedSettings, + SearchType, + ) _AUDIT_CLEANUP_LOCK_KEY = 21987431 @@ -104,13 +104,13 @@ async def lifespan(app: FastAPI): cleanup_task: asyncio.Task | None = None if not settings.DEBUG: await create_tables() - # ProtectedSettings( - # ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL), - # ProtectedRolesSettings( - # APP_NAMESPACE=settings.APP_NAMESPACE, - # APP_NAME=settings.APP_NAME, - # ), - # ) + ProtectedSettings( + ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL), + ProtectedRolesSettings( + APP_NAMESPACE=settings.APP_NAMESPACE, + APP_NAME=settings.APP_NAME, + ), + ) cleanup_task = asyncio.create_task(_audit_cleanup_loop()) try: yield @@ -185,41 +185,61 @@ async def readyz(response: Response): -# if not settings.DEBUG: -# app.add_middleware( -# AuthorizationMiddleware, -# open_endpoints=[ -# OpenEndpoint(path="", type_search=SearchType.ABSOLUTE), -# OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE), -# OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE), -# OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE), -# OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE), -# OpenEndpoint( -# path=settings.ROOT_PATH + "/healthcheck", -# type_search=SearchType.ABSOLUTE, -# ), -# OpenEndpoint(path="/healthz", type_search=SearchType.ABSOLUTE), -# OpenEndpoint( -# path=settings.ROOT_PATH + "/healthz", -# type_search=SearchType.ABSOLUTE, -# ), -# OpenEndpoint(path="/readyz", type_search=SearchType.ABSOLUTE), -# OpenEndpoint( -# path=settings.ROOT_PATH + "/readyz", -# type_search=SearchType.ABSOLUTE, -# ), -# 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, -# ), -# ], -# ) +if not settings.DEBUG: + app.add_middleware( + AuthorizationMiddleware, + open_endpoints=[ + OpenEndpoint(path="", type_search=SearchType.ABSOLUTE), + OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE), + OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE), + OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE), + OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE), + OpenEndpoint( + path=settings.ROOT_PATH + "/healthcheck", + type_search=SearchType.ABSOLUTE, + ), + OpenEndpoint(path="/healthcheck2", type_search=SearchType.ABSOLUTE), + OpenEndpoint( + path=settings.ROOT_PATH + "/healthcheck2", + type_search=SearchType.ABSOLUTE, + ), + OpenEndpoint(path="/healthz", type_search=SearchType.ABSOLUTE), + OpenEndpoint( + path=settings.ROOT_PATH + "/healthz", + type_search=SearchType.ABSOLUTE, + ), + OpenEndpoint(path="/readyz", type_search=SearchType.ABSOLUTE), + OpenEndpoint( + path=settings.ROOT_PATH + "/readyz", + type_search=SearchType.ABSOLUTE, + ), + OpenEndpoint(path="/static", type_search=SearchType.START), + OpenEndpoint( + path=settings.ROOT_PATH + "/static", + 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( CORSMiddleware, diff --git a/api/src/services/auth_service.py b/api/src/services/auth_service.py index e1c926d..e882449 100644 --- a/api/src/services/auth_service.py +++ b/api/src/services/auth_service.py @@ -26,10 +26,10 @@ class AuthService: async def authenticate_user_via_email(self, email: str) -> Optional[Token]: user = await self.user_repo.authenticate_via_email(email) - if not user: + if not user or not user.is_active: return None - access_token = create_access_token(data={"sub": user.email}) - refresh_token = create_refresh_token(data={"sub": user.email}) + access_token = create_access_token(data={"sub": user.username}) + refresh_token = create_refresh_token(data={"sub": user.username}) return Token( access_token=access_token, refresh_token=refresh_token, @@ -41,16 +41,19 @@ class AuthService: if payload is None: return None - username: str | None = payload.get("sub") - if username is None: + subject: str | None = payload.get("sub") + if subject is None: return None - user = await self.user_repo.get_by_username(username) - if not user: + user = await self.user_repo.get_by_username(subject) + if user is None: + user = await self.user_repo.get_by_email(subject) + if user is None or not user.is_active: return None - access_token = create_access_token(data={"sub": user.username}) - new_refresh_token = create_refresh_token(data={"sub": user.username}) + token_sub = user.email if subject == user.email else user.username + access_token = create_access_token(data={"sub": token_sub}) + new_refresh_token = create_refresh_token(data={"sub": token_sub}) return Token( access_token=access_token, refresh_token=new_refresh_token, diff --git a/api/tests/unit/test_auth_api_prod_branch.py b/api/tests/unit/test_auth_api_prod_branch.py new file mode 100644 index 0000000..4249dc8 --- /dev/null +++ b/api/tests/unit/test_auth_api_prod_branch.py @@ -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) diff --git a/api/tests/unit/test_auth_service.py b/api/tests/unit/test_auth_service.py index 23d307b..e9e7842 100644 --- a/api/tests/unit/test_auth_service.py +++ b/api/tests/unit/test_auth_service.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from src.core.security import create_refresh_token, verify_token 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") 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