diff --git a/.gitignore b/.gitignore index d8dcc85..ddbf74a 100644 --- a/.gitignore +++ b/.gitignore @@ -131,4 +131,7 @@ dmypy.json .idea/ # ELK -logstash.db \ No newline at end of file +logstash.db + +# DB +db/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fca39..8be166b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,12 @@ straightforward as possible. ### Added - [Initial API & Project Structure](https://github.com/kevencript/embed-backend-devops/commit/5bc6bb790bf645f63f62173fa7534b10e047656a) - Initial project structure (FastAPI & Mongo deployment with Docker-compose and basic requirements) + Initial project structure (FastAPI & Mongo deployment with Docker-compose and basic requirements) - [ELK Application Logging #1](https://github.com/kevencript/embed-backend-devops/commit/4bfbbb5b8ea410b221f8f8f9d07d6982ebb61a26) - Here we deploy/config the [ELK Stack](https://www.elastic.co/pt/elastic-stack/) in order to store, search, analyze, and visualize data from our Python FastAPI application. + Here we deploy/config the [ELK Stack](https://www.elastic.co/pt/elastic-stack/) in order to store, search, analyze, and visualize data from our Python FastAPI application. Beside of the ELK stack, we created the logs injection from our app with python-logstash (since that the API already have the logging structure, basically we added the logstash handler in order to inject the data into the Elasticsearch), making the logs available into Kibana. +- [JWT Authentication Method #2](https://github.com/kevencript/embed-backend-devops/CHANGE-ME) + Initial project structure (FastAPI & Mongo deployment with Docker-compose and basic requirements) ### Changed diff --git a/docker-compose.yml b/docker-compose.yml index ab7d18d..a1182d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: - .env ports: - "27017:27017" + volumes: + - ./db:/data/db:rw environment: - "MONGO_INITDB_DATABASE=${MONGO_DB}" - "MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}" diff --git a/embed/config.py b/embed/config.py index 14de6c3..7d561fa 100644 --- a/embed/config.py +++ b/embed/config.py @@ -21,18 +21,27 @@ class Settings(BaseSettings): instance of Settings """ - + # Env Configs environment: str = os.getenv("ENVIRONMENT", "local") testing: str = os.getenv("TESTING", "0") up: str = os.getenv("UP", "up") down: str = os.getenv("DOWN", "down") web_server: str = os.getenv("WEB_SERVER", "web_server") + # MongoDB Configs db_url: str = os.getenv("MONGO_URL", "") db_name: str = os.getenv("MONGO_DB", "") collection: str = os.getenv("MONGO_COLLECTION", "") test_db_name: str = os.getenv("MONGO_TEST_DB", "") + # JWT Authentication Configs + JWT_PUBLIC_KEY: str = os.getenv("JWT_PUBLIC_KEY", "") + JWT_PRIVATE_KEY: str = os.getenv("JWT_PRIVATE_KEY", "") + REFRESH_TOKEN_EXPIRES_IN: int = os.getenv("REFRESH_TOKEN_EXPIRES_IN", "") + ACCESS_TOKEN_EXPIRES_IN: int = os.getenv("ACCESS_TOKEN_EXPIRES_IN", "") + JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "") + CLIENT_ORIGIN: str = os.getenv("CLIENT_ORIGIN", "") + @lru_cache def get_settings(): diff --git a/embed/main.py b/embed/main.py index d23c5f4..8aafd88 100644 --- a/embed/main.py +++ b/embed/main.py @@ -1,5 +1,5 @@ from fastapi import FastAPI - +from fastapi.middleware.cors import CORSMiddleware from embed import config from embed.routers import router as v1 from embed.services.repository import get_mongo_meta @@ -12,6 +12,18 @@ app = FastAPI() +origins = [ + global_settings.CLIENT_ORIGIN, +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + app.include_router(v1, prefix="/api/v1") diff --git a/embed/oauth2.py b/embed/oauth2.py new file mode 100644 index 0000000..a221345 --- /dev/null +++ b/embed/oauth2.py @@ -0,0 +1,70 @@ +import base64 +from typing import List +from fastapi import Depends, HTTPException, status +from fastapi_jwt_auth import AuthJWT +from pydantic import BaseModel +from bson.objectid import ObjectId + +from embed.serializers.userSerializers import userEntity +from embed.services.repository import mail_exists, retrieve_document +from embed.utils import get_logger + + +from embed import config + + +global_settings = config.get_settings() + +class SettingsAuth(BaseModel): + authjwt_algorithm: str = global_settings.JWT_ALGORITHM + authjwt_decode_algorithms: List[str] = [global_settings.JWT_ALGORITHM] + #authjwt_token_location: set = {'cookies', 'headers'} + authjwt_access_cookie_key: str = 'access_token' + authjwt_refresh_cookie_key: str = 'refresh_token' + authjwt_cookie_csrf_protect: bool = False + authjwt_public_key: str = base64.b64decode( + global_settings.JWT_PUBLIC_KEY).decode('utf-8') + authjwt_private_key: str = base64.b64decode( + global_settings.JWT_PRIVATE_KEY).decode('utf-8') + + +@AuthJWT.load_config +def get_config(): + return SettingsAuth() + +class NotVerified(Exception): + pass + + +class UserNotFound(Exception): + pass + + +async def require_user(Authorize: AuthJWT = Depends()): + try: + Authorize.jwt_required() + user_id = Authorize.get_jwt_subject() + + # Validating if user really exists + notSerializedUser = await retrieve_document(ObjectId(str(user_id)), global_settings.collection) + user = userEntity(notSerializedUser) + + if not user: + raise UserNotFound('User no longer exist') + + except Exception as e: + error = e.__class__.__name__ + print(error) + if error == 'MissingTokenError': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail='You are not logged in') + if error == 'UserNotFound': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail='User no longer exist') + if error == 'NotVerified': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail='Please verify your account') + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail='Token is invalid or has expired') + return user_id + diff --git a/embed/routers/__init__.py b/embed/routers/__init__.py new file mode 100644 index 0000000..1de02ac --- /dev/null +++ b/embed/routers/__init__.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from embed.routers.v1.vegs import router as vegs_api +from embed.routers.v1.auth import router as auth_api + +router = APIRouter() + +router.include_router(vegs_api, prefix="/vegs", tags=["vegetables"]) +router.include_router(auth_api, prefix="/auth", tags=["Auth"]) diff --git a/embed/routers/exceptions.py b/embed/routers/exceptions.py new file mode 100644 index 0000000..3c18296 --- /dev/null +++ b/embed/routers/exceptions.py @@ -0,0 +1,18 @@ +from fastapi import HTTPException, status + + +class NotFoundHTTPException(HTTPException): + def __init__(self, msg: str): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail=msg if msg else "Requested resource is not found", + ) + + +class AlreadyExistsHTTPException(HTTPException): + def __init__(self, msg: str): + super().__init__( + status_code=status.HTTP_409_CONFLICT, + detail=msg if msg else "Document with specified id already exists", + ) + diff --git a/embed/routers/v1/__init__.py b/embed/routers/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/embed/routers/v1/auth.py b/embed/routers/v1/auth.py new file mode 100644 index 0000000..391ac05 --- /dev/null +++ b/embed/routers/v1/auth.py @@ -0,0 +1,103 @@ +from os import access +from fastapi import APIRouter, Response, status, Depends, HTTPException +from datetime import datetime, timedelta +from bson.objectid import ObjectId + +from embed import config + +from embed.utils import hash_password, verify_password +from embed.routers.exceptions import NotFoundHTTPException +from embed.services.repository import create_user_db, mail_exists, retrieve_document +from embed.serializers.userSerializers import userResponseEntity, userEntity +from embed.schemas.users import LoginUserSchema,CreateUserSchema, UserResponse +from embed.oauth2 import AuthJWT, require_user + +global_settings = config.get_settings() +collection = global_settings.collection + +router = APIRouter() + +ACCESS_TOKEN_EXPIRES_IN = global_settings.ACCESS_TOKEN_EXPIRES_IN +REFRESH_TOKEN_EXPIRES_IN = global_settings.REFRESH_TOKEN_EXPIRES_IN + +@router.post( + '/register', + status_code=status.HTTP_201_CREATED, + response_model=UserResponse +) +async def create_user(payload: CreateUserSchema, Authorize: AuthJWT = Depends()): + """ + + :param payload: + :return: + """ + try: + # Check if user already exist + mailAlreadyExist = await mail_exists(payload.email.lower(), collection) + if mailAlreadyExist: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, + detail='Account already exist') + + # Compare password and passwordConfirm + if payload.password != payload.passwordConfirm: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail='Passwords do not match') + + # Hash the password + payload.password = hash_password(payload.password) + del payload.passwordConfirm + payload.verified = True + payload.email = payload.email.lower() + payload.created_at = datetime.utcnow() + + userDict = payload.dict() + + # Inserting user into the DB + createdUser = await create_user_db(userDict, collection) + userToReturn = userResponseEntity(createdUser) # Serialized pattern for return + + # Create access token + access_token = Authorize.create_access_token( + subject=str(userToReturn["id"]), expires_time=timedelta(minutes=ACCESS_TOKEN_EXPIRES_IN)) + + return {"status": "success", "access_token":access_token, "user": userToReturn} + except ValueError as exception: + raise NotFoundHTTPException(msg=str(exception)) + + +@router.post('/login', status_code=status.HTTP_200_OK, ) +async def login(payload: LoginUserSchema, Authorize: AuthJWT = Depends()): + """ + + :param payload: + :param response: + :param Authorize: + :return: + """ + try: + + notSerializedUser = await mail_exists(payload.email.lower(), collection) + if not notSerializedUser: + raise NotFoundHTTPException('Incorrect Email or Password') + + # Check if the user exist + user = userEntity(notSerializedUser) + + # Check if the password is valid + if not verify_password(payload.password, user['password']): + raise NotFoundHTTPException('Incorrect Email or Password') + + # Create access token + access_token = Authorize.create_access_token( + subject=str(user["id"]), expires_time=timedelta(minutes=ACCESS_TOKEN_EXPIRES_IN)) + + # Send both access + return {'status': 'success', 'access_token': access_token} + + except ValueError as exception: + raise NotFoundHTTPException(msg=str(exception)) + +@router.get('/profile', status_code=status.HTTP_200_OK) +async def protected(user_id: str = Depends(require_user)): + userAccount = await retrieve_document(document_id=user_id, collection=collection) + return userResponseEntity(userAccount) diff --git a/embed/routers/v1/vegs.py b/embed/routers/v1/vegs.py new file mode 100644 index 0000000..763ce78 --- /dev/null +++ b/embed/routers/v1/vegs.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from starlette.status import HTTP_201_CREATED + +from embed import config +from embed.routers.exceptions import NotFoundHTTPException +from embed.schemas.vegs import Document, DocumentResponse, ObjectIdField +from embed.services.repository import create_document, retrieve_document + +global_settings = config.get_settings() +collection = global_settings.collection + +router = APIRouter() + + +@router.post( + "", + status_code=HTTP_201_CREATED, + response_description="Document created", + response_model=DocumentResponse, +) +async def add_document(payload: Document): + """ + + :param payload: + :return: + """ + try: + payload = jsonable_encoder(payload) + return await create_document(payload, collection) + except ValueError as exception: + raise NotFoundHTTPException(msg=str(exception)) + + +@router.get( + "/{object_id}", + response_description="Document retrieved", + response_model=DocumentResponse, +) +async def get_document(object_id: ObjectIdField): + """ + + :param object_id: + :return: + """ + try: + return await retrieve_document(object_id, collection) + except ValueError as exception: + raise NotFoundHTTPException(msg=str(exception)) + + +# TODO: PUT for replace aka set PATCH for update ? diff --git a/embed/schemas/__init__.py b/embed/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/embed/schemas/users.py b/embed/schemas/users.py new file mode 100644 index 0000000..0f88a22 --- /dev/null +++ b/embed/schemas/users.py @@ -0,0 +1,33 @@ +from datetime import datetime +from pydantic import BaseModel, constr + + +class UserBaseSchema(BaseModel): + email: str + created_at: datetime | None = None + + class Config: + orm_mode = True + + +class CreateUserSchema(UserBaseSchema): + password: constr(min_length=8) + passwordConfirm: str + verified: bool = False + + +class LoginUserSchema(BaseModel): + email: str + password: constr(min_length=8) + + +class UserResponseSchema(UserBaseSchema): + id: str + pass + + +class UserResponse(BaseModel): + status: str + access_token: str + user: UserResponseSchema + diff --git a/embed/schemas/vegs.py b/embed/schemas/vegs.py new file mode 100644 index 0000000..21e1d9e --- /dev/null +++ b/embed/schemas/vegs.py @@ -0,0 +1,30 @@ +from bson import ObjectId +from bson.errors import InvalidId +from pydantic import BaseModel, Field + + +class ObjectIdField(str): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, value): + try: + return ObjectId(str(value)) + except InvalidId: + raise ValueError("Not a valid ObjectId") + + +class Document(BaseModel): + name: str = Field(...) + desc: str = Field(...) + + class Config: + allow_population_by_field_name = True + arbitrary_types_allowed = True + json_encoders = {ObjectId: str} + + +class DocumentResponse(Document): + id: ObjectIdField = Field(...) diff --git a/embed/serializers/userSerializers.py b/embed/serializers/userSerializers.py new file mode 100644 index 0000000..87aa19f --- /dev/null +++ b/embed/serializers/userSerializers.py @@ -0,0 +1,28 @@ +def userEntity(user) -> dict: + return { + "id": str(user["_id"]), + "email": user["email"], + "password": user["password"], + "created_at": user["created_at"], + + } + + +def userResponseEntity(user) -> dict: + return { + "id": str(user["_id"]), + "email": user["email"], + "created_at": user["created_at"], + } + + +def embeddedUserResponse(user) -> dict: + return { + "id": str(user["_id"]), + "email": user["email"], + } + + +def userListEntity(users) -> list: + return [userEntity(user) for user in users] + diff --git a/embed/services/repository.py b/embed/services/repository.py index ee33d48..e3fef99 100644 --- a/embed/services/repository.py +++ b/embed/services/repository.py @@ -1,14 +1,9 @@ +from datetime import date from bson import ObjectId from pymongo.errors import WriteError import embed.main as embed -from embed.routers.exceptions import AlreadyExistsHTTPException - - -async def document_id_helper(document: dict) -> dict: - document["id"] = document.pop("_id") - return document - +from embed.routers.exceptions import AlreadyExistsHTTPException, NotFoundHTTPException async def retrieve_document(document_id: str, collection: str) -> dict: """ @@ -19,10 +14,36 @@ async def retrieve_document(document_id: str, collection: str) -> dict: """ document_filter = {"_id": ObjectId(document_id)} if document := await embed.app.state.mongo_collection[collection].find_one(document_filter): - return await document_id_helper(document) + return document else: raise ValueError(f"No document found for {document_id=} in {collection=}") +async def mail_exists(email: str, collection: str) -> dict: + """ + + :param email: + :param collection: + :return: + """ + document_filter = {"email": email} + if document := await embed.app.state.mongo_collection[collection].find_one(document_filter): + return document + else: + return False + +async def create_user_db(document: dict, collection: str) -> dict: + """ + + :param document: + :param collection: + :return: + """ + try: + document = await embed.app.state.mongo_collection[collection].insert_one(document) + return await retrieve_document(document.inserted_id, collection) + except WriteError: + raise NotFoundHTTPException("Error while creating the user") + async def create_document(document: dict, collection: str) -> dict: """ diff --git a/embed/utils.py b/embed/utils.py index dfa8ff1..b50db06 100644 --- a/embed/utils.py +++ b/embed/utils.py @@ -1,13 +1,15 @@ -import logging +from passlib.context import CryptContext from functools import lru_cache -import logstash from motor.motor_asyncio import AsyncIOMotorClient from rich.console import Console from rich.logging import RichHandler -console = Console(color_system="256", width=150, style="blue") +import logging +import logstash +console = Console(color_system="256", width=150, style="blue") +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @lru_cache def get_logger(module_name): @@ -54,3 +56,9 @@ async def init_mongo(db_name: str, db_url: str, collection: str): } # return {0: mongo_client, 1: mongo_database, 2: mongo_collections} return mongo_client, mongo_database, mongo_collections + +def hash_password(password: str): + return pwd_context.hash(password) + +def verify_password(password: str, hashed_password: str): + return pwd_context.verify(password, hashed_password) diff --git a/requirements.txt b/requirements.txt index b5fd2dc..fb5a5ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -71,4 +71,6 @@ uvloop==0.16.0 watchfiles==0.16.1 websockets==10.3 python-logstash +passlib[bcrypt] +fastapi-jwt-auth[asymmetric]