feat: add docs and custom exc
This commit is contained in:
@@ -3,7 +3,7 @@ from datetime import datetime,timedelta,timezone
|
|||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.contracts import IBeorgService,ILogger
|
from src.application.contracts import IBeorgService,ILogger
|
||||||
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse,KycSessionResponse
|
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse,KycSessionResponse
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException,KycFailedException,KycNotCompletedException,KycSessionExpiredException,KycSessionMissingUserTokenException
|
||||||
from src.application.services import ensure_adult,extract_personal_data,parse_birth_date
|
from src.application.services import ensure_adult,extract_personal_data,parse_birth_date
|
||||||
|
|
||||||
|
|
||||||
@@ -58,11 +58,11 @@ class CompleteKycCommand:
|
|||||||
async def __call__(self,user_id: str) -> BeorgKycResultResponse:
|
async def __call__(self,user_id: str) -> BeorgKycResultResponse:
|
||||||
session = await self._get_session(user_id)
|
session = await self._get_session(user_id)
|
||||||
if not session.user_token:
|
if not session.user_token:
|
||||||
raise ApplicationException(status_code=409,message='KYC session has no user token')
|
raise KycSessionMissingUserTokenException()
|
||||||
|
|
||||||
result = await self._beorg_service.get_result(user_token=session.user_token)
|
result = await self._beorg_service.get_result(user_token=session.user_token)
|
||||||
if result.done_state is None:
|
if result.done_state is None:
|
||||||
raise ApplicationException(status_code=409,message='KYC is not completed yet')
|
raise KycNotCompletedException()
|
||||||
if result.done_state is False:
|
if result.done_state is False:
|
||||||
async with self._unit_of_work as unit_of_work:
|
async with self._unit_of_work as unit_of_work:
|
||||||
await unit_of_work.kyc_repository.update_session_result(
|
await unit_of_work.kyc_repository.update_session_result(
|
||||||
@@ -74,7 +74,7 @@ class CompleteKycCommand:
|
|||||||
result_data=result.data,
|
result_data=result.data,
|
||||||
error='KYC failed',
|
error='KYC failed',
|
||||||
)
|
)
|
||||||
raise ApplicationException(status_code=400,message='KYC failed')
|
raise KycFailedException()
|
||||||
|
|
||||||
personal_data = extract_personal_data(result.data)
|
personal_data = extract_personal_data(result.data)
|
||||||
birth_date = parse_birth_date(personal_data.birth_date)
|
birth_date = parse_birth_date(personal_data.birth_date)
|
||||||
@@ -115,7 +115,7 @@ class CompleteKycCommand:
|
|||||||
client_user_token=session.client_user_token,
|
client_user_token=session.client_user_token,
|
||||||
qr_code=session.qr_code,
|
qr_code=session.qr_code,
|
||||||
)
|
)
|
||||||
raise ApplicationException(status_code=404,message='KYC session expired')
|
raise KycSessionExpiredException()
|
||||||
|
|
||||||
|
|
||||||
class GetKycSessionCommand:
|
class GetKycSessionCommand:
|
||||||
@@ -134,7 +134,7 @@ class GetKycSessionCommand:
|
|||||||
await unit_of_work.kyc_repository.expire_started_sessions(user_id=user_id,now=now)
|
await unit_of_work.kyc_repository.expire_started_sessions(user_id=user_id,now=now)
|
||||||
session = await unit_of_work.kyc_repository.get_latest_session(user_id=user_id)
|
session = await unit_of_work.kyc_repository.get_latest_session(user_id=user_id)
|
||||||
if session is None or session.expires_at is None:
|
if session is None or session.expires_at is None:
|
||||||
raise ApplicationException(status_code=404,message='KYC session expired')
|
raise KycSessionExpiredException()
|
||||||
|
|
||||||
expires_in = max(int((session.expires_at - now).total_seconds()),0)
|
expires_in = max(int((session.expires_at - now).total_seconds()),0)
|
||||||
return KycSessionResponse(
|
return KycSessionResponse(
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
from src.application.domain.exceptions.application_exceptions import ApplicationException
|
from src.application.domain.exceptions.application_exceptions import ApplicationException
|
||||||
|
from src.application.domain.exceptions.kyc_exceptions import BeorgConfigException,BeorgRejectedException,BeorgUnavailableException,CsrfRequestRequiredException,InvalidTokenException,JwtDecodeFailedException,KycAgeRestrictedException,KycBirthDateInvalidException,KycFailedException,KycNotCompletedException,KycPersonalDataIncompleteException,KycSessionExpiredException,KycSessionMissingUserTokenException,NotAuthenticatedException,UnauthorizedException,UserNotFoundException
|
||||||
@@ -7,11 +7,13 @@ class ApplicationException(Exception):
|
|||||||
self,
|
self,
|
||||||
status_code: int,
|
status_code: int,
|
||||||
message: str,
|
message: str,
|
||||||
|
error_code: str = 'application_error',
|
||||||
headers: Mapping[str, str] | None = None,
|
headers: Mapping[str, str] | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.error_code = error_code
|
||||||
self.headers = headers
|
self.headers = headers
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
98
src/application/domain/exceptions/kyc_exceptions.py
Normal file
98
src/application/domain/exceptions/kyc_exceptions.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from src.application.domain.exceptions.application_exceptions import ApplicationException
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthorizedException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self,message: str = 'Unauthorized') -> None:
|
||||||
|
super().__init__(status_code=401,message=message,error_code='unauthorized')
|
||||||
|
|
||||||
|
|
||||||
|
class NotAuthenticatedException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=401,message='Not authenticated',error_code='not_authenticated')
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTokenException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self,message: str = 'Invalid token') -> None:
|
||||||
|
super().__init__(status_code=401,message=message,error_code='invalid_token')
|
||||||
|
|
||||||
|
|
||||||
|
class JwtDecodeFailedException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=500,message='JWT decode failed',error_code='jwt_decode_failed')
|
||||||
|
|
||||||
|
|
||||||
|
class CsrfRequestRequiredException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=500,message='Request is required for CSRF protection',error_code='csrf_request_required')
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotFoundException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=404,message='User not found',error_code='user_not_found')
|
||||||
|
|
||||||
|
|
||||||
|
class KycSessionMissingUserTokenException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=409,message='KYC session has no user token',error_code='kyc_session_missing_user_token')
|
||||||
|
|
||||||
|
|
||||||
|
class KycNotCompletedException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=409,message='KYC is not completed yet',error_code='kyc_not_completed')
|
||||||
|
|
||||||
|
|
||||||
|
class KycFailedException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=400,message='KYC failed',error_code='kyc_failed')
|
||||||
|
|
||||||
|
|
||||||
|
class KycSessionExpiredException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=404,message='KYC session expired',error_code='kyc_session_expired')
|
||||||
|
|
||||||
|
|
||||||
|
class KycPersonalDataIncompleteException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=422,message='KYC personal data is incomplete',error_code='kyc_personal_data_incomplete')
|
||||||
|
|
||||||
|
|
||||||
|
class KycBirthDateInvalidException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=422,message='KYC birth date has invalid format',error_code='kyc_birth_date_invalid')
|
||||||
|
|
||||||
|
|
||||||
|
class KycAgeRestrictedException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=403,message='KYC is unavailable for users under 18',error_code='kyc_age_restricted')
|
||||||
|
|
||||||
|
|
||||||
|
class BeorgUnavailableException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=502,message='Beorg service unavailable',error_code='beorg_unavailable')
|
||||||
|
|
||||||
|
|
||||||
|
class BeorgRejectedException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self,message: str = 'Beorg rejected kyc request') -> None:
|
||||||
|
super().__init__(status_code=400,message=message,error_code='beorg_rejected')
|
||||||
|
|
||||||
|
|
||||||
|
class BeorgConfigException(ApplicationException):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(status_code=500,message='Beorg service is not configured',error_code='beorg_not_configured')
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
from datetime import date,datetime
|
from datetime import date,datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from src.application.domain.dto import KycPersonalData
|
from src.application.domain.dto import KycPersonalData
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import KycAgeRestrictedException,KycBirthDateInvalidException,KycPersonalDataIncompleteException
|
||||||
|
|
||||||
|
|
||||||
FIELD_ALIASES = {
|
FIELD_ALIASES = {
|
||||||
@@ -33,7 +33,7 @@ def extract_personal_data(data: Any) -> KycPersonalData:
|
|||||||
|
|
||||||
missing = [field for field in ('first_name','last_name','birth_date') if not values.get(field)]
|
missing = [field for field in ('first_name','last_name','birth_date') if not values.get(field)]
|
||||||
if missing:
|
if missing:
|
||||||
raise ApplicationException(status_code=422,message='KYC personal data is incomplete')
|
raise KycPersonalDataIncompleteException()
|
||||||
|
|
||||||
return KycPersonalData(
|
return KycPersonalData(
|
||||||
first_name=values['first_name'],
|
first_name=values['first_name'],
|
||||||
@@ -51,7 +51,7 @@ def ensure_adult(birth_date: date) -> None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
adult_from = date(today.year - 18,2,28)
|
adult_from = date(today.year - 18,2,28)
|
||||||
if birth_date > adult_from:
|
if birth_date > adult_from:
|
||||||
raise ApplicationException(status_code=403,message='KYC is unavailable for users under 18')
|
raise KycAgeRestrictedException()
|
||||||
|
|
||||||
|
|
||||||
def parse_birth_date(value: str) -> date:
|
def parse_birth_date(value: str) -> date:
|
||||||
@@ -84,4 +84,4 @@ def _parse_date(value: str) -> date:
|
|||||||
return datetime.strptime(clean,date_format).date()
|
return datetime.strptime(clean,date_format).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
raise ApplicationException(status_code=422,message='KYC birth date has invalid format')
|
raise KycBirthDateInvalidException()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import Any
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
from src.application.contracts import IBeorgService
|
from src.application.contracts import IBeorgService
|
||||||
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse
|
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import BeorgConfigException,BeorgRejectedException,BeorgUnavailableException
|
||||||
|
|
||||||
|
|
||||||
class BeorgService(IBeorgService):
|
class BeorgService(IBeorgService):
|
||||||
@@ -49,11 +49,11 @@ class BeorgService(IBeorgService):
|
|||||||
data = await response.json(content_type=None)
|
data = await response.json(content_type=None)
|
||||||
|
|
||||||
if response.status >= 500:
|
if response.status >= 500:
|
||||||
raise ApplicationException(status_code=502,message='Beorg service unavailable')
|
raise BeorgUnavailableException()
|
||||||
|
|
||||||
result = BeorgKycCreateResponse.model_validate(data)
|
result = BeorgKycCreateResponse.model_validate(data)
|
||||||
if not result.status:
|
if not result.status:
|
||||||
raise ApplicationException(status_code=400,message=result.error or 'Beorg rejected kyc request')
|
raise BeorgRejectedException(result.error or 'Beorg rejected kyc request')
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -73,11 +73,11 @@ class BeorgService(IBeorgService):
|
|||||||
data = await response.json(content_type=None)
|
data = await response.json(content_type=None)
|
||||||
|
|
||||||
if response.status >= 500:
|
if response.status >= 500:
|
||||||
raise ApplicationException(status_code=502,message='Beorg service unavailable')
|
raise BeorgUnavailableException()
|
||||||
|
|
||||||
return BeorgKycResultResponse.model_validate(data)
|
return BeorgKycResultResponse.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_configured(self) -> None:
|
def _ensure_configured(self) -> None:
|
||||||
if not self._project_id or not self._machine_uid or not self._token or not self._process_info:
|
if not self._project_id or not self._machine_uid or not self._token or not self._process_info:
|
||||||
raise ApplicationException(status_code=500,message='Beorg service is not configured')
|
raise BeorgConfigException()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from src.application.abstractions.repositories import IUserRepository
|
from src.application.abstractions.repositories import IUserRepository
|
||||||
from src.application.domain.entities import UserEntity
|
from src.application.domain.entities import UserEntity
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import UserNotFoundException
|
||||||
from src.infrastructure.database.models.user import UserModel
|
from src.infrastructure.database.models.user import UserModel
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ class UserRepository(IUserRepository):
|
|||||||
result = await self._session.execute(select(UserModel).where(UserModel.email == email))
|
result = await self._session.execute(select(UserModel).where(UserModel.email == email))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise ApplicationException(status_code=404,message='User not found')
|
raise UserNotFoundException()
|
||||||
return self._to_entity(user)
|
return self._to_entity(user)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ class UserRepository(IUserRepository):
|
|||||||
) -> UserEntity:
|
) -> UserEntity:
|
||||||
user = await self._session.get(UserModel,user_id)
|
user = await self._session.get(UserModel,user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise ApplicationException(status_code=404,message='User not found')
|
raise UserNotFoundException()
|
||||||
|
|
||||||
user.first_name = first_name
|
user.first_name = first_name
|
||||||
user.last_name = last_name
|
user.last_name = last_name
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
from jose import jwt, ExpiredSignatureError, JWTError
|
from jose import jwt, ExpiredSignatureError, JWTError
|
||||||
from src.application.contracts import ILogger, IJwtService
|
from src.application.contracts import ILogger, IJwtService
|
||||||
from src.application.domain.dto import AccessTokenPayload
|
from src.application.domain.dto import AccessTokenPayload
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException,InvalidTokenException,JwtDecodeFailedException
|
||||||
from src.infrastructure.config.settings import settings
|
from src.infrastructure.config.settings import settings
|
||||||
from src.infrastructure.vault import JwtKeyStore
|
from src.infrastructure.vault import JwtKeyStore
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ class JwtService(IJwtService):
|
|||||||
|
|
||||||
if payload.get('type') != 'access':
|
if payload.get('type') != 'access':
|
||||||
self._logger.warning(f'Access token invalid type received_type={payload.get("type")}')
|
self._logger.warning(f'Access token invalid type received_type={payload.get("type")}')
|
||||||
raise ApplicationException(status_code=401, message='Invalid token type')
|
raise InvalidTokenException('Invalid token type')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return AccessTokenPayload(
|
return AccessTokenPayload(
|
||||||
@@ -32,7 +32,7 @@ class JwtService(IJwtService):
|
|||||||
)
|
)
|
||||||
except KeyError as exception:
|
except KeyError as exception:
|
||||||
self._logger.warning(f'Access token missing claim error={str(exception)}')
|
self._logger.warning(f'Access token missing claim error={str(exception)}')
|
||||||
raise ApplicationException(status_code=401, message=f'Missing token claim: {exception}')
|
raise InvalidTokenException(f'Missing token claim: {exception}')
|
||||||
|
|
||||||
async def _decode_and_verify(self, token: str) -> dict:
|
async def _decode_and_verify(self, token: str) -> dict:
|
||||||
kid: str | None = None
|
kid: str | None = None
|
||||||
@@ -42,12 +42,12 @@ class JwtService(IJwtService):
|
|||||||
kid = header.get('kid')
|
kid = header.get('kid')
|
||||||
if not kid:
|
if not kid:
|
||||||
self._logger.warning(f'JWT header missing kid header={header}')
|
self._logger.warning(f'JWT header missing kid header={header}')
|
||||||
raise ApplicationException(status_code=401, message='Missing token header: kid')
|
raise InvalidTokenException('Missing token header: kid')
|
||||||
|
|
||||||
received_alg = header.get('alg')
|
received_alg = header.get('alg')
|
||||||
if received_alg != settings.JWT_ALGORITHM:
|
if received_alg != settings.JWT_ALGORITHM:
|
||||||
self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_alg={settings.JWT_ALGORITHM}')
|
self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_alg={settings.JWT_ALGORITHM}')
|
||||||
raise ApplicationException(status_code=401, message='Invalid token algorithm')
|
raise InvalidTokenException('Invalid token algorithm')
|
||||||
|
|
||||||
public_pem = await self._key_store.get_public_key_for_kid(str(kid))
|
public_pem = await self._key_store.get_public_key_for_kid(str(kid))
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ class JwtService(IJwtService):
|
|||||||
|
|
||||||
if not public_pem:
|
if not public_pem:
|
||||||
self._logger.warning(f'JWT unknown kid kid={kid}')
|
self._logger.warning(f'JWT unknown kid kid={kid}')
|
||||||
raise ApplicationException(status_code=401, message='Unknown token kid')
|
raise InvalidTokenException('Unknown token kid')
|
||||||
|
|
||||||
options = {
|
options = {
|
||||||
'verify_signature': True,
|
'verify_signature': True,
|
||||||
@@ -85,25 +85,25 @@ class JwtService(IJwtService):
|
|||||||
|
|
||||||
if 'sid' not in payload:
|
if 'sid' not in payload:
|
||||||
self._logger.warning(f'JWT missing sid claim kid={kid}')
|
self._logger.warning(f'JWT missing sid claim kid={kid}')
|
||||||
raise ApplicationException(status_code=401, message='Missing token claim: sid')
|
raise InvalidTokenException('Missing token claim: sid')
|
||||||
|
|
||||||
if 'type' not in payload:
|
if 'type' not in payload:
|
||||||
self._logger.warning(f'JWT missing type claim kid={kid}')
|
self._logger.warning(f'JWT missing type claim kid={kid}')
|
||||||
raise ApplicationException(status_code=401, message='Missing token claim: type')
|
raise InvalidTokenException('Missing token claim: type')
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
except ExpiredSignatureError as exception:
|
except ExpiredSignatureError as exception:
|
||||||
self._logger.info(f'JWT expired kid={kid} error={str(exception)}')
|
self._logger.info(f'JWT expired kid={kid} error={str(exception)}')
|
||||||
raise ApplicationException(status_code=401, message='Token expired')
|
raise InvalidTokenException('Token expired')
|
||||||
|
|
||||||
except ApplicationException:
|
except ApplicationException:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except JWTError as exception:
|
except JWTError as exception:
|
||||||
self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}')
|
self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}')
|
||||||
raise ApplicationException(status_code=401, message='Invalid token')
|
raise InvalidTokenException()
|
||||||
|
|
||||||
except Exception as exception:
|
except Exception as exception:
|
||||||
self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}')
|
self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}')
|
||||||
raise ApplicationException(status_code=500, message='JWT decode failed')
|
raise JwtDecodeFailedException()
|
||||||
16
src/main.py
16
src/main.py
@@ -3,13 +3,14 @@ from contextlib import asynccontextmanager
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from fastapi import Depends, FastAPI, status
|
from fastapi import Depends,FastAPI
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
from src.application.commands import PollKycSessionsCommand
|
from src.application.commands import PollKycSessionsCommand
|
||||||
from src.application.domain.enums import LogFormat,LogLevel
|
from src.application.domain.enums import LogFormat,LogLevel
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException,UnauthorizedException
|
||||||
from src.infrastructure.beorg import BeorgService
|
from src.infrastructure.beorg import BeorgService
|
||||||
from src.infrastructure.config.settings import get_settings
|
from src.infrastructure.config.settings import get_settings
|
||||||
from src.infrastructure.database.context import async_session_maker
|
from src.infrastructure.database.context import async_session_maker
|
||||||
@@ -18,7 +19,7 @@ from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler
|
|||||||
from src.infrastructure.utils import generate_instance_id
|
from src.infrastructure.utils import generate_instance_id
|
||||||
from src.infrastructure.logger import logger
|
from src.infrastructure.logger import logger
|
||||||
from src.infrastructure.config import settings
|
from src.infrastructure.config import settings
|
||||||
from src.presentation.handlers import application_exception_handler, unhandled_exception_handler
|
from src.presentation.handlers import application_exception_handler,unhandled_exception_handler,validation_exception_handler
|
||||||
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
|
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
|
||||||
from src.presentation.routing import kyc_router
|
from src.presentation.routing import kyc_router
|
||||||
|
|
||||||
@@ -29,11 +30,9 @@ async def verify_credentials(credentials: HTTPBasicCredentials = Depends(securit
|
|||||||
user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME)
|
user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME)
|
||||||
pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD)
|
pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD)
|
||||||
if not (user_ok and pass_ok):
|
if not (user_ok and pass_ok):
|
||||||
raise ApplicationException(
|
exception = UnauthorizedException()
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
exception.headers = {'WWW-Authenticate': 'Basic'}
|
||||||
message='Unauthorized',
|
raise exception
|
||||||
headers={'WWW-Authenticate': 'Basic'},
|
|
||||||
)
|
|
||||||
return credentials
|
return credentials
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +96,7 @@ app: FastAPI = FastAPI(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.add_exception_handler(ApplicationException, application_exception_handler)
|
app.add_exception_handler(ApplicationException, application_exception_handler)
|
||||||
|
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||||
|
|
||||||
app.include_router(kyc_router)
|
app.include_router(kyc_router)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import Depends, Request
|
from fastapi import Depends, Request
|
||||||
from fastapi.security.utils import get_authorization_scheme_param
|
from fastapi.security.utils import get_authorization_scheme_param
|
||||||
from src.application.contracts import IJwtService
|
from src.application.contracts import IJwtService
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import InvalidTokenException,NotAuthenticatedException
|
||||||
from src.application.domain.dto import AccessTokenPayload, AuthContext
|
from src.application.domain.dto import AccessTokenPayload, AuthContext
|
||||||
from src.presentation.dependencies import get_jwt_service
|
from src.presentation.dependencies import get_jwt_service
|
||||||
|
|
||||||
@@ -27,10 +27,10 @@ async def require_access_token(
|
|||||||
) -> AuthContext:
|
) -> AuthContext:
|
||||||
token = _extract_access_token(request)
|
token = _extract_access_token(request)
|
||||||
if not token:
|
if not token:
|
||||||
raise ApplicationException(status_code=401, message='Not authenticated')
|
raise NotAuthenticatedException()
|
||||||
|
|
||||||
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
|
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
|
||||||
if payload.type != 'access':
|
if payload.type != 'access':
|
||||||
raise ApplicationException(status_code=401, message='Invalid token type')
|
raise InvalidTokenException('Invalid token type')
|
||||||
|
|
||||||
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
|
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import inspect
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Callable, Awaitable, Any, Optional, Annotated
|
from typing import Callable, Awaitable, Any, Optional, Annotated
|
||||||
from fastapi import Request, Header
|
from fastapi import Request, Header
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import CsrfRequestRequiredException
|
||||||
from src.infrastructure.security import CsrfService
|
from src.infrastructure.security import CsrfService
|
||||||
|
|
||||||
|
|
||||||
@@ -39,10 +39,7 @@ def csrf_protect(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if request is None:
|
if request is None:
|
||||||
raise ApplicationException(
|
raise CsrfRequestRequiredException()
|
||||||
status_code=500,
|
|
||||||
message='Request is required for CSRF protection',
|
|
||||||
)
|
|
||||||
|
|
||||||
csrf = CsrfService()
|
csrf = CsrfService()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import ORJSONResponse
|
from fastapi.responses import ORJSONResponse
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException
|
||||||
|
|
||||||
@@ -6,13 +7,35 @@ from src.application.domain.exceptions import ApplicationException
|
|||||||
async def application_exception_handler(request: Request,exception: ApplicationException) -> ORJSONResponse:
|
async def application_exception_handler(request: Request,exception: ApplicationException) -> ORJSONResponse:
|
||||||
return ORJSONResponse(
|
return ORJSONResponse(
|
||||||
status_code=exception.status_code,
|
status_code=exception.status_code,
|
||||||
content={'detail': exception.message},
|
content={
|
||||||
|
'error': {
|
||||||
|
'code': exception.error_code,
|
||||||
|
'message': exception.message,
|
||||||
|
},
|
||||||
|
},
|
||||||
headers=exception.headers,
|
headers=exception.headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validation_exception_handler(request: Request,exception: RequestValidationError) -> ORJSONResponse:
|
||||||
|
return ORJSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={
|
||||||
|
'error': {
|
||||||
|
'code': 'request_validation_error',
|
||||||
|
'message': 'Request validation error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def unhandled_exception_handler(request: Request,exception: Exception) -> ORJSONResponse:
|
async def unhandled_exception_handler(request: Request,exception: Exception) -> ORJSONResponse:
|
||||||
return ORJSONResponse(
|
return ORJSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={'detail': 'Internal server error'},
|
content={
|
||||||
|
'error': {
|
||||||
|
'code': 'internal_server_error',
|
||||||
|
'message': 'Internal server error',
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,27 +1,81 @@
|
|||||||
from fastapi import APIRouter,Depends
|
from fastapi import APIRouter,Depends
|
||||||
from fastapi.responses import ORJSONResponse
|
|
||||||
from src.application.commands import GetKycSessionCommand,PassKycCommand
|
from src.application.commands import GetKycSessionCommand,PassKycCommand
|
||||||
from src.application.domain.dto import AuthContext
|
from src.application.domain.dto import AuthContext,BeorgKycCreateResponse,KycSessionResponse
|
||||||
from src.presentation.decorators.auth import require_access_token
|
from src.presentation.decorators.auth import require_access_token
|
||||||
from src.presentation.dependencies.commands import get_kyc_session_command,get_pass_kyc_command
|
from src.presentation.dependencies.commands import get_kyc_session_command,get_pass_kyc_command
|
||||||
|
from src.presentation.schemas import ErrorResponse
|
||||||
|
|
||||||
|
|
||||||
kyc_router = APIRouter(prefix='/kyc', tags=['Kyc'])
|
kyc_router = APIRouter(prefix='/kyc', tags=['Kyc'])
|
||||||
|
|
||||||
|
CREATE_KYC_RESPONSES = {
|
||||||
|
400: {
|
||||||
|
'model': ErrorResponse,
|
||||||
|
'description': 'Beorg rejected request. error.code: beorg_rejected',
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
'model': ErrorResponse,
|
||||||
|
'description': 'Authentication error. error.code: not_authenticated, invalid_token',
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
'model': ErrorResponse,
|
||||||
|
'description': 'Configuration or internal error. error.code: beorg_not_configured, internal_server_error',
|
||||||
|
},
|
||||||
|
502: {
|
||||||
|
'model': ErrorResponse,
|
||||||
|
'description': 'Beorg is unavailable. error.code: beorg_unavailable',
|
||||||
|
},
|
||||||
|
422: {
|
||||||
|
'model': ErrorResponse,
|
||||||
|
'description': 'Request validation error. error.code: request_validation_error',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@kyc_router.post('/create')
|
GET_KYC_SESSION_RESPONSES = {
|
||||||
|
401: {
|
||||||
|
'model': ErrorResponse,
|
||||||
|
'description': 'Authentication error. error.code: not_authenticated, invalid_token',
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
'model': ErrorResponse,
|
||||||
|
'description': 'Active KYC session was not found. error.code: kyc_session_expired',
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
'model': ErrorResponse,
|
||||||
|
'description': 'Internal error. error.code: internal_server_error',
|
||||||
|
},
|
||||||
|
422: {
|
||||||
|
'model': ErrorResponse,
|
||||||
|
'description': 'Request validation error. error.code: request_validation_error',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@kyc_router.post(
|
||||||
|
'/create',
|
||||||
|
response_model=BeorgKycCreateResponse,
|
||||||
|
responses=CREATE_KYC_RESPONSES,
|
||||||
|
summary='Start KYC session',
|
||||||
|
description='Creates a Beorg KYC session for one hour and returns link, user token and QR code.',
|
||||||
|
)
|
||||||
async def create_kyc(
|
async def create_kyc(
|
||||||
#auth: AuthContext = Depends(require_access_token),
|
#auth: AuthContext = Depends(require_access_token),
|
||||||
command: PassKycCommand = Depends(get_pass_kyc_command),
|
command: PassKycCommand = Depends(get_pass_kyc_command),
|
||||||
) -> ORJSONResponse:
|
) -> BeorgKycCreateResponse:
|
||||||
result = await command(user_id='01KPKAFN6J1NJBY15DX8JE2QYB')
|
result = await command(user_id='01KPKAFN6J1NJBY15DX8JE2QYB')
|
||||||
return ORJSONResponse(result.model_dump())
|
return result
|
||||||
|
|
||||||
|
|
||||||
@kyc_router.get('/session')
|
@kyc_router.get(
|
||||||
|
'/session',
|
||||||
|
response_model=KycSessionResponse,
|
||||||
|
responses=GET_KYC_SESSION_RESPONSES,
|
||||||
|
summary='Get KYC session',
|
||||||
|
description='Returns latest KYC session status, link, QR code and expiration data.',
|
||||||
|
)
|
||||||
async def get_kyc_session(
|
async def get_kyc_session(
|
||||||
#auth: AuthContext = Depends(require_access_token),
|
#auth: AuthContext = Depends(require_access_token),
|
||||||
command: GetKycSessionCommand = Depends(get_kyc_session_command),
|
command: GetKycSessionCommand = Depends(get_kyc_session_command),
|
||||||
) -> ORJSONResponse:
|
) -> KycSessionResponse:
|
||||||
result = await command(user_id='01KPKAFN6J1NJBY15DX8JE2QYB')
|
result = await command(user_id='01KPKAFN6J1NJBY15DX8JE2QYB')
|
||||||
return ORJSONResponse(result.model_dump())
|
return result
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from src.presentation.schemas.error import ErrorDetail,ErrorResponse
|
||||||
|
|||||||
10
src/presentation/schemas/error.py
Normal file
10
src/presentation/schemas/error.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorDetail(BaseModel):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
error: ErrorDetail
|
||||||
Reference in New Issue
Block a user