diff --git a/lib/api_doc_response.py b/lib/api_doc_response.py new file mode 100644 index 0000000..22a7ae6 --- /dev/null +++ b/lib/api_doc_response.py @@ -0,0 +1,5 @@ +def api_doc_response(example: dict, description: str | None = None) -> dict: + return { + "description": description, + "content": {"application/json": {"examples": example}}, + } diff --git a/lib/schema.py b/lib/schema.py new file mode 100644 index 0000000..506bccd --- /dev/null +++ b/lib/schema.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class ResponseSuccess(BaseModel): + success: bool = True + reason: None = None diff --git a/projects/account_password_management/src/api/account/__init__.py b/projects/account_password_management/src/api/account/__init__.py new file mode 100644 index 0000000..f2c046b --- /dev/null +++ b/projects/account_password_management/src/api/account/__init__.py @@ -0,0 +1 @@ +from src.api.account import controller, query, routers diff --git a/projects/account_password_management/src/api/account/controller.py b/projects/account_password_management/src/api/account/controller.py new file mode 100644 index 0000000..cd79eaf --- /dev/null +++ b/projects/account_password_management/src/api/account/controller.py @@ -0,0 +1,86 @@ +import re + +from sqlalchemy.orm import Session +from src.api import account +from src.schema.account import Account as AccountSchema + +from lib.exceptions import CustomHTTPException, UnprocessableEntityException + +MIN_USERNAME_LEN = 3 +MAX_USERNAME_LEN = 32 +MIN_PASSWORD_LEN = 8 +MAX_PASSWORD_LEN = 32 +USERNAME_TOO_SHORT_REASON = "Username is too short" +USERNAME_TOO_LONG_REASON = "Username is too long" +PASSWORD_TOO_SHORT_REASON = "Password is too short" +PASSWORD_TOO_LONG_REASON = "Password is too long" +PASSWORD_COMPONENT_NOT_CORRECT_REASON = ( + "Password not contain at least 1 uppercase letter, 1 lowercase letter, and 1 number" +) +AT_LEAST_ONE_UPPERCASE_LETTER_ONE_LOWERCASE_LETTER_ONE_NUMBER_REGEX = ( + r"^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).+$" +) + + +class Account: + def __init__(self, account: AccountSchema) -> None: + self.__account: AccountSchema = account + + async def create_account(self) -> None: + try: + await self.__verify_created_account_format() + await self.__hash_password() + await account.query.create_account() + + except CustomHTTPException as exception: + raise exception + + async def __verify_created_account_format(self) -> None: + try: + await self.__verify_username_format() + await self.__verify_password_format() + + except UnprocessableEntityException as exception: + raise exception + + async def __verify_username_format(self) -> None: + try: + await self.__verify_username_len(len(self.__account.username)) + + except UnprocessableEntityException as exception: + raise exception + + async def __verify_username_len(self, username_len: int) -> None: + if username_len < MIN_USERNAME_LEN: + raise UnprocessableEntityException(detail=USERNAME_TOO_SHORT_REASON) + + elif username_len > MAX_USERNAME_LEN: + raise UnprocessableEntityException(detail=USERNAME_TOO_LONG_REASON) + + async def __verify_password_format(self) -> None: + try: + await self.__verify_password_len(len(self.__account.password)) + await self.__verify_password_component(self.__account.password) + + except UnprocessableEntityException as exception: + raise exception + + async def __verify_password_len(self, password_len: int) -> None: + if password_len < MIN_PASSWORD_LEN: + raise UnprocessableEntityException(detail=PASSWORD_TOO_SHORT_REASON) + + elif password_len > MAX_PASSWORD_LEN: + raise UnprocessableEntityException(detail=PASSWORD_TOO_LONG_REASON) + + async def __verify_password_component(self, password: str) -> None: + password_pattern = re.compile( + AT_LEAST_ONE_UPPERCASE_LETTER_ONE_LOWERCASE_LETTER_ONE_NUMBER_REGEX + ) + + if not password_pattern.match(password): + raise UnprocessableEntityException( + detail=PASSWORD_COMPONENT_NOT_CORRECT_REASON + ) + + async def __hash_password(self) -> None: + return diff --git a/projects/account_password_management/src/api/account/query.py b/projects/account_password_management/src/api/account/query.py new file mode 100644 index 0000000..9447241 --- /dev/null +++ b/projects/account_password_management/src/api/account/query.py @@ -0,0 +1,20 @@ +from lib.exceptions import ConflictException, NotFound + +USERNAME_NOT_EXISTS_REASON = "Username not exists" +USERNAME_ALREADY_EXISTS_REASON = "Username already exists" + + +async def create_account(account) -> None: + try: + return + + except: + raise ConflictException(detail=USERNAME_ALREADY_EXISTS_REASON) + + +async def get_account(username: str) -> None: + try: + return + + except: + raise NotFound(detail=USERNAME_NOT_EXISTS_REASON) diff --git a/projects/account_password_management/src/api/account/routers.py b/projects/account_password_management/src/api/account/routers.py new file mode 100644 index 0000000..971ca52 --- /dev/null +++ b/projects/account_password_management/src/api/account/routers.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, status +from fastapi.responses import JSONResponse +from src.api.account.controller import ( + PASSWORD_COMPONENT_NOT_CORRECT_REASON, + PASSWORD_TOO_LONG_REASON, + PASSWORD_TOO_SHORT_REASON, + USERNAME_TOO_LONG_REASON, + USERNAME_TOO_SHORT_REASON, + Account, +) +from src.api.account.query import USERNAME_ALREADY_EXISTS_REASON +from src.schema.account import Account as AccountSchema + +from lib.api_doc_response import api_doc_response +from lib.custom_response import failed_response, success_response +from lib.schema import ResponseSuccess + +router = APIRouter() + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + responses={ + status.HTTP_409_CONFLICT: api_doc_response( + example={ + "Username already exists": { + "value": failed_response( + reason=USERNAME_ALREADY_EXISTS_REASON, + ), + } + } + ), + status.HTTP_422_UNPROCESSABLE_ENTITY: api_doc_response( + example={ + "Username is too short": { + "value": failed_response( + reason=USERNAME_TOO_SHORT_REASON, + ), + }, + "Username is too long": { + "value": failed_response( + reason=USERNAME_TOO_LONG_REASON, + ), + }, + "Password is too short": { + "value": failed_response( + reason=PASSWORD_TOO_SHORT_REASON, + ), + }, + "Password is too long": { + "value": failed_response( + reason=PASSWORD_TOO_LONG_REASON, + ), + }, + "Password component is not correct": { + "value": failed_response( + reason=PASSWORD_COMPONENT_NOT_CORRECT_REASON, + ), + }, + }, + ), + }, +) +async def create_account( + account_data: AccountSchema, +) -> ResponseSuccess: + """ + - "username": a string representing the desired username for the account, with a minimum length of 3 characters and a maximum length of 32 characters. + - "password": a string representing the desired password for the account, with a minimum length of 8 characters and a maximum length of 32 characters, containing at least 1 uppercase letter, 1 lowercase letter, and 1 number. + """ + account = Account(account_data) + await account.create_account() + + return JSONResponse(status_code=status.HTTP_201_CREATED, content=success_response()) diff --git a/projects/account_password_management/src/api/authentication/__init__.py b/projects/account_password_management/src/api/authentication/__init__.py new file mode 100644 index 0000000..498b8bf --- /dev/null +++ b/projects/account_password_management/src/api/authentication/__init__.py @@ -0,0 +1 @@ +from src.api.authentication import routers diff --git a/projects/account_password_management/src/api/authentication/controller.py b/projects/account_password_management/src/api/authentication/controller.py new file mode 100644 index 0000000..d69dce1 --- /dev/null +++ b/projects/account_password_management/src/api/authentication/controller.py @@ -0,0 +1,36 @@ +from datetime import timedelta + + +from src.api.account.query import get_account +from src.schema.account import Account as AccountSchema + +from lib.exceptions import CustomHTTPException + +MAX_FAILED_VERIFICATION_ATTEMPTS = 5 +BLOCK_VERIFICATION_TIME = timedelta(minutes=1) +PASSWORD_NOT_CORRECT_REASON = "Password is not correct" +TOO_MANY_FAILED_VERIFICATION_ATTEMPTS_REASON = ( + "Too many failed verification attempts, blocking one minutes" +) + + +class Authentication: + def __init__(self, account: AccountSchema) -> None: + self.__input_account: AccountSchema = account + self.__db_account: None = None + + async def verify_account(self) -> None: + try: + self.__db_account = await get_account( + username=self.__input_account.username + ) + await self.__verify_verification_attempt() + await self.__verify_password() + except CustomHTTPException as exception: + raise exception + + async def __verify_verification_attempt(self) -> None: + return + + async def __verify_password(self) -> None: + return diff --git a/projects/account_password_management/src/api/authentication/routers.py b/projects/account_password_management/src/api/authentication/routers.py new file mode 100644 index 0000000..8da970f --- /dev/null +++ b/projects/account_password_management/src/api/authentication/routers.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, status +from src.api.account.query import USERNAME_NOT_EXISTS_REASON +from src.api.authentication.controller import ( + PASSWORD_NOT_CORRECT_REASON, + TOO_MANY_FAILED_VERIFICATION_ATTEMPTS_REASON, + Authentication, +) +from src.schema.account import Account + + +from lib.api_doc_response import api_doc_response +from lib.custom_response import failed_response, success_response + +from lib.schema import ResponseSuccess + +router = APIRouter() + + +@router.post( + "/verifications", + responses={ + status.HTTP_401_UNAUTHORIZED: api_doc_response( + example={ + "Password is not correct": { + "value": failed_response( + reason=PASSWORD_NOT_CORRECT_REASON, + ), + }, + "Too many failed verification attempts": { + "value": failed_response( + reason=TOO_MANY_FAILED_VERIFICATION_ATTEMPTS_REASON, + ), + }, + } + ), + status.HTTP_404_NOT_FOUND: api_doc_response( + example={ + "Username not exists": { + "value": failed_response( + reason=USERNAME_NOT_EXISTS_REASON, + ), + }, + } + ), + }, +) +async def verify_account( + account: Account, +) -> ResponseSuccess: + """ + - "username": a string representing the username of the account being accessed. + - "password": a string representing the password being used to access the account. If the password verification fails five times, the user should wait one minute before attempting to verify the password again. + """ + authentication = Authentication(account=account) + await authentication.verify_account() + + return success_response() diff --git a/projects/account_password_management/src/main.py b/projects/account_password_management/src/main.py index f45a384..5926756 100644 --- a/projects/account_password_management/src/main.py +++ b/projects/account_password_management/src/main.py @@ -1,10 +1,12 @@ from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError - +from src.api import account, authentication from lib.custom_http_exception import custom_http_exception_handler from lib.exceptions import CustomHTTPException, UnprocessableEntityException API_PREFIX = "api" +ACCOUNT_API_ROUTE = f"/{API_PREFIX}/accounts" +AUTHENTICATION_API_ROUTE = f"/{API_PREFIX}/authentications" app = FastAPI() @@ -16,3 +18,15 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE app.add_exception_handler(CustomHTTPException, custom_http_exception_handler) + +app.include_router( + authentication.routers.router, + prefix=AUTHENTICATION_API_ROUTE, + tags=["Authentication"], +) + +app.include_router( + account.routers.router, + prefix=ACCOUNT_API_ROUTE, + tags=["Account"], +) diff --git a/projects/account_password_management/src/schema/account.py b/projects/account_password_management/src/schema/account.py new file mode 100644 index 0000000..f0ddb7b --- /dev/null +++ b/projects/account_password_management/src/schema/account.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class Account(BaseModel): + username: str + password: str