From 9d3c5b401a1077fa7687e238a25cef8945a85358 Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Fri, 5 Jun 2026 12:41:25 +0300 Subject: [PATCH] feat: add refresh --- src/application/commands/admin_login.py | 40 ++++++++++++- src/application/domain/dto/admin_auth.py | 2 + src/infrastructure/config/settings.py | 1 + src/presentation/auth_cookies.py | 44 ++++++++++++++ src/presentation/routing/__init__.py | 2 + src/presentation/routing/auth.py | 74 ++++++++++++++++++++---- src/presentation/routing/jwt.py | 51 +++++----------- src/presentation/schemas/admin_auth.py | 11 ++++ 8 files changed, 175 insertions(+), 50 deletions(-) create mode 100644 src/presentation/auth_cookies.py diff --git a/src/application/commands/admin_login.py b/src/application/commands/admin_login.py index 7ae4853..ff9e9a8 100644 --- a/src/application/commands/admin_login.py +++ b/src/application/commands/admin_login.py @@ -1,11 +1,14 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta + +from ulid import ULID from src.application.abstractions import IUnitOfWork from src.application.contracts import IHashService, IJwtService, ILogger from src.application.domain.dto.admin_auth import AdminLoginDto from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config import settings from src.infrastructure.database.decorators import transactional @@ -23,7 +26,15 @@ class AdminLoginCommand: self._logger = logger @transactional - async def __call__(self, *, login: str, password: str) -> AdminLoginDto: + async def __call__( + self, + *, + login: str, + password: str, + device_id: str | None, + ip: str | None, + user_agent: str | None, + ) -> AdminLoginDto: login = (login or '').strip() if not login: raise ApplicationException(status_code=400, message='Login is required') @@ -40,9 +51,32 @@ class AdminLoginCommand: now = datetime.now(timezone.utc) await self._unit_of_work.admin_user_repository.update_last_login(admin.id, last_login_at=now) + resolved_device_id = device_id or str(ULID()) + sid = str(ULID()) + jti = str(ULID()) + jti_hash = await self._hash_service.hash(value=jti) + refresh_expires_at = now + timedelta(seconds=int(settings.JWT_REFRESH_TTL_SECONDS)) + + await self._unit_of_work.admin_session_repository.upsert_by_device( + admin_user_id=admin.id, + device_id=resolved_device_id, + sid=sid, + refresh_jti_hash=jti_hash, + refresh_expires_at=refresh_expires_at, + user_agent=user_agent, + ip=ip, + now=now, + ) + access_token = await self._jwt_service.create_access_token( user_id=admin.id, role=admin.role, + sid=sid, + ) + refresh_token = await self._jwt_service.create_refresh_token( + user_id=admin.id, + sid=sid, + refresh_jti=jti, ) self._logger.info(f'Admin logged in admin_user_id={admin.id}') @@ -54,5 +88,7 @@ class AdminLoginCommand: last_name=admin.last_name, role=admin.role, access_token=access_token, + refresh_token=refresh_token, + device_id=resolved_device_id, last_login_at=now, ) diff --git a/src/application/domain/dto/admin_auth.py b/src/application/domain/dto/admin_auth.py index a2d6cf4..f36fd65 100644 --- a/src/application/domain/dto/admin_auth.py +++ b/src/application/domain/dto/admin_auth.py @@ -12,4 +12,6 @@ class AdminLoginDto: last_name: str | None role: str access_token: str + refresh_token: str + device_id: str last_login_at: datetime | None = None diff --git a/src/infrastructure/config/settings.py b/src/infrastructure/config/settings.py index ff16272..0403e19 100644 --- a/src/infrastructure/config/settings.py +++ b/src/infrastructure/config/settings.py @@ -59,6 +59,7 @@ class Settings(BaseSettings): DOCS_PASSWORD: str = 'admin' JWT_ACCESS_TTL_SECONDS: int = 8 * 60 * 60 + JWT_REFRESH_TTL_SECONDS: int = 30 * 24 * 60 * 60 ADMIN_JWT_ISSUER: str | None = 'admin-service' JWT_AUDIENCE: str | None = None JWT_ALGORITHM: str = 'RS256' diff --git a/src/presentation/auth_cookies.py b/src/presentation/auth_cookies.py new file mode 100644 index 0000000..782b91b --- /dev/null +++ b/src/presentation/auth_cookies.py @@ -0,0 +1,44 @@ +from fastapi.responses import ORJSONResponse + +from src.infrastructure.config import settings + + +def clear_auth_cookies(response: ORJSONResponse) -> None: + response.delete_cookie('access_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN) + response.delete_cookie('refresh_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN) + + +def set_auth_cookies(response: ORJSONResponse, access: str, refresh: str) -> None: + response.set_cookie( + key='access_token', + value=access, + httponly=True, + secure=settings.ADMIN_COOKIE_SECURE, + samesite='lax', + path='/', + domain=settings.ADMIN_COOKIE_DOMAIN, + max_age=int(settings.JWT_ACCESS_TTL_SECONDS), + ) + response.set_cookie( + key='refresh_token', + value=refresh, + httponly=True, + secure=settings.ADMIN_COOKIE_SECURE, + samesite='lax', + path='/', + domain=settings.ADMIN_COOKIE_DOMAIN, + max_age=int(settings.JWT_REFRESH_TTL_SECONDS), + ) + + +def set_device_id_cookie(response: ORJSONResponse, device_id: str) -> None: + response.set_cookie( + key='device_id', + value=device_id, + httponly=True, + secure=settings.ADMIN_COOKIE_SECURE, + samesite='lax', + path='/', + domain=settings.ADMIN_COOKIE_DOMAIN, + max_age=int(settings.JWT_REFRESH_TTL_SECONDS), + ) diff --git a/src/presentation/routing/__init__.py b/src/presentation/routing/__init__.py index b6179d2..b0f0bc2 100644 --- a/src/presentation/routing/__init__.py +++ b/src/presentation/routing/__init__.py @@ -2,11 +2,13 @@ from fastapi import APIRouter from src.presentation.routing.auth import auth_router from src.presentation.routing.documents import documents_router +from src.presentation.routing.jwt import jwt_router from src.presentation.routing.organizations import organizations_router from src.presentation.routing.purchase_requests import purchase_requests_router v1_router = APIRouter(prefix='/v1') v1_router.include_router(auth_router) +v1_router.include_router(jwt_router) v1_router.include_router(organizations_router) v1_router.include_router(documents_router) v1_router.include_router(purchase_requests_router) diff --git a/src/presentation/routing/auth.py b/src/presentation/routing/auth.py index 9187da2..3099df2 100644 --- a/src/presentation/routing/auth.py +++ b/src/presentation/routing/auth.py @@ -1,29 +1,79 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, Request, status from fastapi.responses import ORJSONResponse -from src.application.commands import AdminLoginCommand, GetAdminMeCommand +from src.application.commands import AdminJwtRefreshCommand, AdminLoginCommand, GetAdminMeCommand from src.application.domain.dto import AdminAuthContext +from src.application.domain.exceptions import ApplicationException, RefreshConcurrentException +from src.presentation.auth_cookies import set_auth_cookies, set_device_id_cookie from src.presentation.decorators.admin_auth import require_admin_access -from src.presentation.dependencies.commands import get_admin_login_command, get_admin_me_command -from src.presentation.schemas.admin_auth import AdminLoginRequest, AdminLoginResponse, AdminMeResponse +from src.presentation.dependencies.commands import ( + get_admin_jwt_refresh_command, + get_admin_login_command, + get_admin_me_command, +) +from src.presentation.schemas.admin_auth import ( + AdminLoginRequest, + AdminLoginResponse, + AdminMeResponse, + AdminRefreshRequest, + AdminRefreshResponse, +) auth_router = APIRouter(prefix='/auth', tags=['auth']) +def _client_ip(request: Request) -> str | None: + xff = request.headers.get('x-forwarded-for') + if xff: + return xff.split(',')[0].strip() + return request.client.host if request.client else None + + @auth_router.post('/login', response_model=AdminLoginResponse, status_code=status.HTTP_200_OK) async def admin_login( body: AdminLoginRequest, + request: Request, command: AdminLoginCommand = Depends(get_admin_login_command), ): - dto = await command(login=body.login, password=body.password) - return AdminLoginResponse( - access_token=dto.access_token, - id=dto.id, - login=dto.login, - first_name=dto.first_name, - last_name=dto.last_name, - role=dto.role, + dto = await command( + login=body.login, + password=body.password, + device_id=request.cookies.get('device_id'), + ip=_client_ip(request), + user_agent=request.headers.get('user-agent'), ) + response = ORJSONResponse( + AdminLoginResponse( + access_token=dto.access_token, + refresh_token=dto.refresh_token, + id=dto.id, + login=dto.login, + first_name=dto.first_name, + last_name=dto.last_name, + role=dto.role, + ).model_dump() + ) + set_auth_cookies(response, dto.access_token, dto.refresh_token) + set_device_id_cookie(response, dto.device_id) + return response + + +@auth_router.post('/refresh', response_model=AdminRefreshResponse, status_code=status.HTTP_200_OK) +async def admin_refresh( + body: AdminRefreshRequest, + request: Request, + command: AdminJwtRefreshCommand = Depends(get_admin_jwt_refresh_command), +): + try: + access, refresh = await command( + refresh_token=body.refresh_token, + ip=_client_ip(request), + user_agent=request.headers.get('user-agent'), + ) + except RefreshConcurrentException: + raise ApplicationException(status_code=409, message='Refresh already in progress') + + return AdminRefreshResponse(access_token=access, refresh_token=refresh) @auth_router.post('/logout', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) diff --git a/src/presentation/routing/jwt.py b/src/presentation/routing/jwt.py index b085d94..ecf2ba1 100644 --- a/src/presentation/routing/jwt.py +++ b/src/presentation/routing/jwt.py @@ -4,38 +4,17 @@ from starlette import status from src.application.commands import AdminJwtRefreshCommand from src.application.domain.exceptions import ApplicationException, RefreshConcurrentException -from src.infrastructure.config import settings +from src.presentation.auth_cookies import clear_auth_cookies, set_auth_cookies from src.presentation.dependencies.commands import get_admin_jwt_refresh_command jwt_router = APIRouter(prefix='/jwt', tags=['jwt']) -def _clear_auth_cookies(response: ORJSONResponse) -> None: - response.delete_cookie('access_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN) - response.delete_cookie('refresh_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN) - - -def _set_auth_cookies(response: ORJSONResponse, access: str, refresh: str) -> None: - response.set_cookie( - key='access_token', - value=access, - httponly=True, - secure=settings.ADMIN_COOKIE_SECURE, - samesite='lax', - path='/', - domain=settings.ADMIN_COOKIE_DOMAIN, - max_age=int(settings.JWT_ACCESS_TTL_SECONDS), - ) - response.set_cookie( - key='refresh_token', - value=refresh, - httponly=True, - secure=settings.ADMIN_COOKIE_SECURE, - samesite='lax', - path='/', - domain=settings.ADMIN_COOKIE_DOMAIN, - max_age=int(settings.JWT_REFRESH_TTL_SECONDS), - ) +def _client_ip(request: Request) -> str | None: + xff = request.headers.get('x-forwarded-for') + if xff: + return xff.split(',')[0].strip() + return request.client.host if request.client else None @jwt_router.post('/refresh', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) @@ -45,26 +24,26 @@ async def refresh_tokens( ): refresh_token = request.cookies.get('refresh_token') if not refresh_token: - response = ORJSONResponse({'ok': False, 'error': 'No refresh token'}, status_code=401) - _clear_auth_cookies(response) + response = ORJSONResponse({'result': False, 'error': 'No refresh token'}, status_code=401) + clear_auth_cookies(response) return response - xff = request.headers.get('x-forwarded-for') - ip = xff.split(',')[0].strip() if xff else (request.client.host if request.client else None) - user_agent = request.headers.get('user-agent') - try: - tokens = await command(refresh_token=refresh_token, ip=ip, user_agent=user_agent) + tokens = await command( + refresh_token=refresh_token, + ip=_client_ip(request), + user_agent=request.headers.get('user-agent'), + ) except RefreshConcurrentException: return ORJSONResponse({'result': True, 'concurrent': True}, status_code=status.HTTP_200_OK) except ApplicationException as exc: if exc.status_code == status.HTTP_401_UNAUTHORIZED: response = ORJSONResponse({'result': False}, status_code=401) - _clear_auth_cookies(response) + clear_auth_cookies(response) return response raise access, refresh = tokens response = ORJSONResponse({'result': True}) - _set_auth_cookies(response, access, refresh) + set_auth_cookies(response, access, refresh) return response diff --git a/src/presentation/schemas/admin_auth.py b/src/presentation/schemas/admin_auth.py index 2a9cb29..a855e4e 100644 --- a/src/presentation/schemas/admin_auth.py +++ b/src/presentation/schemas/admin_auth.py @@ -8,6 +8,7 @@ class AdminLoginRequest(BaseModel): class AdminLoginResponse(BaseModel): access_token: str + refresh_token: str token_type: str = 'Bearer' id: str login: str @@ -16,6 +17,16 @@ class AdminLoginResponse(BaseModel): role: str +class AdminRefreshRequest(BaseModel): + refresh_token: str = Field(min_length=10) + + +class AdminRefreshResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = 'Bearer' + + class AdminMeResponse(BaseModel): id: str login: str