feat: add refresh
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
44
src/presentation/auth_cookies.py
Normal file
44
src/presentation/auth_cookies.py
Normal 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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user