Skip to content

Commit

Permalink
reset password added
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexTraveylan committed Aug 28, 2024
1 parent 4dfe337 commit 8e8a2fc
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 5 deletions.
34 changes: 33 additions & 1 deletion app/commun/crypto.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import base64
import secrets
import string
from datetime import datetime, timedelta, timezone
from random import choices

import jwt
from cryptography.fernet import Fernet
from passlib.context import CryptContext

from app.settings import AES_KEY
from app.settings import AES_KEY, SECRET_KEY

PWD_CONTEXT = CryptContext(schemes=["bcrypt"], deprecated="auto")

Expand Down Expand Up @@ -33,3 +37,31 @@ def verify_password(plain_password, hashed_password):

def get_password_hash(password):
return PWD_CONTEXT.hash(password)


def generate_password() -> str:
at_least_one_upper = string.ascii_uppercase
at_least_one_lower = string.ascii_lowercase
at_least_one_digit = string.digits

return (
secrets.token_urlsafe(nbytes=4)
+ choices(at_least_one_upper)[0]
+ choices(at_least_one_lower)[0]
+ choices(at_least_one_digit)[0]
)


def generate_password_reset_token(user_id: int) -> str:
expiration = datetime.now(timezone.utc) + timedelta(hours=1)
data = {"sub": str(user_id), "exp": expiration}
return jwt.encode(data, SECRET_KEY, algorithm="HS256")


def verify_password_reset_token(token: str) -> int | None:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
user_id = int(payload.get("sub"))
return user_id
except jwt.exceptions.InvalidTokenError:
return None
65 changes: 62 additions & 3 deletions app/emailmanager/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,30 @@
from pydantic import BaseModel, Field

from app.api.user_information.models import USER_INFORMATION_SERVICE, UserInformation
from app.auth.token import UserWithInformations, get_current_user_with_informations
from app.commun.crypto import generate_confirmation_token
from app.auth.models import USER_SERVICE
from app.auth.token import (
UserWithInformations,
get_current_user_with_informations,
)
from app.commun.crypto import (
generate_confirmation_token,
generate_password_reset_token,
verify_password_reset_token,
)
from app.database.unit_of_work import unit_api
from app.emailmanager.models import (
EMAIL_CONFIRMATION_TOKEN_SERVICE,
EmailConfirmationToken,
)
from app.emailmanager.schema import EmailSchema
from app.emailmanager.schema import EmailSchema, PasswordResetSchema, UsernameSchema
from app.emailmanager.send_email import (
html_wrapper_for_confirmation_email_with_token,
html_wrapper_for_introduction_email,
html_wrapper_for_password_reset_email,
send_contact_message,
)
from app.exceptions import RessourceNotFoundException, UnauthorizedException
from app.settings import FRONTEND_URL

email_router = APIRouter(
tags=["Email"],
Expand Down Expand Up @@ -137,3 +147,52 @@ def contact_user(
html=html,
to=user_info.email,
)


@email_router.post("/request-password-reset", status_code=status.HTTP_204_NO_CONTENT)
def request_password_reset(
payload: UsernameSchema,
) -> None:
with unit_api("Demande de réinitialisation du mot de passe") as session:
user = USER_SERVICE.get_or_none(session, username=payload.username)

if user is None:
raise UnauthorizedException("Utilisateur non trouvé")

user_info = USER_INFORMATION_SERVICE.get_or_none(session, user_id=user.id)

if user_info is None:
raise UnauthorizedException("Utilisateur n'a pas d'informations")

if user_info.email is None or not user_info.is_email_confirmed:
raise UnauthorizedException("L'utilisateur n'a pas confirmé son email")

reset_token = generate_password_reset_token(user_info.id)
reset_link = f"{FRONTEND_URL}/auth/reset-password?token={reset_token}"

html = html_wrapper_for_password_reset_email(reset_link)
send_contact_message(
subject="ParentsListMaker - Réinitialisation du mot de passe",
html=html,
to=user_info.email,
)


@email_router.post("/reset-password", status_code=status.HTTP_204_NO_CONTENT)
def reset_password(
payload: PasswordResetSchema,
) -> None:
with unit_api("Réinitialisation du mot de passe") as session:
user_id = verify_password_reset_token(payload.token)
if user_id is None:
raise UnauthorizedException("Token de réinitialisation invalide ou expiré")

user_info = USER_INFORMATION_SERVICE.get_or_none(session, user_id=user_id)
if user_info is None:
raise UnauthorizedException("Utilisateur non trouvé")

USER_INFORMATION_SERVICE.update(
session,
user_info.id,
encrypted_password=payload.new_password,
)
9 changes: 9 additions & 0 deletions app/emailmanager/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ class EmailSchema(BaseModel):
@field_validator("email")
def email_format(cls, value: str) -> str:
return validate_email(value)


class UsernameSchema(BaseModel):
username: str


class PasswordResetSchema(BaseModel):
token: str
new_password: str
54 changes: 54 additions & 0 deletions app/emailmanager/send_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,57 @@ def html_wrapper_for_introduction_email(sender_email: str, message: str) -> str:
"""

return html


def html_wrapper_for_password_reset_email(reset_link: str) -> str:
html = f"""
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Réinitialisation du mot de passe</title>
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.btn {{
display: inline-block;
padding: 10px 20px;
background-color: #007bff;
color: #ffffff;
text-decoration: none;
border-radius: 5px;
margin-top: 20px;
}}
.btn:hover {{
background-color: #0056b3;
}}
.copy-link {{
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 10px;
margin-top: 20px;
word-break: break-all;
}}
</style>
</head>
<body>
<h1>Réinitialisation de votre mot de passe</h1>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Veuillez cliquer sur le bouton ci-dessous pour créer un nouveau mot de passe :</p>
<a href="{reset_link}" class="btn">Réinitialiser mon mot de passe</a>
<p>Si le bouton ne fonctionne pas, vous pouvez copier et coller le lien suivant dans votre navigateur :</p>
<p class="copy-link">{reset_link}</p>
<p>Ce lien expirera dans 1 heure pour des raisons de sécurité.</p>
<p>Si vous n'avez pas demandé cette réinitialisation ou si quelque chose vous semble suspect, veuillez contacter immédiatement notre administrateur à l'adresse : {ADMINSTRATOR_EMAIL}</p>
</body>
</html>
"""

return html
1 change: 1 addition & 0 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# General
PRODUCTION = True if os.getenv("PRODUCTION") == "True" else False
BACKEND_HOST = os.getenv("BACKEND_HOST")
FRONTEND_URL = os.getenv("FRONTEND_URL")

# Database
DB_URL = os.getenv("DB_URL")
Expand Down
12 changes: 11 additions & 1 deletion tests/commun/test_crypto.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from app.commun.crypto import decrypt, encrypt
import pytest

from app.commun.crypto import decrypt, encrypt, generate_password
from app.commun.validator import validate_password


def test_encryt_and_decrypt_email(key: bytes):
Expand All @@ -7,3 +10,10 @@ def test_encryt_and_decrypt_email(key: bytes):
encrypted_email = encrypt(email, key)

assert email == decrypt(encrypted_email, key)


@pytest.mark.parametrize("_", range(50))
def test_generate_password(_):
password = generate_password()

assert validate_password(password) == password

0 comments on commit 8e8a2fc

Please sign in to comment.