diff --git a/app/dependencies/database.py b/app/dependencies/database.py index 7ff0550..cbf875c 100644 --- a/app/dependencies/database.py +++ b/app/dependencies/database.py @@ -1,5 +1,7 @@ from pymongo import MongoClient -import os +import os, redis client = MongoClient("mongodb+srv://{0}:{1}@{2}/?retryWrites=true&w=majority&appName=AtlasApp".format(os.environ["MONGO_USER"], os.environ["MONGO_PASSWORD"], os.environ["MONGO_HOST"])) -database = client[os.environ["MONGO_DATABASE"]] \ No newline at end of file +database = client[os.environ["MONGO_DATABASE"]] + +connect_redis = redis.Redis(host=os.environ["REDIS_URL"], port=os.environ["REDIS_PORT"], decode_responses=True) \ No newline at end of file diff --git a/app/dependencies/mail.py b/app/dependencies/mail.py new file mode 100644 index 0000000..5620481 --- /dev/null +++ b/app/dependencies/mail.py @@ -0,0 +1,14 @@ +from fastapi_mail import ConnectionConfig +from pathlib import Path +import os + +conf = ConnectionConfig( + MAIL_USERNAME = os.environ["MAILER_USERNAME"], + MAIL_PASSWORD = os.environ["MAILER_PASSWORD"], + MAIL_FROM = os.environ["MAILER_FROM"], + MAIL_PORT = os.environ["MAILER_PORT"], + MAIL_SERVER = os.environ["MAILER_HOST"], + MAIL_STARTTLS = True, + MAIL_SSL_TLS = False, + TEMPLATE_FOLDER = Path(__file__).parents[1] / 'templates', +) diff --git a/app/dependencies/permissions_checker.py b/app/dependencies/permissions_checker.py index 896679f..9c5e414 100644 --- a/app/dependencies/permissions_checker.py +++ b/app/dependencies/permissions_checker.py @@ -1,4 +1,4 @@ -from ..dependencies import users_active +from ..dependencies import users_token from fastapi import Depends, HTTPException, status from ..models import users @@ -8,7 +8,7 @@ class PermissionChecker: def __init__(self, roles: list[str]) -> None: self.roles = roles - def __call__(self, user: users.User = Depends(users_active.get_current_active_user)) -> bool: + def __call__(self, user: users.User = Depends(users_token.get_current_active_user)) -> bool: for role in self.roles: if role == user.roles: return True diff --git a/app/dependencies/user_add.py b/app/dependencies/user_add.py index 198c10f..fae97c5 100644 --- a/app/dependencies/user_add.py +++ b/app/dependencies/user_add.py @@ -1,19 +1,22 @@ from ..models import users from ..dependencies import database from passlib.context import CryptContext +from pydantic import EmailStr -def add(username="", password="", roles="User", disabled=False): +def add(username="", password="", roles="User", disabled=False, confirmed=True, email="test@toto.com"): user_repository = users.UserRepository(database=database.database) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") result = user_repository.find_one_by({'username': username}) change = "added" - user = users.User(username=username, password=pwd_context.hash(password), roles=roles, disabled=disabled) + user = users.User(username=username, password=pwd_context.hash(password), roles=roles, disabled=disabled, confirmed=confirmed, email=email) if result is not None: result.password=pwd_context.hash(password) result.roles=roles result.disabled=disabled + result.confirmed=confirmed + result.email=email user = result change = "updated" user_repository.save(user) diff --git a/app/dependencies/users_active.py b/app/dependencies/users_token.py similarity index 100% rename from app/dependencies/users_active.py rename to app/dependencies/users_token.py diff --git a/app/main.py b/app/main.py index 8297dde..f49d8ae 100644 --- a/app/main.py +++ b/app/main.py @@ -1,12 +1,13 @@ from fastapi import FastAPI -from .routers import users, token +from .routers import users, token, mail from .dependencies import user_add app = FastAPI() app.include_router(users.router) app.include_router(token.router) +app.include_router(mail.router) @app.on_event("startup") diff --git a/app/models/email.py b/app/models/email.py new file mode 100644 index 0000000..2981d3e --- /dev/null +++ b/app/models/email.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, EmailStr +from typing import List, Any, Dict + + +class EmailSchema(BaseModel): + email: List[EmailStr] + body: Dict[str, Any] \ No newline at end of file diff --git a/app/models/users.py b/app/models/users.py index 743a924..4912304 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -1,18 +1,38 @@ -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr from pydantic_mongo import AbstractRepository, ObjectIdField class User(BaseModel): id: ObjectIdField = None username: str password: str - roles: str - disabled: bool + roles: str = "User" + disabled: bool = False + removed: bool = False + confirmed: bool = False + email: EmailStr class UserOut(BaseModel): id: ObjectIdField = None username: str roles: str disabled: bool + removed: bool + confirmed: bool + email: EmailStr + + +class UserIn(BaseModel): + username: str + roles: str + password: str + email: EmailStr + + +class UserCreate(BaseModel): + username: str + password: str + email: EmailStr + class UserInDB(User): password: str diff --git a/app/routers/mail.py b/app/routers/mail.py new file mode 100644 index 0000000..e140385 --- /dev/null +++ b/app/routers/mail.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, HTTPException, status +from ..dependencies import users_token, database, mail +from ..models import users, email +from fastapi.responses import JSONResponse +from fastapi_mail import MessageSchema, MessageType, FastMail +import random +router = APIRouter() + + +@router.post("/mail",tags=["mail"]) +async def create_user(userSingle: users.UserCreate | None = None): + if userSingle is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Body request is empty" + ) + user_repository = users.UserRepository(database=database.database) + user = user_repository.find_one_by({"$or": [{"username": {'$eq': userSingle.username}},{"email": {"$eq": userSingle.email}}] }) + if user is not None: + raise HTTPException( + status_code=status.HTTP_204_NO_CONTENT, + detail="User is already exist" + ) + + fm = FastMail(mail.conf) + numberkey = str(random.Random()) + key_hashed = users_token.get_password_hash(numberkey) + email_body = {"key":key_hashed, "username":userSingle.username} + email_schema = email.EmailSchema(email=[userSingle.email], body=email_body) + message = MessageSchema( + subject="Fastapi-Mail module", + recipients=email_schema.dict().get("email"), + template_body=email_schema.dict().get("body"), + subtype=MessageType.html, + ) + + await fm.send_message(message, template_name="mailer.html") + current_user = users.User(username=userSingle.username, password=users_token.get_password_hash(userSingle.password), email=userSingle.email) + user_repository.save(current_user) + database.connect_redis.set(userSingle.username, key_hashed) + return JSONResponse(status_code=status.HTTP_200_OK, content={"message": "email has been sent"}) + +@router.get("/mail",tags=["mail"]) +async def confirm_user(key: str | None = None, username: str | None = None): + if key is None or username is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Parameter key or/and username is empty" + ) + user_repository = users.UserRepository(database=database.database) + user = user_repository.find_one_by({"username": {'$eq': username}}) + key_hashed = database.connect_redis.get(username) + if key_hashed != key: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Key is invalid" + ) + user.confirmed = True + user_repository.save(user) + return JSONResponse(status_code=status.HTTP_200_OK, content={"message": "user account confirmed"}) \ No newline at end of file diff --git a/app/routers/token.py b/app/routers/token.py index 55ffdc4..7ef5419 100644 --- a/app/routers/token.py +++ b/app/routers/token.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from typing import Annotated from fastapi import Depends, FastAPI, HTTPException, status, APIRouter from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from ..dependencies import users_active +from ..dependencies import users_token from ..models import token router = APIRouter() @@ -13,7 +13,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 30 async def login_for_access_token( form_data: Annotated[OAuth2PasswordRequestForm, Depends()] ): - user = users_active.authenticate_user(form_data.username, form_data.password) + user = users_token.authenticate_user(form_data.username, form_data.password) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -21,7 +21,7 @@ async def login_for_access_token( headers={"WWW-Authenticate": "Bearer"}, ) access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = users_active.create_access_token( + access_token = users_token.create_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) return {"access_token": access_token, "token_type": "bearer"} \ No newline at end of file diff --git a/app/routers/users.py b/app/routers/users.py index f97970c..6b79929 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,11 +1,12 @@ from fastapi import APIRouter, Depends, HTTPException, status -from ..dependencies import users_active, permissions_checker, database +from ..dependencies import users_token, permissions_checker, database from ..models import users from typing import Annotated from bson import ObjectId - router = APIRouter() + + @router.get("/users", tags=["users"], response_model=list[users.UserOut]) async def read_users(authorize: Annotated[bool, Depends(permissions_checker.PermissionChecker(roles=["Admin"]))], skip: int = 0, limit: int = 20): if limit < 1 or skip < 0 or limit < skip: @@ -17,7 +18,7 @@ async def read_users(authorize: Annotated[bool, Depends(permissions_checker.Perm listUsers = [] user_repository = users.UserRepository(database=database.database) for user_index in user_repository.find_by({}, limit=limit, skip=skip): - user = users.UserOut(id=user_index.id, username=user_index.username, disabled=user_index.disabled, roles=user_index.roles) + user = users.UserOut(id=user_index.id, username=user_index.username, email=user_index.email, disabled=user_index.disabled, roles=user_index.roles, removed=user_index.removed, confirmed=user_index.confirmed) listUsers.append(user) return listUsers @@ -37,13 +38,13 @@ async def read_users_id(authorize: Annotated[bool, Depends(permissions_checker.P listUsers = [] user_repository = users.UserRepository(database=database.database) for user_index in user_repository.find_by({key: {'$regex': value}}, limit=limit, skip=skip): - user = users.UserOut(id=user_index.id, username=user_index.username, disabled=user_index.disabled, roles=user_index.roles) + user = users.UserOut(id=user_index.id, username=user_index.username, disabled=user_index.disabled, roles=user_index.roles, email=user_index.email, removed=user_index.removed, confirmed=user_index.confirmed) listUsers.append(user) return listUsers -@router.get("/users/me",tags=["users"], response_model=users.User, response_model_exclude=["password"]) -async def read_users_me(current_user: Annotated[users.User, Depends(users_active.get_current_active_user)], authorize: Annotated[bool, Depends(permissions_checker.PermissionChecker(roles=["Admin", "User"]))]): +@router.get("/users/me",tags=["users"], response_model=users.User, response_model_exclude=["id", "password", "roles", "disabled"]) +async def read_users_me(current_user: Annotated[users.User, Depends(users_token.get_current_active_user)], authorize: Annotated[bool, Depends(permissions_checker.PermissionChecker(roles=["Admin", "User"]))]): return current_user @router.get("/users/{item_id}", tags=["users"], response_model=users.User) @@ -51,3 +52,54 @@ async def read_users_id(item_id : str, authorize: Annotated[bool, Depends(permis user_repository = users.UserRepository(database=database.database) user = user_repository.find_one_by_id(ObjectId(item_id)) return user + + + +@router.delete("/users/me",tags=["users"], response_model=users.User, response_model_exclude=["id", "password", "roles", "disabled"]) +async def read_users_me(current_user: Annotated[users.User, Depends(users_token.get_current_active_user)], authorize: Annotated[bool, Depends(permissions_checker.PermissionChecker(roles=["Admin", "User"]))], remove: bool = False): + user_repository = users.UserRepository(database=database.database) + current_user.disabled = True + if remove is True: + current_user.removed = True + user_repository.save(current_user) + return current_user + +@router.delete("/users/{item_id}", tags=["users"], response_model=users.User) +async def read_users_id(item_id : str, authorize: Annotated[bool, Depends(permissions_checker.PermissionChecker(roles=["Admin"]))], remove : bool = False): + user_repository = users.UserRepository(database=database.database) + user = user_repository.find_one_by_id(ObjectId(item_id)) + user.disabled = True + if remove is True: + user.removed = True + user_repository.save(user) + return user + +@router.put("/users/me",tags=["users"], response_model=users.User, response_model_exclude=["id", "password", "roles", "disabled"]) +async def read_users_me(current_user: Annotated[users.User, Depends(users_token.get_current_active_user)], authorize: Annotated[bool, Depends(permissions_checker.PermissionChecker(roles=["Admin", "User"]))], userSingle: users.UserIn | None = None): + user_repository = users.UserRepository(database=database.database) + current_user.username = userSingle.username + current_user.password = user_token.get_password_hash(userSingle.password) + current_user.roles = userSingle.roles + current_user.email = userSingle.email + user_repository.save(current_user) + return current_user + +@router.put("/users", tags=["users"], response_model=users.User, status_code=status.HTTP_200_OK) +async def read_users_id(authorize: Annotated[bool, Depends(permissions_checker.PermissionChecker(roles=["Admin"]))], userSingle: users.UserIn | None = None): + if userSingle is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Body request is empty" + ) + user_repository = users.UserRepository(database=database.database) + + user = user_repository.find_one_by({"username": {'$eq': userSingle.username}}) + if user is None: + response.status_code = status.HTTP_201_CREATED + user = users.User() + user.username = userSingle.username + user.password = user_token.get_password_hash(userSingle.password) + user.roles = userSingle.roles + user.email = userSingle.email + user_repository.save(user) + return user \ No newline at end of file diff --git a/app/templates/mailer.html b/app/templates/mailer.html new file mode 100644 index 0000000..011f049 --- /dev/null +++ b/app/templates/mailer.html @@ -0,0 +1,6 @@ + +
Voici un lien https://localhost:8080/api/mail?key={{ key }}&username={{ username }} +
+ + diff --git a/requierements.txt b/requierements.txt index 5bd6d38..2645894 100644 --- a/requierements.txt +++ b/requierements.txt @@ -3,4 +3,6 @@ uvicorn[standard] pydantic-mongo python-jose[cryptography] passlib[bcrypt] -python-multipart \ No newline at end of file +python-multipart +fastapi-mail +redis \ No newline at end of file