Initial commit

This commit is contained in:
2026-04-12 09:16:16 +03:00
commit 5fe8efc5d4
98 changed files with 5351 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
from src.presentation.routing.auth import auth_router
from src.presentation.routing.csrf import csrf_router
from src.presentation.routing.jwt import jwt_router
v1_router = APIRouter(prefix='/v1')
v1_router.include_router(auth_router)
v1_router.include_router(csrf_router)
v1_router.include_router(jwt_router)

View File

@@ -0,0 +1,222 @@
from fastapi import APIRouter, Depends, status, Request
from fastapi.responses import ORJSONResponse
from ulid import ULID
from src.application.commands import (
UserLogoutCommand,
UserRegistrationStartCommand,
UserLoginStartCommand,
UserRegistrationCompleteCommand,
UserLoginCompleteCommand
)
from src.application.contracts import ILogger
from src.application.domain.dto import UserLoginDto
from src.infrastructure.config import settings
from src.infrastructure.logger import get_logger
from src.presentation.decorators import rate_limit, email_rl_key
from src.presentation.dependencies import (
get_user_registration_complete_command,
get_user_logout_command,
get_user_registration_start_command,
get_user_login_start_command,
get_user_login_complete_command
)
from src.presentation.schemas import UserLogin, RegistrationStart, RegistrationComplete, LoginStart
#from src.presentation.decorators import csrf_protect
auth_router = APIRouter(prefix='/auth', tags=['auth'])
@auth_router.post(
path='/registration/start',
response_class=ORJSONResponse,
status_code=status.HTTP_200_OK,
)
@rate_limit(limit=5, window_seconds=60, scope='ip')
@rate_limit(limit=3, window_seconds=600, scope='key', key_prefix='rl:reg_start', key_builder=email_rl_key)
async def registration_start(
request: Request,
body: RegistrationStart,
command: UserRegistrationStartCommand = Depends(get_user_registration_start_command),
):
result = await command(body.email)
return {'success': result}
@auth_router.post(path='/registration/complete', response_class=ORJSONResponse, status_code=status.HTTP_201_CREATED)
@rate_limit(limit=10, window_seconds=300, scope='ip')
async def registration(
request: Request,
user: RegistrationComplete,
command: UserRegistrationCompleteCommand = Depends(get_user_registration_complete_command),
logger: ILogger = Depends(get_logger),
):
device_id = request.cookies.get('device_id')
if not device_id:
device_id = str(ULID())
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')
created = await command(
email=str(user.email),
password=user.password,
device_id=device_id,
code=user.code,
user_agent=user_agent,
ip=ip,
)
logger.info(f'Registration completed for user_id={created.id}')
response = ORJSONResponse(content={'id': created.id, 'email': created.email})
response.set_cookie(
key='device_id',
value=device_id,
httponly=True,
secure=True,
samesite='lax',
path='/',
max_age=60 * 60 * 24 * 365 * 5
)
response.set_cookie(
key='access_token',
value=created.access_token,
httponly=True,
secure=True,
samesite='lax',
path='/',
max_age=int(settings.JWT_ACCESS_TTL_SECONDS),
)
response.set_cookie(
key='refresh_token',
value=created.refresh_token,
httponly=True,
secure=True,
samesite='lax',
path='/',
max_age=int(settings.JWT_REFRESH_TTL_SECONDS),
)
return response
@auth_router.post(path='/login/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
@rate_limit(limit=5, window_seconds=60, scope='ip')
@rate_limit(limit=3, window_seconds=600, scope='key', key_prefix='rl:login_start', key_builder=email_rl_key)
async def login_start(
request: Request,
body: LoginStart,
command: UserLoginStartCommand = Depends(get_user_login_start_command),
):
result = await command(body.email)
return {'success': result}
@auth_router.post(path='/login/compete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
@rate_limit(limit=10, window_seconds=300, scope='ip')
async def login(
request: Request,
user: UserLogin,
command: UserLoginCompleteCommand = Depends(get_user_login_complete_command),
logger: ILogger = Depends(get_logger)
):
device_id = request.cookies.get('device_id')
if not device_id:
device_id = str(ULID())
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')
dto: UserLoginDto = await command(
email=str(user.email),
password=user.password,
code=user.code,
device_id=device_id,
user_agent=user_agent,
ip=ip,
)
logger.info(f'Login completed for user_id={dto.id}')
response = ORJSONResponse(
content={
'id': dto.id,
'email': dto.email,
'first_name': dto.first_name,
'middle_name': dto.middle_name,
'last_name': dto.last_name,
'birth_date': dto.birth_date.isoformat() if dto.birth_date else None,
'crypto_wallet': dto.crypto_wallet,
'phone': dto.phone,
'bik': dto.bik,
'account_number': dto.account_number,
'card_number': dto.card_number,
'inn': dto.inn,
'kyc_verified': dto.kyc_verified,
'kyc_verified_at': dto.kyc_verified_at,
'created_at': dto.created_at.isoformat() if dto.created_at else None,
'updated_at': dto.updated_at.isoformat() if dto.updated_at else None,
}
)
response.set_cookie(
key='device_id',
value=device_id,
httponly=True,
secure=True,
samesite='lax',
path='/',
max_age=60 * 60 * 24 * 365 * 5
)
response.set_cookie(
key='access_token',
value=dto.access_token,
httponly=True,
secure=True,
samesite='lax',
path='/',
max_age=int(settings.JWT_ACCESS_TTL_SECONDS),
)
response.set_cookie(
key='refresh_token',
value=dto.refresh_token,
httponly=True,
secure=True,
samesite='lax',
path='/',
max_age=int(settings.JWT_REFRESH_TTL_SECONDS),
)
return response
@auth_router.post(path='/logout', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
@rate_limit(limit=settings.RATE_LIMIT_REQUESTS, window_seconds=settings.RATE_LIMIT_WINDOW, scope='ip')
async def logout_current(
request: Request,
command: UserLogoutCommand = Depends(get_user_logout_command),
):
refresh_token = request.cookies.get('refresh_token')
await command(refresh_token=refresh_token)
response = ORJSONResponse({'ok': True})
response.delete_cookie('access_token', path='/')
response.delete_cookie('refresh_token', path='/')
return response
# @auth_router.get(path='/ping')
# @csrf_protect()
# async def ping(request: Request):
# return ORJSONResponse(
# content={
# 'status': 'pong'
# }
# )

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi.responses import ORJSONResponse
from starlette import status
from src.infrastructure.security import CsrfService
from src.infrastructure.config import settings
from src.presentation.decorators import rate_limit
csrf_router = APIRouter(prefix='/csrf', tags=['csrf'])
@csrf_router.get('/token', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
@rate_limit(limit=settings.RATE_LIMIT_REQUESTS, window_seconds=settings.RATE_LIMIT_WINDOW, scope='ip')
async def issue_csrf_token():
csrf = CsrfService()
token = csrf.issue()
response = ORJSONResponse(
content={
'token': token,
'header_name': csrf.header_name,
}
)
response.set_cookie(
key=csrf.cookie_name,
value=token,
secure=settings.CSRF_COOKIE_SECURE,
httponly=settings.CSRF_COOKIE_HTTPONLY,
samesite=settings.CSRF_COOKIE_SAMESITE,
path=settings.CSRF_COOKIE_PATH,
domain=settings.CSRF_COOKIE_DOMAIN,
max_age=csrf.ttl_seconds,
)
return response

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import ORJSONResponse
from starlette import status
from src.application.commands import JwtRefreshCommand
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.config import settings
from src.presentation.decorators import rate_limit
from src.presentation.dependencies import get_jwt_refresh_command
jwt_router = APIRouter(prefix='/jwt', tags=['Jwt'])
@jwt_router.post('/refresh', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
@rate_limit(limit=settings.RATE_LIMIT_REQUESTS, window_seconds=settings.RATE_LIMIT_WINDOW, scope='ip')
async def refresh_tokens(
request: Request,
command: JwtRefreshCommand = Depends(get_jwt_refresh_command)
):
refresh_token = request.cookies.get('refresh_token')
if not refresh_token:
response = ORJSONResponse({'ok': False, 'error': 'No refresh token'}, status_code=401)
response.delete_cookie('access_token', path='/')
response.delete_cookie('refresh_token', path='/')
return response
ip = request.client.host if request.client else None
user_agent = request.headers.get('user-agent')
try:
access, refresh = await command(refresh_token=refresh_token, ip=ip, user_agent=user_agent)
except ApplicationException:
response = ORJSONResponse({'result': False}, status_code=401)
response.delete_cookie('access_token', path='/')
response.delete_cookie('refresh_token', path='/')
return response
response = ORJSONResponse({'result': True})
response.set_cookie(
key='access_token',
value=access,
httponly=True,
secure=True,
samesite='lax',
path='/',
max_age=int(settings.JWT_ACCESS_TTL_SECONDS),
)
response.set_cookie(
key='refresh_token',
value=refresh,
httponly=True,
secure=True,
samesite='lax',
path='/',
max_age=int(settings.JWT_REFRESH_TTL_SECONDS),
)
return response
# Usage
# @jwt_router.get("/test")
# async def profile(auth: AuthContext = Depends(require_access_token)):
# return 'ok'