feat: add refresh
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
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.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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user