feat: add refresh

This commit is contained in:
2026-06-05 12:41:25 +03:00
parent d300d2eabe
commit 9d3c5b401a
8 changed files with 175 additions and 50 deletions

View File

@@ -1,11 +1,14 @@
from __future__ import annotations 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.abstractions import IUnitOfWork
from src.application.contracts import IHashService, IJwtService, ILogger from src.application.contracts import IHashService, IJwtService, ILogger
from src.application.domain.dto.admin_auth import AdminLoginDto from src.application.domain.dto.admin_auth import AdminLoginDto
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException
from src.infrastructure.config import settings
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -23,7 +26,15 @@ class AdminLoginCommand:
self._logger = logger self._logger = logger
@transactional @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() login = (login or '').strip()
if not login: if not login:
raise ApplicationException(status_code=400, message='Login is required') raise ApplicationException(status_code=400, message='Login is required')
@@ -40,9 +51,32 @@ class AdminLoginCommand:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
await self._unit_of_work.admin_user_repository.update_last_login(admin.id, last_login_at=now) 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( access_token = await self._jwt_service.create_access_token(
user_id=admin.id, user_id=admin.id,
role=admin.role, 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}') self._logger.info(f'Admin logged in admin_user_id={admin.id}')
@@ -54,5 +88,7 @@ class AdminLoginCommand:
last_name=admin.last_name, last_name=admin.last_name,
role=admin.role, role=admin.role,
access_token=access_token, access_token=access_token,
refresh_token=refresh_token,
device_id=resolved_device_id,
last_login_at=now, last_login_at=now,
) )

View File

@@ -12,4 +12,6 @@ class AdminLoginDto:
last_name: str | None last_name: str | None
role: str role: str
access_token: str access_token: str
refresh_token: str
device_id: str
last_login_at: datetime | None = None last_login_at: datetime | None = None

View File

@@ -59,6 +59,7 @@ class Settings(BaseSettings):
DOCS_PASSWORD: str = 'admin' DOCS_PASSWORD: str = 'admin'
JWT_ACCESS_TTL_SECONDS: int = 8 * 60 * 60 JWT_ACCESS_TTL_SECONDS: int = 8 * 60 * 60
JWT_REFRESH_TTL_SECONDS: int = 30 * 24 * 60 * 60
ADMIN_JWT_ISSUER: str | None = 'admin-service' ADMIN_JWT_ISSUER: str | None = 'admin-service'
JWT_AUDIENCE: str | None = None JWT_AUDIENCE: str | None = None
JWT_ALGORITHM: str = 'RS256' JWT_ALGORITHM: str = 'RS256'

View File

@@ -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),
)

View File

@@ -2,11 +2,13 @@ from fastapi import APIRouter
from src.presentation.routing.auth import auth_router from src.presentation.routing.auth import auth_router
from src.presentation.routing.documents import documents_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.organizations import organizations_router
from src.presentation.routing.purchase_requests import purchase_requests_router from src.presentation.routing.purchase_requests import purchase_requests_router
v1_router = APIRouter(prefix='/v1') v1_router = APIRouter(prefix='/v1')
v1_router.include_router(auth_router) v1_router.include_router(auth_router)
v1_router.include_router(jwt_router)
v1_router.include_router(organizations_router) v1_router.include_router(organizations_router)
v1_router.include_router(documents_router) v1_router.include_router(documents_router)
v1_router.include_router(purchase_requests_router) v1_router.include_router(purchase_requests_router)

View File

@@ -1,29 +1,79 @@
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import ORJSONResponse 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.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.decorators.admin_auth import require_admin_access
from src.presentation.dependencies.commands import get_admin_login_command, get_admin_me_command from src.presentation.dependencies.commands import (
from src.presentation.schemas.admin_auth import AdminLoginRequest, AdminLoginResponse, AdminMeResponse 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']) 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) @auth_router.post('/login', response_model=AdminLoginResponse, status_code=status.HTTP_200_OK)
async def admin_login( async def admin_login(
body: AdminLoginRequest, body: AdminLoginRequest,
request: Request,
command: AdminLoginCommand = Depends(get_admin_login_command), command: AdminLoginCommand = Depends(get_admin_login_command),
): ):
dto = await command(login=body.login, password=body.password) dto = await command(
return AdminLoginResponse( 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, access_token=dto.access_token,
refresh_token=dto.refresh_token,
id=dto.id, id=dto.id,
login=dto.login, login=dto.login,
first_name=dto.first_name, first_name=dto.first_name,
last_name=dto.last_name, last_name=dto.last_name,
role=dto.role, 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) @auth_router.post('/logout', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)

View File

@@ -4,38 +4,17 @@ from starlette import status
from src.application.commands import AdminJwtRefreshCommand from src.application.commands import AdminJwtRefreshCommand
from src.application.domain.exceptions import ApplicationException, RefreshConcurrentException 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 from src.presentation.dependencies.commands import get_admin_jwt_refresh_command
jwt_router = APIRouter(prefix='/jwt', tags=['jwt']) jwt_router = APIRouter(prefix='/jwt', tags=['jwt'])
def _clear_auth_cookies(response: ORJSONResponse) -> None: def _client_ip(request: Request) -> str | None:
response.delete_cookie('access_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN) xff = request.headers.get('x-forwarded-for')
response.delete_cookie('refresh_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN) if xff:
return xff.split(',')[0].strip()
return request.client.host if request.client else None
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),
)
@jwt_router.post('/refresh', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) @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') refresh_token = request.cookies.get('refresh_token')
if not refresh_token: if not refresh_token:
response = ORJSONResponse({'ok': False, 'error': 'No refresh token'}, status_code=401) response = ORJSONResponse({'result': False, 'error': 'No refresh token'}, status_code=401)
_clear_auth_cookies(response) clear_auth_cookies(response)
return 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: 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: except RefreshConcurrentException:
return ORJSONResponse({'result': True, 'concurrent': True}, status_code=status.HTTP_200_OK) return ORJSONResponse({'result': True, 'concurrent': True}, status_code=status.HTTP_200_OK)
except ApplicationException as exc: except ApplicationException as exc:
if exc.status_code == status.HTTP_401_UNAUTHORIZED: if exc.status_code == status.HTTP_401_UNAUTHORIZED:
response = ORJSONResponse({'result': False}, status_code=401) response = ORJSONResponse({'result': False}, status_code=401)
_clear_auth_cookies(response) clear_auth_cookies(response)
return response return response
raise raise
access, refresh = tokens access, refresh = tokens
response = ORJSONResponse({'result': True}) response = ORJSONResponse({'result': True})
_set_auth_cookies(response, access, refresh) set_auth_cookies(response, access, refresh)
return response return response

View File

@@ -8,6 +8,7 @@ class AdminLoginRequest(BaseModel):
class AdminLoginResponse(BaseModel): class AdminLoginResponse(BaseModel):
access_token: str access_token: str
refresh_token: str
token_type: str = 'Bearer' token_type: str = 'Bearer'
id: str id: str
login: str login: str
@@ -16,6 +17,16 @@ class AdminLoginResponse(BaseModel):
role: str 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): class AdminMeResponse(BaseModel):
id: str id: str
login: str login: str