From 9aab6678449e02689ee40146a9346d9c2f5d75e9 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Mon, 9 Jun 2025 14:19:44 +0000 Subject: [PATCH 01/50] Refactor user registration to use PostgreSQL stored procedure and sanitize username input --- app/models/user.py | 4 +- app/routes/v1/endpoints/authentication.py | 49 +++++++++++++++++------ app/routes/v1/schemas/user/create.py | 2 +- app/utility/username.py | 9 +++++ 4 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 app/utility/username.py diff --git a/app/models/user.py b/app/models/user.py index 5094861..18a79db 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -9,7 +9,7 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text +from sqlalchemy import Boolean, Column, DateTime, Integer, Text from sqlalchemy.dialects.postgresql import UUID from app.models import Base @@ -44,7 +44,7 @@ class User(Base): user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - username = Column(String(50), unique=True, nullable=False) + username = Column(Text, unique=True, nullable=False) email_encrypted = Column(Text, nullable=False) email_hash = Column(Text, unique=True, nullable=False) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 7b99292..bb43a81 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends +from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from app.models.user import User from app.routes.v1.schemas.user.create import UserCreate from app.utility.database import get_db from app.utility.security import ( @@ -11,23 +11,46 @@ hash_password, hash_phone, ) +from app.utility.username import sanitize_username router = APIRouter() @router.post("/register") async def register(data: UserCreate, db: AsyncSession = Depends(get_db)): - """Endpoint for user registration.""" - user = User( - username=data.username, - email_encrypted=encrypt_email(data.email), - email_hash=hash_email(data.email), - password_hash=hash_password(data.password), - phone_encrypted=encrypt_phone(data.phone) if data.phone else None, - phone_hash=hash_phone(data.phone) if data.phone else None, - language_id=data.language_id, + """Endpoint for user registration using PostgreSQL procedure.""" + username = sanitize_username(data.username) + email_encrypted = encrypt_email(data.email) + email_hash = hash_email(data.email) + password_hash = hash_password(data.password) + phone_encrypted = encrypt_phone(data.phone) if data.phone else None + phone_hash = hash_phone(data.phone) if data.phone else None + language_iso_code = data.language_iso_code # Should be a 2-letter code + + # Call the stored procedure with correct parameter order + await db.execute( + text( + """ + CALL register_user( + :username, + :email_encrypted, + :email_hash, + :password_hash, + :phone_encrypted, + :phone_hash, + :preferred_language_iso_code + ) + """ + ), + { + "username": username, + "email_encrypted": email_encrypted, + "email_hash": email_hash, + "password_hash": password_hash, + "phone_encrypted": phone_encrypted, + "phone_hash": phone_hash, + "preferred_language_iso_code": language_iso_code, + }, ) - db.add(user) await db.commit() - await db.refresh(user) - return {"message": "User registered successfully", "user_id": str(user.user_id)} + return {"message": "User registered successfully", "username": username} diff --git a/app/routes/v1/schemas/user/create.py b/app/routes/v1/schemas/user/create.py index ce54bb0..45afa78 100644 --- a/app/routes/v1/schemas/user/create.py +++ b/app/routes/v1/schemas/user/create.py @@ -6,4 +6,4 @@ class UserCreate(BaseModel): email: EmailStr password: str phone: str | None = None - language_id: int | None = None + language_iso_code: str | None = "en" diff --git a/app/utility/username.py b/app/utility/username.py new file mode 100644 index 0000000..abd323b --- /dev/null +++ b/app/utility/username.py @@ -0,0 +1,9 @@ +import re + + +def sanitize_username(username: str) -> str: + """ + Replace all characters not allowed by the database username constraint + (^[a-zA-Z0-9_]+$) with underscores. + """ + return re.sub(r"[^a-zA-Z0-9_]", "_", username) From 7888715682a5ceb64bc9a11ff94980f997edbabe Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Mon, 9 Jun 2025 14:25:41 +0000 Subject: [PATCH 02/50] Refactor user registration to use UserRegister schema and remove UserCreate --- app/routes/v1/endpoints/authentication.py | 4 ++-- app/routes/v1/schemas/user/{create.py => register.py} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename app/routes/v1/schemas/user/{create.py => register.py} (84%) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index bb43a81..6983d97 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -2,7 +2,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from app.routes.v1.schemas.user.create import UserCreate +from app.routes.v1.schemas.user.register import UserRegister from app.utility.database import get_db from app.utility.security import ( encrypt_email, @@ -17,7 +17,7 @@ @router.post("/register") -async def register(data: UserCreate, db: AsyncSession = Depends(get_db)): +async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): """Endpoint for user registration using PostgreSQL procedure.""" username = sanitize_username(data.username) email_encrypted = encrypt_email(data.email) diff --git a/app/routes/v1/schemas/user/create.py b/app/routes/v1/schemas/user/register.py similarity index 84% rename from app/routes/v1/schemas/user/create.py rename to app/routes/v1/schemas/user/register.py index 45afa78..d75da06 100644 --- a/app/routes/v1/schemas/user/create.py +++ b/app/routes/v1/schemas/user/register.py @@ -1,7 +1,7 @@ from pydantic import BaseModel, EmailStr -class UserCreate(BaseModel): +class UserRegister(BaseModel): username: str email: EmailStr password: str From 4e0adaf7adb8f39b686375a7637503d4a1cfc42a Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Mon, 9 Jun 2025 14:30:43 +0000 Subject: [PATCH 03/50] Refactor username sanitization: move sanitize_username function to string_utils and remove username.py --- app/routes/v1/endpoints/authentication.py | 2 +- app/utility/{username.py => string_utils.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename app/utility/{username.py => string_utils.py} (100%) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 6983d97..12a29b3 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -11,7 +11,7 @@ hash_password, hash_phone, ) -from app.utility.username import sanitize_username +from app.utility.string_utils import sanitize_username router = APIRouter() diff --git a/app/utility/username.py b/app/utility/string_utils.py similarity index 100% rename from app/utility/username.py rename to app/utility/string_utils.py From c3d4e3a8c6ccd367e15f2ad8b8007a3fe450081c Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Mon, 9 Jun 2025 14:52:55 +0000 Subject: [PATCH 04/50] Enhance user registration: add HTTPException for existing users and improve code structure --- app/routes/v1/endpoints/authentication.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 12a29b3..0433b8c 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession @@ -18,16 +18,28 @@ @router.post("/register") async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): - """Endpoint for user registration using PostgreSQL procedure.""" username = sanitize_username(data.username) email_encrypted = encrypt_email(data.email) email_hash = hash_email(data.email) password_hash = hash_password(data.password) phone_encrypted = encrypt_phone(data.phone) if data.phone else None phone_hash = hash_phone(data.phone) if data.phone else None - language_iso_code = data.language_iso_code # Should be a 2-letter code + language_iso_code = data.language_iso_code + + # Check if user is available + result = await db.execute( + text( + """ + SELECT is_user_available(:username, :email_hash, :phone_hash) AS available + """ + ), + {"username": username, "email_hash": email_hash, "phone_hash": phone_hash}, + ) + available = result.scalar() + + if not available: + raise HTTPException(409, "Username, email, or phone already exists") - # Call the stored procedure with correct parameter order await db.execute( text( """ From 8579c8db193b78f697c45068f0ba32715830a74c Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Mon, 9 Jun 2025 14:54:34 +0000 Subject: [PATCH 05/50] Add docstring to user registration endpoint for clarity --- app/routes/v1/endpoints/authentication.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 0433b8c..38b577e 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -18,6 +18,7 @@ @router.post("/register") async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): + """Endpoint for user registration.""" username = sanitize_username(data.username) email_encrypted = encrypt_email(data.email) email_hash = hash_email(data.email) From 89e56e76e3ac5fc82323591a38f727973ab7ba14 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Mon, 9 Jun 2025 17:03:07 +0000 Subject: [PATCH 06/50] Fix attribute name in User model: update language_id to language_iso_code for clarity --- app/models/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/user.py b/app/models/user.py index 18a79db..7f802bf 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -32,7 +32,7 @@ class User(Base): password_hash (str): Argon2 hash of the user's password. phone_encrypted (str): AES-encrypted phone number. phone_hash (str): SHA-256 hash of the normalized phone number. - language_id (int): Preferred language ID. + language_iso_code (str): Preferred language ISO code. created_at (datetime): Timestamp of user creation. updated_at (datetime): Timestamp of last update. last_login_at (datetime): Timestamp of last login. @@ -55,7 +55,7 @@ class User(Base): phone_encrypted = Column(Text) phone_hash = Column(Text, unique=True) - language_id = Column(Integer, nullable=True) + language_iso_code = Column(Integer, nullable=True) created_at = Column(DateTime(timezone=True), default=datetime.utcnow) updated_at = Column(DateTime(timezone=True), default=datetime.utcnow) From ce222fb59129912ab69467afc27b7bf4fbc92a52 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Mon, 9 Jun 2025 18:10:11 +0000 Subject: [PATCH 07/50] Refactor language preference handling: rename language_iso_code to language_id in User model and registration schema for consistency --- app/models/user.py | 4 ++-- app/routes/v1/endpoints/authentication.py | 6 +++--- app/routes/v1/schemas/user/register.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/user.py b/app/models/user.py index 7f802bf..18a79db 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -32,7 +32,7 @@ class User(Base): password_hash (str): Argon2 hash of the user's password. phone_encrypted (str): AES-encrypted phone number. phone_hash (str): SHA-256 hash of the normalized phone number. - language_iso_code (str): Preferred language ISO code. + language_id (int): Preferred language ID. created_at (datetime): Timestamp of user creation. updated_at (datetime): Timestamp of last update. last_login_at (datetime): Timestamp of last login. @@ -55,7 +55,7 @@ class User(Base): phone_encrypted = Column(Text) phone_hash = Column(Text, unique=True) - language_iso_code = Column(Integer, nullable=True) + language_id = Column(Integer, nullable=True) created_at = Column(DateTime(timezone=True), default=datetime.utcnow) updated_at = Column(DateTime(timezone=True), default=datetime.utcnow) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 38b577e..d0925d9 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -25,7 +25,7 @@ async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): password_hash = hash_password(data.password) phone_encrypted = encrypt_phone(data.phone) if data.phone else None phone_hash = hash_phone(data.phone) if data.phone else None - language_iso_code = data.language_iso_code + language_id = data.language_id # Check if user is available result = await db.execute( @@ -51,7 +51,7 @@ async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): :password_hash, :phone_encrypted, :phone_hash, - :preferred_language_iso_code + :preferred_language_id ) """ ), @@ -62,7 +62,7 @@ async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): "password_hash": password_hash, "phone_encrypted": phone_encrypted, "phone_hash": phone_hash, - "preferred_language_iso_code": language_iso_code, + "preferred_language_id": language_id, }, ) await db.commit() diff --git a/app/routes/v1/schemas/user/register.py b/app/routes/v1/schemas/user/register.py index d75da06..dd6228f 100644 --- a/app/routes/v1/schemas/user/register.py +++ b/app/routes/v1/schemas/user/register.py @@ -6,4 +6,4 @@ class UserRegister(BaseModel): email: EmailStr password: str phone: str | None = None - language_iso_code: str | None = "en" + language_id: int | None = None From 86a04fe745e1ea103650bfa6cb2bd72ae868780b Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 03:03:04 +0000 Subject: [PATCH 08/50] Implement email confirmation feature and login endpoint --- app/routes/v1/__init__.py | 2 + app/routes/v1/endpoints/authentication.py | 101 +++++++++++++++------ app/routes/v1/endpoints/email.py | 45 +++++++++ app/routes/v1/schemas/email/__init__.py | 1 + app/routes/v1/schemas/email/request.py | 5 + app/routes/v1/schemas/user/__init__.py | 2 + app/routes/v1/schemas/user/login.py | 6 ++ app/routes/v1/schemas/user/register.py | 5 +- app/send_email.py | 62 +++++++++++++ app/templates/email_confirmation.html | 106 ++++++++++++++++++++++ app/utility/security.py | 15 +++ requirements.txt | 2 + 12 files changed, 320 insertions(+), 32 deletions(-) create mode 100644 app/routes/v1/endpoints/email.py create mode 100644 app/routes/v1/schemas/email/__init__.py create mode 100644 app/routes/v1/schemas/email/request.py create mode 100644 app/routes/v1/schemas/user/login.py create mode 100644 app/send_email.py create mode 100644 app/templates/email_confirmation.html diff --git a/app/routes/v1/__init__.py b/app/routes/v1/__init__.py index ec0b86f..5677766 100644 --- a/app/routes/v1/__init__.py +++ b/app/routes/v1/__init__.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from .endpoints.authentication import router as auth_router +from .endpoints.email import router as email_router from .endpoints.orders import router as order_router from .endpoints.products import router as product_router @@ -13,5 +14,6 @@ api = FastAPI(title="ChocoMax Shop API", version=__version__) api.include_router(auth_router, prefix="/auth", tags=["Authentication"]) +api.include_router(email_router, prefix="/email", tags=["Email"]) api.include_router(product_router, prefix="/products", tags=["Products"]) api.include_router(order_router, prefix="/orders", tags=["Orders"]) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index d0925d9..ecf8739 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -1,69 +1,112 @@ +import random + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from app.routes.v1.schemas.user.register import UserRegister +from app.routes.v1.schemas.user import UserLogin, UserRegister from app.utility.database import get_db -from app.utility.security import ( - encrypt_email, - encrypt_phone, - hash_email, - hash_password, - hash_phone, -) +from app.utility.security import hash_email, hash_password, verify_password from app.utility.string_utils import sanitize_username router = APIRouter() +@router.post("/login") +async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): + """Endpoint for user login.""" + email_hash = hash_email(data.email) + password = data.password + + # Verify password + result = await db.execute( + text("SELECT get_password_hash_by_email_hash(:email_hash)"), + {"email_hash": email_hash}, + ) + password_hash = result.scalar() + + if not password_hash or not verify_password(password_hash, password): + raise HTTPException(401, "Invalid credentials") + + # Retrieve user information + result = await db.execute( + text("SELECT * FROM get_user_info_by_email_hash(:email_hash)"), + {"email_hash": email_hash}, + ) + user_info = result.fetchone() + + if not user_info: + raise HTTPException(404, "User not found") + + # TODO: Handle user session creation or token generation here and avoid returning sensitive information + return dict(user_info._mapping) + + @router.post("/register") async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): """Endpoint for user registration.""" + token = data.token username = sanitize_username(data.username) - email_encrypted = encrypt_email(data.email) - email_hash = hash_email(data.email) password_hash = hash_password(data.password) - phone_encrypted = encrypt_phone(data.phone) if data.phone else None - phone_hash = hash_phone(data.phone) if data.phone else None language_id = data.language_id - # Check if user is available + if not token: + raise HTTPException(400, "Token is required for registration") + + # Check if the token exists in pending_users result = await db.execute( - text( - """ - SELECT is_user_available(:username, :email_hash, :phone_hash) AS available - """ - ), - {"username": username, "email_hash": email_hash, "phone_hash": phone_hash}, + text("SELECT 1 FROM pending_users WHERE verification_token = :token"), + {"token": token}, + ) + if not result.scalar(): + raise HTTPException(400, "Invalid or expired verification token") + + # Retrieve the list of discriminators for the username + result = await db.execute( + text("SELECT get_used_discriminators(:username) AS discriminator"), + {"username": username}, + ) + used_discriminators = [row.discriminator for row in result.fetchall()] + available_discriminators = set(range(0, 10000)) - set(used_discriminators) + + if not available_discriminators: + raise HTTPException(409, "All discriminators taken for this username") + + # Choose a random discriminator from the available ones + discriminator = random.choice(list(available_discriminators)) + + # Check if email is available + result = await db.execute( + text("SELECT is_email_available(:token) AS available"), {"token": token} ) available = result.scalar() if not available: - raise HTTPException(409, "Username, email, or phone already exists") + raise HTTPException(409, "Email already exists") await db.execute( text( """ CALL register_user( + :token, :username, - :email_encrypted, - :email_hash, + :discriminator, :password_hash, - :phone_encrypted, - :phone_hash, :preferred_language_id ) """ ), { + "token": token, "username": username, - "email_encrypted": email_encrypted, - "email_hash": email_hash, + "discriminator": discriminator, "password_hash": password_hash, - "phone_encrypted": phone_encrypted, - "phone_hash": phone_hash, "preferred_language_id": language_id, }, ) await db.commit() - return {"message": "User registered successfully", "username": username} + return { + "message": "User registered successfully", + "username": username, + "discriminator": discriminator, + } diff --git a/app/routes/v1/endpoints/email.py b/app/routes/v1/endpoints/email.py new file mode 100644 index 0000000..9a5f078 --- /dev/null +++ b/app/routes/v1/endpoints/email.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, BackgroundTasks, Depends +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.routes.v1.schemas.email import EmailRequest +from app.send_email import RegistrationEmailSchema, send_email_background +from app.utility.database import get_db +from app.utility.security import create_verification_token, encrypt_email, hash_email + +router = APIRouter() + + +@router.post("/confirmation") +async def send_confirmation_email( + data: EmailRequest, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), +): + """ + Endpoint to send a confirmation email to the user. + This endpoint accepts a POST request with the user's email in the body. + """ + email = data.email + token = create_verification_token() + email_encrypted = encrypt_email(email) + email_hash = hash_email(email) + + email_schema = RegistrationEmailSchema( + email=[email], + body={ + "title": "Welcome to ChocoMax", + "confirmation_url": f"http://?token={token}", + }, + ) + + send_email_background(background_tasks, email_schema) + + # Send the token to the Database + await db.execute( + text("CALL create_pending_user(:email_encrypted, :email_hash, :token)"), + {"email_encrypted": email_encrypted, "email_hash": email_hash, "token": token}, + ) + await db.commit() + + return {"detail": "Confirmation email sent", "confirmation_token": token} diff --git a/app/routes/v1/schemas/email/__init__.py b/app/routes/v1/schemas/email/__init__.py new file mode 100644 index 0000000..bb7621a --- /dev/null +++ b/app/routes/v1/schemas/email/__init__.py @@ -0,0 +1 @@ +from .request import EmailRequest diff --git a/app/routes/v1/schemas/email/request.py b/app/routes/v1/schemas/email/request.py new file mode 100644 index 0000000..db2aeba --- /dev/null +++ b/app/routes/v1/schemas/email/request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class EmailRequest(BaseModel): + email: str diff --git a/app/routes/v1/schemas/user/__init__.py b/app/routes/v1/schemas/user/__init__.py index e69de29..7041245 100644 --- a/app/routes/v1/schemas/user/__init__.py +++ b/app/routes/v1/schemas/user/__init__.py @@ -0,0 +1,2 @@ +from .login import UserLogin +from .register import UserRegister diff --git a/app/routes/v1/schemas/user/login.py b/app/routes/v1/schemas/user/login.py new file mode 100644 index 0000000..d8992ea --- /dev/null +++ b/app/routes/v1/schemas/user/login.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class UserLogin(BaseModel): + email: str + password: str diff --git a/app/routes/v1/schemas/user/register.py b/app/routes/v1/schemas/user/register.py index dd6228f..bcecdae 100644 --- a/app/routes/v1/schemas/user/register.py +++ b/app/routes/v1/schemas/user/register.py @@ -1,9 +1,8 @@ -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel class UserRegister(BaseModel): + token: str username: str - email: EmailStr password: str - phone: str | None = None language_id: int | None = None diff --git a/app/send_email.py b/app/send_email.py new file mode 100644 index 0000000..8b5bcb0 --- /dev/null +++ b/app/send_email.py @@ -0,0 +1,62 @@ +import os +from pathlib import Path +from typing import Any, Dict, List + +from dotenv import load_dotenv +from fastapi import BackgroundTasks +from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType +from pydantic import BaseModel, EmailStr + +load_dotenv(".env") + + +class Envs: + MAIL_USERNAME = os.getenv("MAIL_USERNAME") + MAIL_PASSWORD = os.getenv("MAIL_PASSWORD") + MAIL_FROM = os.getenv("MAIL_FROM") + MAIL_PORT = int(os.getenv("MAIL_PORT")) + MAIL_SERVER = os.getenv("MAIL_SERVER") + MAIL_FROM_NAME = os.getenv("MAIL_FROM_NAME") + + +conf = ConnectionConfig( + MAIL_USERNAME=Envs.MAIL_USERNAME, + MAIL_PASSWORD=Envs.MAIL_PASSWORD, + MAIL_FROM=Envs.MAIL_FROM, + MAIL_PORT=Envs.MAIL_PORT, + MAIL_SERVER=Envs.MAIL_SERVER, + MAIL_FROM_NAME=Envs.MAIL_FROM_NAME, + MAIL_STARTTLS=True, + MAIL_SSL_TLS=False, + USE_CREDENTIALS=True, + TEMPLATE_FOLDER=Path(__file__).parent / "templates", +) + + +class BaseEmailSchema(BaseModel): + email: List[EmailStr] + subject: str + template_name: str + body: Dict[str, Any] + + +class RegistrationEmailSchema(BaseEmailSchema): + subject: str = "ChocoMax - Email Confirmation" + template_name: str = "email_confirmation.html" + + +class PasswordResetEmailSchema(BaseEmailSchema): + pass # Add specific fields if needed + + +def send_email_background(background_tasks: BackgroundTasks, email: BaseEmailSchema): + message = MessageSchema( + subject=email.subject, + recipients=email.email, + template_body=email.body, + subtype=MessageType.html, + ) + fm = FastMail(conf) + background_tasks.add_task( + fm.send_message, message, template_name=email.template_name + ) diff --git a/app/templates/email_confirmation.html b/app/templates/email_confirmation.html new file mode 100644 index 0000000..eb185ca --- /dev/null +++ b/app/templates/email_confirmation.html @@ -0,0 +1,106 @@ + + + + + + + +
+

+ {{ title }} +

+
+

Hello!

+

+ Thank you for signing up! Please confirm your email address to complete your account creation. +

+ + Confirm Your Email + + + +
+
+ + + diff --git a/app/utility/security.py b/app/utility/security.py index 4412e34..5f8b392 100644 --- a/app/utility/security.py +++ b/app/utility/security.py @@ -1,6 +1,7 @@ import base64 import hashlib import os +import secrets from argon2 import PasswordHasher from cryptography.hazmat.primitives.ciphers.aead import AESGCM @@ -11,6 +12,11 @@ PEPPER = os.getenv("PEPPER", "SuperSecretPepper").encode("utf-8") +def create_verification_token() -> str: + """Generate a secure random token for email verification.""" + return secrets.token_urlsafe(32) + + def normalize_email(email: str) -> str: """Normalize email by stripping spaces and converting to lowercase.""" return email.strip().lower() @@ -49,6 +55,15 @@ def hash_password(password: str) -> str: return ph.hash(peppered_password) +def verify_password(hashed_password: str, password: str) -> bool: + """Verify a password against a hashed password.""" + peppered_password = password.encode("utf-8") + PEPPER + try: + return ph.verify(hashed_password, peppered_password) + except Exception: + return False + + # Wrappers for email and phone to ensure normalization and hashing diff --git a/requirements.txt b/requirements.txt index 5c69585..d653563 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ argon2-cffi>=25.1.0 asyncpg>=0.30.0 cryptography>=45.0.3 +dotenv>=0.9.9 fastapi>=0.115.12 +fastapi_mail>=1.5.0 httpx>=0.28.1 pydantic[email] sqlalchemy>=2.0.41 From aec0f9616edee7aa89b3f04e30cfe0e13fad2c1e Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 10:51:16 +0000 Subject: [PATCH 09/50] Enhance login endpoint --- app/routes/v1/endpoints/authentication.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index ecf8739..0ddbad4 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -28,6 +28,8 @@ async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): if not password_hash or not verify_password(password_hash, password): raise HTTPException(401, "Invalid credentials") + # TODO: Handle 2FA verification here + # Retrieve user information result = await db.execute( text("SELECT * FROM get_user_info_by_email_hash(:email_hash)"), @@ -35,9 +37,6 @@ async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): ) user_info = result.fetchone() - if not user_info: - raise HTTPException(404, "User not found") - # TODO: Handle user session creation or token generation here and avoid returning sensitive information return dict(user_info._mapping) From a416bbbc72851579953e01df34fe0ca1ac4bd4ab Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 14:49:26 +0000 Subject: [PATCH 10/50] Implement 2FA login flow and enhance user registration with OTP secret generation --- app/routes/v1/endpoints/authentication.py | 127 ++++++++++++++++++---- app/routes/v1/schemas/user/__init__.py | 2 +- app/routes/v1/schemas/user/login.py | 5 + app/utility/database.py | 2 +- app/utility/security.py | 15 +++ 5 files changed, 130 insertions(+), 21 deletions(-) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 0ddbad4..bba72d5 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -1,43 +1,127 @@ import random +import secrets +import time from fastapi import APIRouter, Depends, HTTPException +from pyotp import random_base32 as generate_otp_secret from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from app.routes.v1.schemas.user import UserLogin, UserRegister +from app.routes.v1.schemas.user import UserLogin, UserLogin2FA, UserRegister from app.utility.database import get_db -from app.utility.security import hash_email, hash_password, verify_password +from app.utility.security import hash_email, hash_password, verify_otp, verify_password from app.utility.string_utils import sanitize_username router = APIRouter() +# --- Common utility functions --- -@router.post("/login") -async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): - """Endpoint for user login.""" - email_hash = hash_email(data.email) - password = data.password - # Verify password +async def get_password_hash_by_email_hash( + db: AsyncSession, email_hash: str +) -> str | None: result = await db.execute( text("SELECT get_password_hash_by_email_hash(:email_hash)"), {"email_hash": email_hash}, ) - password_hash = result.scalar() + return result.scalar() - if not password_hash or not verify_password(password_hash, password): - raise HTTPException(401, "Invalid credentials") - # TODO: Handle 2FA verification here +async def get_2fa_secret(db: AsyncSession, email_hash: str, method: str = "TOTP"): + result = await db.execute( + text( + "SELECT * FROM get_user_2fa_secret_by_email_hash(:email_hash, :auth_method)" + ), + {"email_hash": email_hash, "auth_method": method}, + ) + return result.fetchone() + - # Retrieve user information +async def get_user_info(db: AsyncSession, email_hash: str): result = await db.execute( text("SELECT * FROM get_user_info_by_email_hash(:email_hash)"), {"email_hash": email_hash}, ) - user_info = result.fetchone() + return result.fetchone() + + +# --- Endpoints --- - # TODO: Handle user session creation or token generation here and avoid returning sensitive information +# Temporary in-memory store for 2FA sessions (replace with Redis or DB in production) +_2fa_sessions = {} + + +@router.post("/login") +async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): + """Step 1: Verify email and password, check if 2FA is required.""" + email_hash = hash_email(data.email) + password = data.password + + # Verify password + password_hash = await get_password_hash_by_email_hash(db, email_hash) + if not password_hash or not verify_password(password_hash, password): + raise HTTPException(401, "Invalid credentials") + + # Check if 2FA is enabled before fetching user info + row = await get_2fa_secret(db, email_hash) + user_2fa_secret = row.authentication_secret if row else None + + if user_2fa_secret: + temp_token = secrets.token_urlsafe(32) + + # Store mapping with expiry (5 minutes) + _2fa_sessions[temp_token] = { + "email_hash": email_hash, + "expires_at": time.time() + 300, + } + + result = await db.execute( + text("SELECT * FROM get_user_2fa_methods_by_email_hash(:email_hash)"), + {"email_hash": email_hash}, + ) + methods_rows = result.fetchall() + methods = [row.authentication_method for row in methods_rows] + preferred_method = next( + (row.authentication_method for row in methods_rows if row.is_preferred), + None, + ) + + if methods: + return { + "2fa_required": True, + "token": temp_token, # Return token instead of email_hash + "methods": methods, + "preferred_method": preferred_method, + } + + # If 2FA is not enabled, return user info/session + user_info = await get_user_info(db, email_hash) + return dict(user_info._mapping) + + +@router.post("/login/otp") +async def login_otp(data: UserLogin2FA, db: AsyncSession = Depends(get_db)): + """Step 2: Verify OTP code and return user info/session.""" + # Validate the temporary session token + session = _2fa_sessions.get(data.token) + + if not session or session["expires_at"] < time.time(): + raise HTTPException(401, "Invalid or expired 2FA session token") + + email_hash = session["email_hash"] + + # Get 2FA secret and method + row = await get_2fa_secret(db, email_hash) + secret = row.authentication_secret if row else None + + if not secret: + raise HTTPException(400, "2FA is not enabled for this user") + + if not verify_otp(secret, data.otp_code): + raise HTTPException(401, "Invalid 2FA code") + + # Return user info/session + user_info = await get_user_info(db, email_hash) return dict(user_info._mapping) @@ -48,16 +132,19 @@ async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): username = sanitize_username(data.username) password_hash = hash_password(data.password) language_id = data.language_id + otp_secret = generate_otp_secret() if not token: raise HTTPException(400, "Token is required for registration") - # Check if the token exists in pending_users + # Check if the token exists and is valid using the new function result = await db.execute( - text("SELECT 1 FROM pending_users WHERE verification_token = :token"), + text("SELECT is_verification_token_valid(:token)"), {"token": token}, ) - if not result.scalar(): + is_valid = result.scalar() + + if not is_valid: raise HTTPException(400, "Invalid or expired verification token") # Retrieve the list of discriminators for the username @@ -91,7 +178,8 @@ async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): :username, :discriminator, :password_hash, - :preferred_language_id + :preferred_language_id, + :otp_secret ) """ ), @@ -101,6 +189,7 @@ async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): "discriminator": discriminator, "password_hash": password_hash, "preferred_language_id": language_id, + "otp_secret": otp_secret, }, ) await db.commit() diff --git a/app/routes/v1/schemas/user/__init__.py b/app/routes/v1/schemas/user/__init__.py index 7041245..c685c9e 100644 --- a/app/routes/v1/schemas/user/__init__.py +++ b/app/routes/v1/schemas/user/__init__.py @@ -1,2 +1,2 @@ -from .login import UserLogin +from .login import UserLogin, UserLogin2FA from .register import UserRegister diff --git a/app/routes/v1/schemas/user/login.py b/app/routes/v1/schemas/user/login.py index d8992ea..2994efd 100644 --- a/app/routes/v1/schemas/user/login.py +++ b/app/routes/v1/schemas/user/login.py @@ -4,3 +4,8 @@ class UserLogin(BaseModel): email: str password: str + + +class UserLogin2FA(BaseModel): + otp_code: int + token: str diff --git a/app/utility/database.py b/app/utility/database.py index c91b1e8..261b80a 100644 --- a/app/utility/database.py +++ b/app/utility/database.py @@ -4,7 +4,7 @@ DATABASE_URL = ( "postgresql+asyncpg://postgres:S3cur3Str0ngP%40ss@172.17.0.1:5432/chocomax" ) -engine = create_async_engine(DATABASE_URL, echo=True) +engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True) SessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) diff --git a/app/utility/security.py b/app/utility/security.py index 5f8b392..5cc40e1 100644 --- a/app/utility/security.py +++ b/app/utility/security.py @@ -3,6 +3,7 @@ import os import secrets +import pyotp from argon2 import PasswordHasher from cryptography.hazmat.primitives.ciphers.aead import AESGCM @@ -55,6 +56,20 @@ def hash_password(password: str) -> str: return ph.hash(peppered_password) +def verify_otp(secret: str, otp_code: str, otp_method: str = "TOTP") -> bool: + """Verify a one-time password (OTP) against a secret using TOTP or HOTP.""" + try: + match otp_method: + case "TOTP": + return pyotp.TOTP(secret).verify(otp_code) + case "HOTP": + return pyotp.HOTP(secret).verify(otp_code) + case _: + raise ValueError("Unsupported OTP method") + except Exception: + return False + + def verify_password(hashed_password: str, password: str) -> bool: """Verify a password against a hashed password.""" peppered_password = password.encode("utf-8") + PEPPER From a159a8b080f46c353624192741f0035acd4e0057 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 14:59:04 +0000 Subject: [PATCH 11/50] Move in-memory store declaration for clarity --- app/routes/v1/endpoints/authentication.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index bba72d5..4ff33e7 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -13,6 +13,10 @@ from app.utility.string_utils import sanitize_username router = APIRouter() +_2fa_sessions = ( + {} +) # Temporary in-memory store for 2FA sessions TODO: (replace with Redis or DB in production) + # --- Common utility functions --- @@ -47,9 +51,6 @@ async def get_user_info(db: AsyncSession, email_hash: str): # --- Endpoints --- -# Temporary in-memory store for 2FA sessions (replace with Redis or DB in production) -_2fa_sessions = {} - @router.post("/login") async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): From b3b55263d47767f02a6e2ea426e9b22012eec471 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 15:03:15 +0000 Subject: [PATCH 12/50] Bump version to 1.2.0 for API and 0.3.0 for application; update breaking change label in version bump workflow --- .github/workflows/suggest-version-bump.yml | 2 +- app/routes/v1/__init__.py | 2 +- app/version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/suggest-version-bump.yml b/.github/workflows/suggest-version-bump.yml index f9b247a..b53e15f 100644 --- a/.github/workflows/suggest-version-bump.yml +++ b/.github/workflows/suggest-version-bump.yml @@ -51,7 +51,7 @@ jobs: BUMP="patch" echo "$LABELS" | grep -q 'type: feature' && BUMP="minor" echo "$LABELS" | grep -q 'type: security' && BUMP="minor" - echo "$LABELS" | grep -q 'type: breaking' && BUMP="major" + echo "$LABELS" | grep -q 'special: breaking change' && BUMP="major" echo "bump=$BUMP" >> "$GITHUB_OUTPUT" - name: Get latest tag diff --git a/app/routes/v1/__init__.py b/app/routes/v1/__init__.py index 5677766..d11db38 100644 --- a/app/routes/v1/__init__.py +++ b/app/routes/v1/__init__.py @@ -9,7 +9,7 @@ from .endpoints.orders import router as order_router from .endpoints.products import router as product_router -__version__ = "1.1.0" +__version__ = "1.2.0" api = FastAPI(title="ChocoMax Shop API", version=__version__) diff --git a/app/version.py b/app/version.py index f93bed8..0e4c943 100644 --- a/app/version.py +++ b/app/version.py @@ -4,4 +4,4 @@ It is used to track changes and updates to the codebase. """ -__version__ = "0.2.1" +__version__ = "0.3.0" From bb83e80eedc92f2a89983f252d5056e96408c2b0 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 15:08:33 +0000 Subject: [PATCH 13/50] Refactor tasks.json to improve structure and remove unnecessary runOptions --- .vscode/tasks.json | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0af07d9..c495184 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,36 +5,33 @@ "label": "Delete old Git branches", "type": "shell", "command": "git fetch --prune && git fetch -p ; git branch -r | awk '{print $1}' | egrep -v -f /dev/fd/0 <(git branch -vv | grep origin) | awk '{print $1}' | xargs git branch -D", - "problemMatcher": [], "presentation": { "showReuseMessage": false - } + }, + "problemMatcher": [], }, { "label": "Package app", "type": "shell", "command": "rm -rf build dist *.egg-info && python -m build", - "problemMatcher": [], - "runOptions": { - "runOn": "default" + "group": { + "kind": "build", + "isDefault": true }, "presentation": { "panel": "dedicated" }, - "group": { - "kind": "build", - "isDefault": true - } + "problemMatcher": [], }, { "label": "Run all tests", "type": "shell", "command": "python3 -m pytest tests", "group": "build", - "problemMatcher": [], - "runOptions": { - "runOn": "default" - } + "presentation": { + "close": true + }, + "problemMatcher": [] }, { "label": "Run on test file", @@ -47,22 +44,16 @@ "close": true }, "problemMatcher": [], - "runOptions": { - "runOn": "default", - } }, { "label": "Start FastAPI server", "type": "shell", "command": "uvicorn app.main:app --reload", - "problemMatcher": [], - "runOptions": { - "runOn": "default" - }, "presentation": { "panel": "dedicated", "close": true - } + }, + "problemMatcher": [], } ] } From d37b9a5f089a727082becb12df9fa01995f648aa Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 15:38:44 +0000 Subject: [PATCH 14/50] Add pyotp dependency for OTP functionality --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index d653563..3f4d024 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,6 @@ fastapi>=0.115.12 fastapi_mail>=1.5.0 httpx>=0.28.1 pydantic[email] +pyotp>=2.9.0 sqlalchemy>=2.0.41 uvicorn>=0.34.2 From e66c9009dffa39ce7701c0f2805d2470a0cabd47 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 15:45:42 +0000 Subject: [PATCH 15/50] Update default email configuration values in send_email.py for better clarity --- app/send_email.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/send_email.py b/app/send_email.py index 8b5bcb0..7042497 100644 --- a/app/send_email.py +++ b/app/send_email.py @@ -11,12 +11,12 @@ class Envs: - MAIL_USERNAME = os.getenv("MAIL_USERNAME") - MAIL_PASSWORD = os.getenv("MAIL_PASSWORD") - MAIL_FROM = os.getenv("MAIL_FROM") - MAIL_PORT = int(os.getenv("MAIL_PORT")) - MAIL_SERVER = os.getenv("MAIL_SERVER") - MAIL_FROM_NAME = os.getenv("MAIL_FROM_NAME") + MAIL_USERNAME = os.getenv("MAIL_USERNAME", "user@example.com") + MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", "password") + MAIL_FROM = os.getenv("MAIL_FROM", "noreply@chocomax.com") + MAIL_PORT = os.getenv("MAIL_PORT", "587") + MAIL_SERVER = os.getenv("MAIL_SERVER", "smtp.example.com") + MAIL_FROM_NAME = os.getenv("MAIL_FROM_NAME", "ChocoMax") conf = ConnectionConfig( From 79d30461f3cb699d39edae494effe0199c2177da Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 18:14:44 +0000 Subject: [PATCH 16/50] Enhance documentation and restructure email utility modules for clarity and maintainability --- .github/workflows/super-linter.yml | 6 +- app/routes/v1/endpoints/authentication.py | 79 +++++++++++- app/routes/v1/endpoints/email.py | 23 +++- app/routes/v1/schemas/email/__init__.py | 10 +- app/routes/v1/schemas/email/request.py | 14 +++ app/routes/v1/schemas/user/__init__.py | 7 ++ app/routes/v1/schemas/user/login.py | 23 ++++ app/routes/v1/schemas/user/register.py | 17 +++ app/send_email.py | 62 ---------- app/utility/database.py | 17 +++ app/utility/email/__init__.py | 11 ++ app/utility/email/config.py | 27 +++++ app/utility/email/schemas.py | 51 ++++++++ app/utility/email/sender.py | 36 ++++++ app/utility/security.py | 140 +++++++++++++++++++--- app/utility/test.py | 0 16 files changed, 439 insertions(+), 84 deletions(-) delete mode 100644 app/send_email.py create mode 100644 app/utility/email/__init__.py create mode 100644 app/utility/email/config.py create mode 100644 app/utility/email/schemas.py create mode 100644 app/utility/email/sender.py delete mode 100644 app/utility/test.py diff --git a/.github/workflows/super-linter.yml b/.github/workflows/super-linter.yml index 3b5b228..8935054 100644 --- a/.github/workflows/super-linter.yml +++ b/.github/workflows/super-linter.yml @@ -48,13 +48,17 @@ jobs: VALIDATE_ALL_CODEBASE: false FILTER_REGEX_EXCLUDE: '(.github/pull_request_template.md|.github/ISSUE_TEMPLATE/*.md)' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VALIDATE_DOCKERFILE_HADOLINT: false VALIDATE_PYTHON_ISORT: false VALIDATE_PYTHON_MYPY: false + FIX_HTML_PRETTIER: true + FIX_JSON: true FIX_JSON_PRETTIER: true FIX_MARKDOWN: true FIX_MARKDOWN_PRETTIER: true + FIX_PYTHON_BLACK: true + FIX_PYTHON_RUFF: true FIX_YAML_PRETTIER: true - VALIDATE_DOCKERFILE_HADOLINT: false - name: Commit and push linting fixes if: > diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 4ff33e7..f3098d0 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -1,3 +1,11 @@ +""" +Authentication endpoints and utilities for user login, registration, and 2FA. + +This module provides FastAPI endpoints for user authentication, including login, +two-factor authentication (2FA), and registration. It also includes utility +functions for interacting with the database and handling authentication logic. +""" + import random import secrets import time @@ -24,6 +32,16 @@ async def get_password_hash_by_email_hash( db: AsyncSession, email_hash: str ) -> str | None: + """ + Retrieve the password hash for a user by their email hash. + + Args: + db (AsyncSession): The database session. + email_hash (str): The hashed email address. + + Returns: + str | None: The password hash if found, otherwise None. + """ result = await db.execute( text("SELECT get_password_hash_by_email_hash(:email_hash)"), {"email_hash": email_hash}, @@ -32,6 +50,17 @@ async def get_password_hash_by_email_hash( async def get_2fa_secret(db: AsyncSession, email_hash: str, method: str = "TOTP"): + """ + Retrieve the 2FA secret for a user by their email hash and authentication method. + + Args: + db (AsyncSession): The database session. + email_hash (str): The hashed email address. + method (str): The authentication method (default: "TOTP"). + + Returns: + Row or None: The database row containing the 2FA secret, or None if not found. + """ result = await db.execute( text( "SELECT * FROM get_user_2fa_secret_by_email_hash(:email_hash, :auth_method)" @@ -42,6 +71,16 @@ async def get_2fa_secret(db: AsyncSession, email_hash: str, method: str = "TOTP" async def get_user_info(db: AsyncSession, email_hash: str): + """ + Retrieve user information by their email hash. + + Args: + db (AsyncSession): The database session. + email_hash (str): The hashed email address. + + Returns: + Row or None: The database row containing user information, or None if not found. + """ result = await db.execute( text("SELECT * FROM get_user_info_by_email_hash(:email_hash)"), {"email_hash": email_hash}, @@ -54,7 +93,19 @@ async def get_user_info(db: AsyncSession, email_hash: str): @router.post("/login") async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): - """Step 1: Verify email and password, check if 2FA is required.""" + """ + Step 1: Verify email and password, check if 2FA is required. + + Args: + data (UserLogin): The login request payload. + db (AsyncSession): The database session. + + Returns: + dict: If 2FA is required, returns a dict with 2FA info and a temporary token. + Otherwise, returns user info/session. + Raises: + HTTPException: If credentials are invalid. + """ email_hash = hash_email(data.email) password = data.password @@ -102,7 +153,18 @@ async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): @router.post("/login/otp") async def login_otp(data: UserLogin2FA, db: AsyncSession = Depends(get_db)): - """Step 2: Verify OTP code and return user info/session.""" + """ + Step 2: Verify OTP code and return user info/session. + + Args: + data (UserLogin2FA): The 2FA login request payload. + db (AsyncSession): The database session. + + Returns: + dict: User info/session if OTP is valid. + Raises: + HTTPException: If the session token is invalid/expired, 2FA is not enabled, or OTP is invalid. + """ # Validate the temporary session token session = _2fa_sessions.get(data.token) @@ -128,7 +190,18 @@ async def login_otp(data: UserLogin2FA, db: AsyncSession = Depends(get_db)): @router.post("/register") async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): - """Endpoint for user registration.""" + """ + Endpoint for user registration. + + Args: + data (UserRegister): The registration request payload. + db (AsyncSession): The database session. + + Returns: + dict: Registration result with username and discriminator. + Raises: + HTTPException: If the token is missing/invalid, all discriminators are taken, or email exists. + """ token = data.token username = sanitize_username(data.username) password_hash = hash_password(data.password) diff --git a/app/routes/v1/endpoints/email.py b/app/routes/v1/endpoints/email.py index 9a5f078..6cb84d5 100644 --- a/app/routes/v1/endpoints/email.py +++ b/app/routes/v1/endpoints/email.py @@ -1,10 +1,18 @@ +""" +Email endpoints for user confirmation. + +This module provides FastAPI endpoints for sending confirmation emails to users. +It handles the creation of verification tokens, email encryption, and interaction +with the database to register pending users. +""" + from fastapi import APIRouter, BackgroundTasks, Depends from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.routes.v1.schemas.email import EmailRequest -from app.send_email import RegistrationEmailSchema, send_email_background from app.utility.database import get_db +from app.utility.email import RegistrationEmailSchema, send_email_background from app.utility.security import create_verification_token, encrypt_email, hash_email router = APIRouter() @@ -18,7 +26,18 @@ async def send_confirmation_email( ): """ Endpoint to send a confirmation email to the user. - This endpoint accepts a POST request with the user's email in the body. + + This endpoint accepts a POST request with the user's email in the body, + generates a verification token, encrypts and hashes the email, sends a + confirmation email asynchronously, and stores the pending user in the database. + + Args: + data (EmailRequest): The request payload containing the user's email. + background_tasks (BackgroundTasks): FastAPI background task manager. + db (AsyncSession): The database session. + + Returns: + dict: A dictionary with a detail message and the confirmation token. """ email = data.email token = create_verification_token() diff --git a/app/routes/v1/schemas/email/__init__.py b/app/routes/v1/schemas/email/__init__.py index bb7621a..8b420d7 100644 --- a/app/routes/v1/schemas/email/__init__.py +++ b/app/routes/v1/schemas/email/__init__.py @@ -1 +1,9 @@ -from .request import EmailRequest +""" +This module exposes the email-related request schemas for API v1. + +It imports and re-exports the EmailRequest schema for use in endpoint definitions. +""" + +from .request import ( + EmailRequest, # EmailRequest: Schema for email-related API requests. +) diff --git a/app/routes/v1/schemas/email/request.py b/app/routes/v1/schemas/email/request.py index db2aeba..852902e 100644 --- a/app/routes/v1/schemas/email/request.py +++ b/app/routes/v1/schemas/email/request.py @@ -1,5 +1,19 @@ +""" +Schemas for email-related API requests. + +This module defines the Pydantic model used for validating and serializing +email request payloads in the API v1 endpoints. +""" + from pydantic import BaseModel class EmailRequest(BaseModel): + """ + Schema for email-related API requests. + + Attributes: + email (str): The user's email address. + """ + email: str diff --git a/app/routes/v1/schemas/user/__init__.py b/app/routes/v1/schemas/user/__init__.py index c685c9e..888e3e7 100644 --- a/app/routes/v1/schemas/user/__init__.py +++ b/app/routes/v1/schemas/user/__init__.py @@ -1,2 +1,9 @@ +""" +This module exposes user-related request schemas. + +It imports and re-exports the UserLogin, UserLogin2FA, and UserRegister schemas +for use in endpoint definitions. +""" + from .login import UserLogin, UserLogin2FA from .register import UserRegister diff --git a/app/routes/v1/schemas/user/login.py b/app/routes/v1/schemas/user/login.py index 2994efd..76222ac 100644 --- a/app/routes/v1/schemas/user/login.py +++ b/app/routes/v1/schemas/user/login.py @@ -1,11 +1,34 @@ +""" +Schemas for user login and two-factor authentication (2FA) requests. + +This module defines Pydantic models used for validating and serializing +user login and 2FA payloads in the authentication endpoints. +""" + from pydantic import BaseModel class UserLogin(BaseModel): + """ + Schema for user login request. + + Attributes: + email (str): The user's email address. + password (str): The user's password. + """ + email: str password: str class UserLogin2FA(BaseModel): + """ + Schema for user two-factor authentication (2FA) request. + + Attributes: + otp_code (int): The one-time password code for 2FA. + token (str): The temporary token issued after initial login. + """ + otp_code: int token: str diff --git a/app/routes/v1/schemas/user/register.py b/app/routes/v1/schemas/user/register.py index bcecdae..e21b779 100644 --- a/app/routes/v1/schemas/user/register.py +++ b/app/routes/v1/schemas/user/register.py @@ -1,7 +1,24 @@ +""" +Schemas for user registration requests. + +This module defines the Pydantic model used for validating and serializing +user registration payloads in the authentication endpoints. +""" + from pydantic import BaseModel class UserRegister(BaseModel): + """ + Schema for user registration request. + + Attributes: + token (str): The registration or invitation token. + username (str): The desired username for the new user. + password (str): The user's password. + language_id (int | None): Optional language preference identifier. + """ + token: str username: str password: str diff --git a/app/send_email.py b/app/send_email.py deleted file mode 100644 index 7042497..0000000 --- a/app/send_email.py +++ /dev/null @@ -1,62 +0,0 @@ -import os -from pathlib import Path -from typing import Any, Dict, List - -from dotenv import load_dotenv -from fastapi import BackgroundTasks -from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType -from pydantic import BaseModel, EmailStr - -load_dotenv(".env") - - -class Envs: - MAIL_USERNAME = os.getenv("MAIL_USERNAME", "user@example.com") - MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", "password") - MAIL_FROM = os.getenv("MAIL_FROM", "noreply@chocomax.com") - MAIL_PORT = os.getenv("MAIL_PORT", "587") - MAIL_SERVER = os.getenv("MAIL_SERVER", "smtp.example.com") - MAIL_FROM_NAME = os.getenv("MAIL_FROM_NAME", "ChocoMax") - - -conf = ConnectionConfig( - MAIL_USERNAME=Envs.MAIL_USERNAME, - MAIL_PASSWORD=Envs.MAIL_PASSWORD, - MAIL_FROM=Envs.MAIL_FROM, - MAIL_PORT=Envs.MAIL_PORT, - MAIL_SERVER=Envs.MAIL_SERVER, - MAIL_FROM_NAME=Envs.MAIL_FROM_NAME, - MAIL_STARTTLS=True, - MAIL_SSL_TLS=False, - USE_CREDENTIALS=True, - TEMPLATE_FOLDER=Path(__file__).parent / "templates", -) - - -class BaseEmailSchema(BaseModel): - email: List[EmailStr] - subject: str - template_name: str - body: Dict[str, Any] - - -class RegistrationEmailSchema(BaseEmailSchema): - subject: str = "ChocoMax - Email Confirmation" - template_name: str = "email_confirmation.html" - - -class PasswordResetEmailSchema(BaseEmailSchema): - pass # Add specific fields if needed - - -def send_email_background(background_tasks: BackgroundTasks, email: BaseEmailSchema): - message = MessageSchema( - subject=email.subject, - recipients=email.email, - template_body=email.body, - subtype=MessageType.html, - ) - fm = FastMail(conf) - background_tasks.add_task( - fm.send_message, message, template_name=email.template_name - ) diff --git a/app/utility/database.py b/app/utility/database.py index 261b80a..50b5fcb 100644 --- a/app/utility/database.py +++ b/app/utility/database.py @@ -1,3 +1,11 @@ +""" +Database utility module for asynchronous SQLAlchemy sessions. + +This module sets up the asynchronous SQLAlchemy engine and sessionmaker for +database interactions. It provides a dependency function for obtaining a +database session in FastAPI endpoints. +""" + from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker @@ -9,5 +17,14 @@ async def get_db(): + """ + Dependency that provides a SQLAlchemy asynchronous database session. + + Yields: + AsyncSession: An active SQLAlchemy async session for database operations. + + Usage: + Use as a dependency in FastAPI endpoints to access the database. + """ async with SessionLocal() as session: yield session diff --git a/app/utility/email/__init__.py b/app/utility/email/__init__.py new file mode 100644 index 0000000..87121bd --- /dev/null +++ b/app/utility/email/__init__.py @@ -0,0 +1,11 @@ +""" +Email utility package initialization. + +This module exposes the main email configuration, schemas, and sending functions +for use throughout the application. Import from this module to access email-related +utilities in a unified manner. +""" + +from .config import EmailConfig +from .schemas import RegistrationEmailSchema +from .sender import send_email_background diff --git a/app/utility/email/config.py b/app/utility/email/config.py new file mode 100644 index 0000000..6e8ca41 --- /dev/null +++ b/app/utility/email/config.py @@ -0,0 +1,27 @@ +""" +Email configuration module. + +This module loads environment variables and sets up the email connection +configuration for sending emails through the application. +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv +from fastapi_mail import ConnectionConfig + +load_dotenv(".env") + +conf = ConnectionConfig( + MAIL_USERNAME=os.getenv("MAIL_USERNAME", "user@example.com"), + MAIL_PASSWORD=os.getenv("MAIL_PASSWORD", "password"), + MAIL_FROM=os.getenv("MAIL_FROM", "noreply@chocomax.com"), + MAIL_PORT=os.getenv("MAIL_PORT", "587"), + MAIL_SERVER=os.getenv("MAIL_SERVER", "smtp.example.com"), + MAIL_FROM_NAME=os.getenv("MAIL_FROM_NAME", "ChocoMax"), + MAIL_STARTTLS=True, + MAIL_SSL_TLS=False, + USE_CREDENTIALS=True, + TEMPLATE_FOLDER=Path(__file__).parent.parent.parent / "templates", +) diff --git a/app/utility/email/schemas.py b/app/utility/email/schemas.py new file mode 100644 index 0000000..66ff4e6 --- /dev/null +++ b/app/utility/email/schemas.py @@ -0,0 +1,51 @@ +""" +Schemas for email-related payloads. + +This module defines Pydantic models for validating and serializing +email payloads, such as registration and password reset emails, +used by the application's email utility functions. +""" + +from typing import Any, Dict, List + +from pydantic import BaseModel, EmailStr + + +class BaseEmailSchema(BaseModel): + """ + Base schema for email payloads. + + Attributes: + email (List[EmailStr]): List of recipient email addresses. + subject (str): Subject line of the email. + template_name (str): Name of the template to use for the email body. + body (Dict[str, Any]): Data to render within the email template. + """ + + email: List[EmailStr] + subject: str + template_name: str + body: Dict[str, Any] + + +class RegistrationEmailSchema(BaseEmailSchema): + """ + Schema for registration confirmation emails. + + Attributes: + subject (str): Default subject for registration emails. + template_name (str): Default template for registration emails. + """ + + subject: str = "ChocoMax - Email Confirmation" + template_name: str = "email_confirmation.html" + + +class PasswordResetEmailSchema(BaseEmailSchema): + """ + Schema for password reset emails. + + Inherits all fields from BaseEmailSchema. + """ + + pass # TODO diff --git a/app/utility/email/sender.py b/app/utility/email/sender.py new file mode 100644 index 0000000..1b69148 --- /dev/null +++ b/app/utility/email/sender.py @@ -0,0 +1,36 @@ +""" +Email sending utilities. + +This module provides functions for sending emails using FastAPI background tasks +and the FastMail library. It is used to send templated emails asynchronously +throughout the application. +""" + +from fastapi import BackgroundTasks +from fastapi_mail import FastMail, MessageSchema, MessageType + +from .config import conf +from .schemas import BaseEmailSchema + + +def send_email_background(background_tasks: BackgroundTasks, email: BaseEmailSchema): + """ + Send an email in the background using FastAPI's BackgroundTasks. + + Args: + background_tasks (BackgroundTasks): The FastAPI background task manager. + email (BaseEmailSchema): The email payload containing recipients, subject, template, and body. + + This function creates a message from the provided schema and schedules it to be sent + asynchronously using FastMail and the specified template. + """ + message = MessageSchema( + subject=email.subject, + recipients=email.email, + template_body=email.body, + subtype=MessageType.html, + ) + fm = FastMail(conf) + background_tasks.add_task( + fm.send_message, message, template_name=email.template_name + ) diff --git a/app/utility/security.py b/app/utility/security.py index 5cc40e1..0c5d0c2 100644 --- a/app/utility/security.py +++ b/app/utility/security.py @@ -1,3 +1,12 @@ +""" +Security utility module for encryption, hashing, and authentication. + +This module provides functions and constants for handling password hashing, +field encryption/decryption, OTP verification, and normalization of sensitive +fields such as email and phone numbers. It is used throughout the application +to ensure secure handling of user credentials and sensitive data. +""" + import base64 import hashlib import os @@ -14,22 +23,51 @@ def create_verification_token() -> str: - """Generate a secure random token for email verification.""" + """ + Generate a secure random token for email verification. + + Returns: + str: A URL-safe, random token string. + """ return secrets.token_urlsafe(32) def normalize_email(email: str) -> str: - """Normalize email by stripping spaces and converting to lowercase.""" + """ + Normalize email by stripping spaces and converting to lowercase. + + Args: + email (str): The email address to normalize. + + Returns: + str: The normalized email address. + """ return email.strip().lower() def normalize_phone(phone: str) -> str: - """Normalize phone number by stripping spaces and removing non-numeric characters.""" + """ + Normalize phone number by stripping spaces and removing non-numeric characters. + + Args: + phone (str): The phone number to normalize. + + Returns: + str: The normalized phone number. + """ return phone.strip().replace(" ", "") def encrypt_field(value: str) -> str: - """Encrypt a value using AES-256-GCM (returns base64 of IV + ciphertext + tag).""" + """ + Encrypt a value using AES-256-GCM. + + Args: + value (str): The value to encrypt. + + Returns: + str: Base64-encoded string of IV + ciphertext + tag. + """ aesgcm = AESGCM(AES_KEY) iv = os.urandom(12) # 96-bit IV recommended for AES-GCM ciphertext = aesgcm.encrypt(iv, value.encode("utf-8"), associated_data=None) @@ -37,7 +75,15 @@ def encrypt_field(value: str) -> str: def decrypt_field(encrypted_base64: str) -> str: - """Decrypt a value encrypted with AES-256-GCM.""" + """ + Decrypt a value encrypted with AES-256-GCM. + + Args: + encrypted_base64 (str): The base64-encoded encrypted value. + + Returns: + str: The decrypted string. + """ encrypted_data = base64.b64decode(encrypted_base64) iv, ciphertext = encrypted_data[:12], encrypted_data[12:] aesgcm = AESGCM(AES_KEY) @@ -46,18 +92,44 @@ def decrypt_field(encrypted_base64: str) -> str: def hash_field(value: str) -> str: - """Generate a SHA-256 hash of a field (used for fast lookup).""" + """ + Generate a SHA-256 hash of a field (used for fast lookup). + + Args: + value (str): The value to hash. + + Returns: + str: The SHA-256 hash as a hexadecimal string. + """ return hashlib.sha256(value.encode("utf-8")).hexdigest() def hash_password(password: str) -> str: - """Hash a password using Argon2 + pepper.""" + """ + Hash a password using Argon2 and a pepper. + + Args: + password (str): The plaintext password. + + Returns: + str: The Argon2 hash of the peppered password. + """ peppered_password = password.encode("utf-8") + PEPPER return ph.hash(peppered_password) def verify_otp(secret: str, otp_code: str, otp_method: str = "TOTP") -> bool: - """Verify a one-time password (OTP) against a secret using TOTP or HOTP.""" + """ + Verify a one-time password (OTP) against a secret using TOTP or HOTP. + + Args: + secret (str): The OTP secret. + otp_code (str): The OTP code to verify. + otp_method (str): The OTP method ("TOTP" or "HOTP"). + + Returns: + bool: True if the OTP is valid, False otherwise. + """ try: match otp_method: case "TOTP": @@ -71,7 +143,16 @@ def verify_otp(secret: str, otp_code: str, otp_method: str = "TOTP") -> bool: def verify_password(hashed_password: str, password: str) -> bool: - """Verify a password against a hashed password.""" + """ + Verify a password against a hashed password. + + Args: + hashed_password (str): The Argon2 hashed password. + password (str): The plaintext password to verify. + + Returns: + bool: True if the password matches, False otherwise. + """ peppered_password = password.encode("utf-8") + PEPPER try: return ph.verify(hashed_password, peppered_password) @@ -79,24 +160,53 @@ def verify_password(hashed_password: str, password: str) -> bool: return False -# Wrappers for email and phone to ensure normalization and hashing +def hash_email(email: str) -> str: + """ + Generate a SHA-256 hash of the normalized email (used for fast lookup). + Args: + email (str): The email address. -def hash_email(email: str) -> str: - """Generate a SHA-256 hash of the email (used for fast lookup).""" + Returns: + str: The SHA-256 hash of the normalized email. + """ return hash_field(normalize_email(email)) def hash_phone(phone: str) -> str: - """Generate a SHA-256 hash of the phone number (used for fast lookup).""" + """ + Generate a SHA-256 hash of the normalized phone number (used for fast lookup). + + Args: + phone (str): The phone number. + + Returns: + str: The SHA-256 hash of the normalized phone number. + """ return hash_field(normalize_phone(phone)) def encrypt_email(email: str) -> str: - """Encrypt the email after normalizing it.""" + """ + Encrypt the normalized email address. + + Args: + email (str): The email address to encrypt. + + Returns: + str: The encrypted email. + """ return encrypt_field(normalize_email(email)) def encrypt_phone(phone: str) -> str: - """Encrypt the phone number after normalizing it.""" + """ + Encrypt the normalized phone number. + + Args: + phone (str): The phone number to encrypt. + + Returns: + str: The encrypted phone number. + """ return encrypt_field(normalize_phone(phone)) diff --git a/app/utility/test.py b/app/utility/test.py deleted file mode 100644 index e69de29..0000000 From c0d3f528ba6c6e12262461b28fbbb40ba5422779 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 18:25:37 +0000 Subject: [PATCH 17/50] Update linter configuration and clean up unused init files --- .github/workflows/super-linter.yml | 1 + .vscode/tasks.json | 8 ++++---- app/routes/v1/endpoints/authentication.py | 3 ++- app/routes/v1/endpoints/email.py | 5 +++-- app/routes/v1/schemas/email/__init__.py | 9 --------- app/routes/v1/schemas/user/__init__.py | 9 --------- app/templates/email_confirmation.html | 3 +++ app/utility/email/__init__.py | 11 ----------- 8 files changed, 13 insertions(+), 36 deletions(-) diff --git a/.github/workflows/super-linter.yml b/.github/workflows/super-linter.yml index 8935054..1922966 100644 --- a/.github/workflows/super-linter.yml +++ b/.github/workflows/super-linter.yml @@ -51,6 +51,7 @@ jobs: VALIDATE_DOCKERFILE_HADOLINT: false VALIDATE_PYTHON_ISORT: false VALIDATE_PYTHON_MYPY: false + VALIDATE_PYTHON_PYLINT: false FIX_HTML_PRETTIER: true FIX_JSON: true FIX_JSON_PRETTIER: true diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c495184..d4dc839 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -8,7 +8,7 @@ "presentation": { "showReuseMessage": false }, - "problemMatcher": [], + "problemMatcher": [] }, { "label": "Package app", @@ -21,7 +21,7 @@ "presentation": { "panel": "dedicated" }, - "problemMatcher": [], + "problemMatcher": [] }, { "label": "Run all tests", @@ -43,7 +43,7 @@ "presentation": { "close": true }, - "problemMatcher": [], + "problemMatcher": [] }, { "label": "Start FastAPI server", @@ -53,7 +53,7 @@ "panel": "dedicated", "close": true }, - "problemMatcher": [], + "problemMatcher": [] } ] } diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index f3098d0..e27bc26 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -15,7 +15,8 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from app.routes.v1.schemas.user import UserLogin, UserLogin2FA, UserRegister +from app.routes.v1.schemas.user.login import UserLogin, UserLogin2FA +from app.routes.v1.schemas.user.register import UserRegister from app.utility.database import get_db from app.utility.security import hash_email, hash_password, verify_otp, verify_password from app.utility.string_utils import sanitize_username diff --git a/app/routes/v1/endpoints/email.py b/app/routes/v1/endpoints/email.py index 6cb84d5..834308c 100644 --- a/app/routes/v1/endpoints/email.py +++ b/app/routes/v1/endpoints/email.py @@ -10,9 +10,10 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from app.routes.v1.schemas.email import EmailRequest +from app.routes.v1.schemas.email.request import EmailRequest from app.utility.database import get_db -from app.utility.email import RegistrationEmailSchema, send_email_background +from app.utility.email.schemas import RegistrationEmailSchema +from app.utility.email.sender import send_email_background from app.utility.security import create_verification_token, encrypt_email, hash_email router = APIRouter() diff --git a/app/routes/v1/schemas/email/__init__.py b/app/routes/v1/schemas/email/__init__.py index 8b420d7..e69de29 100644 --- a/app/routes/v1/schemas/email/__init__.py +++ b/app/routes/v1/schemas/email/__init__.py @@ -1,9 +0,0 @@ -""" -This module exposes the email-related request schemas for API v1. - -It imports and re-exports the EmailRequest schema for use in endpoint definitions. -""" - -from .request import ( - EmailRequest, # EmailRequest: Schema for email-related API requests. -) diff --git a/app/routes/v1/schemas/user/__init__.py b/app/routes/v1/schemas/user/__init__.py index 888e3e7..e69de29 100644 --- a/app/routes/v1/schemas/user/__init__.py +++ b/app/routes/v1/schemas/user/__init__.py @@ -1,9 +0,0 @@ -""" -This module exposes user-related request schemas. - -It imports and re-exports the UserLogin, UserLogin2FA, and UserRegister schemas -for use in endpoint definitions. -""" - -from .login import UserLogin, UserLogin2FA -from .register import UserRegister diff --git a/app/templates/email_confirmation.html b/app/templates/email_confirmation.html index eb185ca..12f75ca 100644 --- a/app/templates/email_confirmation.html +++ b/app/templates/email_confirmation.html @@ -1,6 +1,9 @@ + + Email Confirmation + - + + - -
-

- {{ title }} -

-
-

Hello!

-

- Thank you for signing up! Please confirm your email address to complete your account creation. -

- - Confirm Your Email - - - + +
+

{{ title }}

+
+

Hello!

+

+ Thank you for signing up! Please confirm your email address + to complete your account creation. +

+ + Confirm Your Email + + + +
-
- - + diff --git a/github_conf/branch_protection_rules.json b/github_conf/branch_protection_rules.json new file mode 100644 index 0000000..e545d76 --- /dev/null +++ b/github_conf/branch_protection_rules.json @@ -0,0 +1,5 @@ +{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest", + "status": "404" +} \ No newline at end of file diff --git a/super-linter-output/super-linter-summary.md b/super-linter-output/super-linter-summary.md new file mode 100644 index 0000000..17189ec --- /dev/null +++ b/super-linter-output/super-linter-summary.md @@ -0,0 +1,21 @@ +# Super-linter summary + +| Language | Validation result | +| -------------------------- | ----------------- | +| CHECKOV | Pass ✅ | +| GITHUB_ACTIONS | Pass ✅ | +| GITLEAKS | Pass ✅ | +| GIT_MERGE_CONFLICT_MARKERS | Pass ✅ | +| HTML | Pass ✅ | +| HTML_PRETTIER | Pass ✅ | +| JSCPD | Pass ✅ | +| JSON | Pass ✅ | +| JSON_PRETTIER | Pass ✅ | +| PYTHON_BLACK | Pass ✅ | +| PYTHON_FLAKE8 | Pass ✅ | +| PYTHON_PYINK | Pass ✅ | +| PYTHON_RUFF | Pass ✅ | +| YAML | Pass ✅ | +| YAML_PRETTIER | Pass ✅ | + +All files and directories linted successfully From 8542f8cce159c0e52b0ffebe80ed7361398348f9 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 18:31:26 +0000 Subject: [PATCH 20/50] Refactor Super Linter configuration to simplify validation settings --- .github/workflows/super-linter.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/super-linter.yml b/.github/workflows/super-linter.yml index 38bac7e..8209037 100644 --- a/.github/workflows/super-linter.yml +++ b/.github/workflows/super-linter.yml @@ -27,10 +27,7 @@ jobs: uses: super-linter/super-linter/slim@v7 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VALIDATE_ALL_CODEBASE: false - FILTER_REGEX_EXCLUDE: '(.devcontainer/Dockerfile|.github/pull_request_template.md|.github/ISSUE_TEMPLATE/*.md)' - VALIDATE_PYTHON_ISORT: false - VALIDATE_PYTHON_MYPY: false + DISABLE_ERRORS: true fix-lint: name: Fix Lint From 9a05ef7058fba2c0e832a1d36c00c5361268e737 Mon Sep 17 00:00:00 2001 From: Vianpyro <10519369+Vianpyro@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:33:22 +0000 Subject: [PATCH 21/50] Super-Linter: Fix linting issues --- github_conf/branch_protection_rules.json | 2 +- super-linter-output/super-linter-summary.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/github_conf/branch_protection_rules.json b/github_conf/branch_protection_rules.json index e545d76..7dfcef9 100644 --- a/github_conf/branch_protection_rules.json +++ b/github_conf/branch_protection_rules.json @@ -2,4 +2,4 @@ "message": "Not Found", "documentation_url": "https://docs.github.com/rest", "status": "404" -} \ No newline at end of file +} diff --git a/super-linter-output/super-linter-summary.md b/super-linter-output/super-linter-summary.md index 17189ec..609c517 100644 --- a/super-linter-output/super-linter-summary.md +++ b/super-linter-output/super-linter-summary.md @@ -11,6 +11,9 @@ | JSCPD | Pass ✅ | | JSON | Pass ✅ | | JSON_PRETTIER | Pass ✅ | +| MARKDOWN | Pass ✅ | +| MARKDOWN_PRETTIER | Pass ✅ | +| NATURAL_LANGUAGE | Pass ✅ | | PYTHON_BLACK | Pass ✅ | | PYTHON_FLAKE8 | Pass ✅ | | PYTHON_PYINK | Pass ✅ | From ea601dc12cd225f806eed3eddb4fc891da22f14a Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 18:35:32 +0000 Subject: [PATCH 22/50] Cleanup: Remove obsolete Super Linter output files and update .gitignore --- .gitignore | 4 ++++ github_conf/branch_protection_rules.json | 5 ----- super-linter-output/super-linter-summary.md | 24 --------------------- 3 files changed, 4 insertions(+), 29 deletions(-) delete mode 100644 github_conf/branch_protection_rules.json delete mode 100644 super-linter-output/super-linter-summary.md diff --git a/.gitignore b/.gitignore index 1cc48e5..0373ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -187,3 +187,7 @@ cython_debug/ # refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore + +# # Super Linter output +# github_conf/branch_protection_rules.json +# super-linter-output/super-linter-summary.md diff --git a/github_conf/branch_protection_rules.json b/github_conf/branch_protection_rules.json deleted file mode 100644 index 7dfcef9..0000000 --- a/github_conf/branch_protection_rules.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Not Found", - "documentation_url": "https://docs.github.com/rest", - "status": "404" -} diff --git a/super-linter-output/super-linter-summary.md b/super-linter-output/super-linter-summary.md deleted file mode 100644 index 609c517..0000000 --- a/super-linter-output/super-linter-summary.md +++ /dev/null @@ -1,24 +0,0 @@ -# Super-linter summary - -| Language | Validation result | -| -------------------------- | ----------------- | -| CHECKOV | Pass ✅ | -| GITHUB_ACTIONS | Pass ✅ | -| GITLEAKS | Pass ✅ | -| GIT_MERGE_CONFLICT_MARKERS | Pass ✅ | -| HTML | Pass ✅ | -| HTML_PRETTIER | Pass ✅ | -| JSCPD | Pass ✅ | -| JSON | Pass ✅ | -| JSON_PRETTIER | Pass ✅ | -| MARKDOWN | Pass ✅ | -| MARKDOWN_PRETTIER | Pass ✅ | -| NATURAL_LANGUAGE | Pass ✅ | -| PYTHON_BLACK | Pass ✅ | -| PYTHON_FLAKE8 | Pass ✅ | -| PYTHON_PYINK | Pass ✅ | -| PYTHON_RUFF | Pass ✅ | -| YAML | Pass ✅ | -| YAML_PRETTIER | Pass ✅ | - -All files and directories linted successfully From 24dad07198a49a615ed88e651a47410c111f9774 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 18:35:51 +0000 Subject: [PATCH 23/50] Update .gitignore to include Super Linter output files --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0373ffe..49267a4 100644 --- a/.gitignore +++ b/.gitignore @@ -189,5 +189,5 @@ cython_debug/ .cursorindexingignore # # Super Linter output -# github_conf/branch_protection_rules.json -# super-linter-output/super-linter-summary.md +github_conf/branch_protection_rules.json +super-linter-output/super-linter-summary.md From 1fbeb68eb94f82322707f390beccbec9b511fff8 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 12 Jun 2025 18:44:12 +0000 Subject: [PATCH 24/50] docs: Update module docstring for clarity and formatting --- app/utility/string_utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/utility/string_utils.py b/app/utility/string_utils.py index abd323b..8ecd937 100644 --- a/app/utility/string_utils.py +++ b/app/utility/string_utils.py @@ -1,3 +1,9 @@ +""" +string_utils.py + +Utility functions for string manipulation and sanitization used throughout the API application. +""" + import re From fee60a7377fc1af4981bb0664c596e6bb95da1bd Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 16:08:13 +0000 Subject: [PATCH 25/50] Add email confirmation tests --- .prettierrc | 6 +- .vscode/settings.json | 24 +++-- .vscode/tasks.json | 7 +- app/routes/v1/schemas/email/request.py | 6 +- requirements-dev.txt | 3 +- tests/test_routes/v1/test_email.py | 116 +++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 tests/test_routes/v1/test_email.py diff --git a/.prettierrc b/.prettierrc index 5ea96ce..5c1606c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -7,7 +7,11 @@ "singleQuote": true, "overrides": [ { - "files": ["*.yml", "*.yaml", "*.md"], + "files": [ + "*.yml", + "*.yaml", + "*.md" + ], "options": { "tabWidth": 2 } diff --git a/.vscode/settings.json b/.vscode/settings.json index 000bbd4..0d2487f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,20 +7,30 @@ "files.trimTrailingWhitespace": true, "files.exclude": { "**/__pycache__": true, - "**/.pytest_cache": true + "**/.pytest_cache": true, + "**/*.egg-info": true }, "[python]": { - "editor.rulers": [88], + "editor.rulers": [ + 80 + ], "editor.defaultFormatter": "ms-python.black-formatter", - "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" + "source.organizeImports": "always" } }, - "isort.args": ["--profile", "black"], + "isort.args": [ + "--profile", + "black" + ], "triggerTaskOnSave.tasks": { - "Run on test file": ["tests/**/test_*.py"], - "Run all tests": ["app/**/*.py", "!tests/**"] + "Run file tests": [ + "tests/**/test_*.py" + ], + "Run all tests": [ + "app/**/*.py", + "!tests/**" + ] }, "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d4dc839..d98fe43 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -34,15 +34,12 @@ "problemMatcher": [] }, { - "label": "Run on test file", + "label": "Run file tests", "type": "shell", - "command": "python3 -m pytest '${relativeFile}' -v -x", + "command": "python3 -m pytest '${file}' -v -x", "group": { "kind": "test" }, - "presentation": { - "close": true - }, "problemMatcher": [] }, { diff --git a/app/routes/v1/schemas/email/request.py b/app/routes/v1/schemas/email/request.py index 852902e..a546123 100644 --- a/app/routes/v1/schemas/email/request.py +++ b/app/routes/v1/schemas/email/request.py @@ -5,7 +5,7 @@ email request payloads in the API v1 endpoints. """ -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr class EmailRequest(BaseModel): @@ -13,7 +13,7 @@ class EmailRequest(BaseModel): Schema for email-related API requests. Attributes: - email (str): The user's email address. + email (EmailStr): The user's email address. """ - email: str + email: EmailStr diff --git a/requirements-dev.txt b/requirements-dev.txt index 5893078..a7d5c9c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ -r requirements.txt -pytest>=8.3.5 build>=1.2.0 +pytest>=8.3.5 +pytest-asyncio>=1.0.0 diff --git a/tests/test_routes/v1/test_email.py b/tests/test_routes/v1/test_email.py new file mode 100644 index 0000000..c86dc54 --- /dev/null +++ b/tests/test_routes/v1/test_email.py @@ -0,0 +1,116 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.routes.v1.endpoints import email as email_module + +EMAIL_PATH = "app.routes.v1.endpoints.email" + + +@pytest.fixture +def client(): + from fastapi import FastAPI + + app = FastAPI() + app.include_router(email_module.router, prefix="/v1/email") + return TestClient(app) + + +@pytest.fixture(autouse=True) +def patch_email_dependencies(): + with ( + patch(f"{EMAIL_PATH}.send_email_background") as send_mock, + patch(f"{EMAIL_PATH}.hash_email", return_value="hashed-email") as hash_mock, + patch( + f"{EMAIL_PATH}.encrypt_email", return_value="encrypted-email" + ) as enc_mock, + patch( + f"{EMAIL_PATH}.create_verification_token", return_value="test-token" + ) as token_mock, + ): + yield { + "send": send_mock, + "hash": hash_mock, + "enc": enc_mock, + "token": token_mock, + } + + +@pytest.fixture +def mock_db_and_override(client): + mock_db = AsyncMock() + + async def override_get_db(): + yield mock_db + + client.app.dependency_overrides[email_module.get_db] = override_get_db + return mock_db + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "email", + [ + "user@example.com", + "test.user+alias@domain.co.uk", + "first.last@sub.domain.com", + "user123@domain.io", + "user_name@domain.org", + ], +) +async def test_send_confirmation_email_success( + client: TestClient, + patch_email_dependencies: dict, + mock_db_and_override: AsyncMock, + email: str, +): + mock_db = mock_db_and_override + + response = client.post("/v1/email/confirmation", json={"email": email}) + + assert response.status_code == 200 + data = response.json() + assert data["detail"] == "Confirmation email sent" + assert data["confirmation_token"] == "test-token" + + patch_email_dependencies["token"].assert_called_once() + patch_email_dependencies["enc"].assert_called_once_with(email) + patch_email_dependencies["hash"].assert_called_once_with(email) + patch_email_dependencies["send"].assert_called_once() + mock_db.execute.assert_awaited_once() + mock_db.commit.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "email", + [ + "", + "not-an-email", + "user@.com", + "user@domain", + "userdomain.com", + "@domain.com", + "user@domain..com", + "user@domain,com", + "user@domain.?com", + ], +) +async def test_send_confirmation_email_failure( + client: TestClient, + patch_email_dependencies: dict, + mock_db_and_override: AsyncMock, + email: str, +): + mock_db = mock_db_and_override + + response = client.post("/v1/email/confirmation", json={"email": email}) + assert response.status_code == 422 # Unprocessable Entity + + patch_email_dependencies["token"].assert_not_called() + patch_email_dependencies["enc"].assert_not_called() + patch_email_dependencies["hash"].assert_not_called() + patch_email_dependencies["send"].assert_not_called() + mock_db.execute.assert_not_awaited() + mock_db.commit.assert_not_awaited() From dea7c018a8c5ac056c656880823ce37f84ac1302 Mon Sep 17 00:00:00 2001 From: Vianpyro <10519369+Vianpyro@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:10:05 +0000 Subject: [PATCH 26/50] Super-Linter: Fix linting issues --- .vscode/settings.json | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0d2487f..1511dea 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,26 +11,16 @@ "**/*.egg-info": true }, "[python]": { - "editor.rulers": [ - 80 - ], + "editor.rulers": [80], "editor.defaultFormatter": "ms-python.black-formatter", "editor.codeActionsOnSave": { "source.organizeImports": "always" } }, - "isort.args": [ - "--profile", - "black" - ], + "isort.args": ["--profile", "black"], "triggerTaskOnSave.tasks": { - "Run file tests": [ - "tests/**/test_*.py" - ], - "Run all tests": [ - "app/**/*.py", - "!tests/**" - ] + "Run file tests": ["tests/**/test_*.py"], + "Run all tests": ["app/**/*.py", "!tests/**"] }, "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true From 269762a1c6fa7570dffef74998ca95f75d28a853 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 16:10:51 +0000 Subject: [PATCH 27/50] Update dependency installation to use requirements-dev.txt --- .github/workflows/run-unit-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 2cc3ae8..ffc53b7 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -45,8 +45,8 @@ jobs: - name: Install dependencies run: | - pip install -r requirements.txt - pip install pytest pytest-cov + pip install -r requirements-dev.txt + pip install pytest-cov - name: Run Pytest (Linux/macOS) if: runner.os != 'Windows' From 41778f1c21a4110895c9d3ce10ffeb3609b7af3c Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 17:03:00 +0000 Subject: [PATCH 28/50] Remove confirmation token from email response --- app/routes/v1/endpoints/email.py | 2 +- tests/test_routes/v1/test_email.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/routes/v1/endpoints/email.py b/app/routes/v1/endpoints/email.py index 834308c..aa8bc82 100644 --- a/app/routes/v1/endpoints/email.py +++ b/app/routes/v1/endpoints/email.py @@ -62,4 +62,4 @@ async def send_confirmation_email( ) await db.commit() - return {"detail": "Confirmation email sent", "confirmation_token": token} + return {"detail": "Confirmation email sent"} diff --git a/tests/test_routes/v1/test_email.py b/tests/test_routes/v1/test_email.py index c86dc54..1373758 100644 --- a/tests/test_routes/v1/test_email.py +++ b/tests/test_routes/v1/test_email.py @@ -72,7 +72,6 @@ async def test_send_confirmation_email_success( assert response.status_code == 200 data = response.json() assert data["detail"] == "Confirmation email sent" - assert data["confirmation_token"] == "test-token" patch_email_dependencies["token"].assert_called_once() patch_email_dependencies["enc"].assert_called_once_with(email) From 475fc1eb4e5b9d5d7f39c03d8d8e650f8b0ee263 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 17:56:24 +0000 Subject: [PATCH 29/50] Refactor security utility functions and enhance test coverage with new fixtures and tests for email and phone encryption, hashing, and verification token generation. --- app/utility/security.py | 44 ++++------------- tests/conftest.py | 17 +++++++ tests/test_home.py | 8 +-- tests/test_routes/v1/authentication.py | 0 tests/test_routes/v1/test_email.py | 1 + tests/test_routes/v1/test_orders.py | 5 +- tests/test_routes/v1/test_products.py | 5 +- tests/test_utility/conftest.py | 19 +++++++ .../test_create_verification_token.py | 38 ++++++++++++++ tests/test_utility/test_encrypt_field.py | 49 +++++++++++++++++++ tests/test_utility/test_hash_field.py | 41 ++++++++++++++++ tests/utils/request.py | 33 +++++++++++++ 12 files changed, 210 insertions(+), 50 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_routes/v1/authentication.py create mode 100644 tests/test_utility/conftest.py create mode 100644 tests/test_utility/test_create_verification_token.py create mode 100644 tests/test_utility/test_encrypt_field.py create mode 100644 tests/test_utility/test_hash_field.py diff --git a/app/utility/security.py b/app/utility/security.py index 0c5d0c2..ce652dc 100644 --- a/app/utility/security.py +++ b/app/utility/security.py @@ -32,32 +32,6 @@ def create_verification_token() -> str: return secrets.token_urlsafe(32) -def normalize_email(email: str) -> str: - """ - Normalize email by stripping spaces and converting to lowercase. - - Args: - email (str): The email address to normalize. - - Returns: - str: The normalized email address. - """ - return email.strip().lower() - - -def normalize_phone(phone: str) -> str: - """ - Normalize phone number by stripping spaces and removing non-numeric characters. - - Args: - phone (str): The phone number to normalize. - - Returns: - str: The normalized phone number. - """ - return phone.strip().replace(" ", "") - - def encrypt_field(value: str) -> str: """ Encrypt a value using AES-256-GCM. @@ -162,33 +136,33 @@ def verify_password(hashed_password: str, password: str) -> bool: def hash_email(email: str) -> str: """ - Generate a SHA-256 hash of the normalized email (used for fast lookup). + Generate a SHA-256 hash of the email (used for fast lookup). Args: email (str): The email address. Returns: - str: The SHA-256 hash of the normalized email. + str: The SHA-256 hash of the email. """ - return hash_field(normalize_email(email)) + return hash_field(email) def hash_phone(phone: str) -> str: """ - Generate a SHA-256 hash of the normalized phone number (used for fast lookup). + Generate a SHA-256 hash of the phone number (used for fast lookup). Args: phone (str): The phone number. Returns: - str: The SHA-256 hash of the normalized phone number. + str: The SHA-256 hash of the phone number. """ - return hash_field(normalize_phone(phone)) + return hash_field(phone) def encrypt_email(email: str) -> str: """ - Encrypt the normalized email address. + Encrypt the email address. Args: email (str): The email address to encrypt. @@ -196,7 +170,7 @@ def encrypt_email(email: str) -> str: Returns: str: The encrypted email. """ - return encrypt_field(normalize_email(email)) + return encrypt_field(email) def encrypt_phone(phone: str) -> str: @@ -209,4 +183,4 @@ def encrypt_phone(phone: str) -> str: Returns: str: The encrypted phone number. """ - return encrypt_field(normalize_phone(phone)) + return encrypt_field(phone) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e3e386c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture +def client(): + """ + Create a FastAPI test client for testing the application. + + Returns: + TestClient: FastAPI test client instance. + """ + client = TestClient(app) + yield client + client.close() diff --git a/tests/test_home.py b/tests/test_home.py index 75fbedc..41ce524 100644 --- a/tests/test_home.py +++ b/tests/test_home.py @@ -2,14 +2,8 @@ Test the home endpoint of the API. """ -from fastapi.testclient import TestClient -from app.main import app - -client = TestClient(app) - - -def test_home(): +def test_home(client): """Test the home endpoint.""" response = client.get("/") assert response.status_code == 200 diff --git a/tests/test_routes/v1/authentication.py b/tests/test_routes/v1/authentication.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_routes/v1/test_email.py b/tests/test_routes/v1/test_email.py index 1373758..d87cb64 100644 --- a/tests/test_routes/v1/test_email.py +++ b/tests/test_routes/v1/test_email.py @@ -53,6 +53,7 @@ async def override_get_db(): "email", [ "user@example.com", + "USER@domain.io", "test.user+alias@domain.co.uk", "first.last@sub.domain.com", "user123@domain.io", diff --git a/tests/test_routes/v1/test_orders.py b/tests/test_routes/v1/test_orders.py index fc294b6..564676e 100644 --- a/tests/test_routes/v1/test_orders.py +++ b/tests/test_routes/v1/test_orders.py @@ -6,13 +6,10 @@ from fastapi.testclient import TestClient -from app.main import app from tests.utils.request import v1_get -client = TestClient(app) - -def test_orders(): +def test_orders(client: TestClient): """ Test that the `/api/v1/orders` endpoint returns a 200 status and responds with a JSON list. diff --git a/tests/test_routes/v1/test_products.py b/tests/test_routes/v1/test_products.py index 1f4d4d9..7733b28 100644 --- a/tests/test_routes/v1/test_products.py +++ b/tests/test_routes/v1/test_products.py @@ -6,13 +6,10 @@ from fastapi.testclient import TestClient -from app.main import app from tests.utils.request import v1_get -client = TestClient(app) - -def test_products(): +def test_products(client: TestClient): """ Test that the `/api/v1/products` endpoint returns a 200 status and responds with a JSON list. diff --git a/tests/test_utility/conftest.py b/tests/test_utility/conftest.py new file mode 100644 index 0000000..601534d --- /dev/null +++ b/tests/test_utility/conftest.py @@ -0,0 +1,19 @@ +import pytest + + +@pytest.fixture +def sample_email(): + """ + Fixture to provide a sample email for testing. + This can be used in tests that require an email. + """ + return "user@example.com" + + +@pytest.fixture +def sample_phone(): + """ + Fixture to provide a sample phone number for testing. + This can be used in tests that require a phone number. + """ + return "+1234567890" diff --git a/tests/test_utility/test_create_verification_token.py b/tests/test_utility/test_create_verification_token.py new file mode 100644 index 0000000..e6bdc64 --- /dev/null +++ b/tests/test_utility/test_create_verification_token.py @@ -0,0 +1,38 @@ +import re + +import pytest + +from app.utility.security import create_verification_token + + +@pytest.fixture +def verification_token(): + """ + Fixture to provide a verification token for testing. + This can be used in tests that require a token. + """ + return create_verification_token() + + +def test_create_verification_token_type(verification_token): + """ + Test the `create_verification_token` function to ensure it generates a token + with the expected structure and content. + """ + assert isinstance(verification_token, str) + + +def test_create_verification_token_length(verification_token): + """ + Test the length of the token generated by `create_verification_token`. + The token should be URL-safe and typically 43 characters long. + """ + assert len(verification_token) == 43 + + +def test_create_verification_token_format(verification_token): + """ + Test the format of the token generated by `create_verification_token`. + The token should be URL-safe, containing alphanumeric characters and hyphens. + """ + assert re.match(r"^[A-Za-z0-9_-]+$", verification_token) diff --git a/tests/test_utility/test_encrypt_field.py b/tests/test_utility/test_encrypt_field.py new file mode 100644 index 0000000..31e3c91 --- /dev/null +++ b/tests/test_utility/test_encrypt_field.py @@ -0,0 +1,49 @@ +import re + +import pytest + +from app.utility.security import decrypt_field, encrypt_email, encrypt_phone + + +@pytest.fixture +def encrypted_email(sample_email): + """ + Fixture to provide an encrypted email for testing. + This can be used in tests that require an encrypted email. + """ + return encrypt_email(sample_email) + + +@pytest.fixture +def encrypted_phone(sample_phone): + """ + Fixture to provide an encrypted phone number for testing. + This can be used in tests that require an encrypted phone number. + """ + return encrypt_phone(sample_phone) + + +def test_encrypt_field_type(encrypted_email, encrypted_phone): + """ + Test that the encrypted fields are of type str. + This ensures that the encryption function returns a string. + """ + assert encrypted_email.isascii() + assert encrypted_phone.isascii() + + +def test_encrypt_field_format(encrypted_email, encrypted_phone): + """ + Test that the encrypted fields are in the expected format. + Encrypted fields should be base64 encoded strings. + """ + assert re.match(r"^[A-Za-z0-9+/=]+$", encrypted_email) + assert re.match(r"^[A-Za-z0-9+/=]+$", encrypted_phone) + + +def test_decrypt_email(sample_email, encrypted_email): + assert decrypt_field(encrypted_email) == sample_email + + +def test_decrypt_phone(sample_phone, encrypted_phone): + assert decrypt_field(encrypted_phone) == sample_phone diff --git a/tests/test_utility/test_hash_field.py b/tests/test_utility/test_hash_field.py new file mode 100644 index 0000000..32b5dad --- /dev/null +++ b/tests/test_utility/test_hash_field.py @@ -0,0 +1,41 @@ +import re + +import pytest + +from app.utility.security import hash_email, hash_phone + + +@pytest.fixture +def hashed_email(sample_email): + """ + Fixture to provide a hashed email for testing. + This can be used in tests that require a hashed email. + """ + return hash_email(sample_email) + + +@pytest.fixture +def hashed_phone(sample_phone): + """ + Fixture to provide a hashed phone number for testing. + This can be used in tests that require a hashed phone number. + """ + return hash_phone(sample_phone) + + +def test_hash_field_type(hashed_email, hashed_phone): + """ + Test that the hashed fields are of type str. + This ensures that the hash function returns a string. + """ + assert isinstance(hashed_email, str) + assert isinstance(hashed_phone, str) + + +def test_hash_field_format(hashed_email, hashed_phone): + """ + Test that the hashed fields are in the expected format. + Hashed fields should be hexadecimal strings. + """ + assert re.match(r"^[0-9a-f]{64}$", hashed_email) + assert re.match(r"^[0-9a-f]{64}$", hashed_phone) diff --git a/tests/utils/request.py b/tests/utils/request.py index d685c0b..ef0fc75 100644 --- a/tests/utils/request.py +++ b/tests/utils/request.py @@ -49,3 +49,36 @@ def v2_get(client: TestClient, path: str) -> TestClient: Response: FastAPI test client response. """ return api_get(client, "v2", path) + + +def api_post(client: TestClient, version: str, path: str, *args, **kwargs): + """ + Perform a POST request to a versioned API path. + + Args: + client (TestClient): FastAPI test client. + version (str): API version, e.g., 'v1' or 'v2'. + path (str): Path to append after the version, e.g., '/orders'. + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + Response: FastAPI test client response. + """ + return client.post(f"/api/{version}{path}", *args, **kwargs) + + +def v1_post(client: TestClient, path: str, *args, **kwargs): + """ + Perform a POST request to a v1 API endpoint. + + Args: + client (TestClient): FastAPI test client. + path (str): Path to append after `/api/v1`. + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + Response: FastAPI test client response. + """ + return api_post(client, "v1", path, *args, **kwargs) From 51ad19b8a4c94165558c67bb4007e735e5fbd06c Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 18:19:08 +0000 Subject: [PATCH 30/50] Add test for non-empty encrypted fields to ensure encryption integrity --- tests/test_utility/test_encrypt_field.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_utility/test_encrypt_field.py b/tests/test_utility/test_encrypt_field.py index 31e3c91..55b0c78 100644 --- a/tests/test_utility/test_encrypt_field.py +++ b/tests/test_utility/test_encrypt_field.py @@ -32,6 +32,15 @@ def test_encrypt_field_type(encrypted_email, encrypted_phone): assert encrypted_phone.isascii() +def test_encrypt_field_length(encrypted_email, encrypted_phone): + """ + Test that the encrypted fields are not empty. + This ensures that the encryption does not produce empty strings. + """ + assert len(encrypted_email) > 0 + assert len(encrypted_phone) > 0 + + def test_encrypt_field_format(encrypted_email, encrypted_phone): """ Test that the encrypted fields are in the expected format. From ace571ec146440c4cd5c193248e6549543785713 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 18:19:13 +0000 Subject: [PATCH 31/50] Enhance OTP verification function to support HOTP counter parameter --- app/utility/security.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/utility/security.py b/app/utility/security.py index ce652dc..29e63d5 100644 --- a/app/utility/security.py +++ b/app/utility/security.py @@ -92,7 +92,9 @@ def hash_password(password: str) -> str: return ph.hash(peppered_password) -def verify_otp(secret: str, otp_code: str, otp_method: str = "TOTP") -> bool: +def verify_otp( + secret: str, otp_code: str, otp_method: str = "TOTP", counter: int = 0 +) -> bool: """ Verify a one-time password (OTP) against a secret using TOTP or HOTP. @@ -100,6 +102,7 @@ def verify_otp(secret: str, otp_code: str, otp_method: str = "TOTP") -> bool: secret (str): The OTP secret. otp_code (str): The OTP code to verify. otp_method (str): The OTP method ("TOTP" or "HOTP"). + counter (int): The HOTP counter (required for HOTP). Returns: bool: True if the OTP is valid, False otherwise. @@ -109,7 +112,7 @@ def verify_otp(secret: str, otp_code: str, otp_method: str = "TOTP") -> bool: case "TOTP": return pyotp.TOTP(secret).verify(otp_code) case "HOTP": - return pyotp.HOTP(secret).verify(otp_code) + return pyotp.HOTP(secret).verify(otp_code, counter) case _: raise ValueError("Unsupported OTP method") except Exception: From f9aed57de8b96ab359727c3645c968d3f7f0cca1 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 18:19:16 +0000 Subject: [PATCH 32/50] Add test for hash field length to validate SHA-256 output --- tests/test_utility/test_hash_field.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_utility/test_hash_field.py b/tests/test_utility/test_hash_field.py index 32b5dad..182445b 100644 --- a/tests/test_utility/test_hash_field.py +++ b/tests/test_utility/test_hash_field.py @@ -32,6 +32,15 @@ def test_hash_field_type(hashed_email, hashed_phone): assert isinstance(hashed_phone, str) +def test_hash_field_length(hashed_email, hashed_phone): + """ + Test that the hashed fields are precisely 64 characters long. + This is the expected length for SHA-256 hashes. + """ + assert len(hashed_email) == 64 + assert len(hashed_phone) == 64 + + def test_hash_field_format(hashed_email, hashed_phone): """ Test that the hashed fields are in the expected format. From 450457e67c8bb476c97a99d9636b2773d7fb26c1 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 18:19:21 +0000 Subject: [PATCH 33/50] Add unit tests for OTP verification functionality --- tests/test_utility/test_verify_otp.py | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_utility/test_verify_otp.py diff --git a/tests/test_utility/test_verify_otp.py b/tests/test_utility/test_verify_otp.py new file mode 100644 index 0000000..ef73562 --- /dev/null +++ b/tests/test_utility/test_verify_otp.py @@ -0,0 +1,59 @@ +import pyotp +import pytest + +from app.utility.security import verify_otp + + +@pytest.fixture +def totp_secret(): + return pyotp.random_base32() + + +@pytest.fixture +def hotp_secret(): + return pyotp.random_base32() + + +@pytest.fixture +def unsupported_secret(): + return pyotp.random_base32() + + +def test_verify_otp_valid_totp(totp_secret): + totp = pyotp.TOTP(totp_secret) + otp_code = totp.now() + assert verify_otp(totp_secret, otp_code, "TOTP") is True + + +def test_verify_otp_invalid_totp(totp_secret): + otp_code = "000000" + assert verify_otp(totp_secret, otp_code, "TOTP") is False + + +def test_verify_otp_valid_hotp(hotp_secret): + hotp = pyotp.HOTP(hotp_secret) + counter = 0 + otp_code = hotp.at(counter) + assert verify_otp(hotp_secret, otp_code, "HOTP", counter) is True + + +def test_verify_otp_invalid_hotp(hotp_secret): + counter = 0 + otp_code = "000000" + assert verify_otp(hotp_secret, otp_code, "HOTP", counter) is False + + +def test_verify_otp_unsupported_method(unsupported_secret): + otp_code = "123456" + assert verify_otp(unsupported_secret, otp_code, "SMS") is False + + +def test_verify_otp_invalid_secret(): + secret = "not_a_valid_secret" + otp_code = "123456" + assert verify_otp(secret, otp_code, "TOTP") is False + + +def test_verify_otp_invalid_code_type(totp_secret): + otp_code = None + assert verify_otp(totp_secret, otp_code, "TOTP") is False From d9b1201a6df89efbf4500cf07832fc48d1890c9a Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 18:58:18 +0000 Subject: [PATCH 34/50] Refactor password verification function and add comprehensive tests for verification logic --- app/routes/v1/endpoints/authentication.py | 2 +- app/utility/security.py | 2 +- tests/test_utility/conftest.py | 10 +--- .../test_create_verification_token.py | 5 +- tests/test_utility/test_encrypt_field.py | 10 +--- tests/test_utility/test_hash_field.py | 10 +--- tests/test_utility/test_verify_password.py | 51 +++++++++++++++++++ 7 files changed, 60 insertions(+), 30 deletions(-) create mode 100644 tests/test_utility/test_verify_password.py diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index e27bc26..1c32f7b 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -112,7 +112,7 @@ async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): # Verify password password_hash = await get_password_hash_by_email_hash(db, email_hash) - if not password_hash or not verify_password(password_hash, password): + if not password_hash or not verify_password(password, password_hash): raise HTTPException(401, "Invalid credentials") # Check if 2FA is enabled before fetching user info diff --git a/app/utility/security.py b/app/utility/security.py index 29e63d5..e8e20eb 100644 --- a/app/utility/security.py +++ b/app/utility/security.py @@ -119,7 +119,7 @@ def verify_otp( return False -def verify_password(hashed_password: str, password: str) -> bool: +def verify_password(password: str, hashed_password: str) -> bool: """ Verify a password against a hashed password. diff --git a/tests/test_utility/conftest.py b/tests/test_utility/conftest.py index 601534d..53eae16 100644 --- a/tests/test_utility/conftest.py +++ b/tests/test_utility/conftest.py @@ -3,17 +3,11 @@ @pytest.fixture def sample_email(): - """ - Fixture to provide a sample email for testing. - This can be used in tests that require an email. - """ + """Fixture to provide a sample email for testing.""" return "user@example.com" @pytest.fixture def sample_phone(): - """ - Fixture to provide a sample phone number for testing. - This can be used in tests that require a phone number. - """ + """Fixture to provide a sample phone number for testing.""" return "+1234567890" diff --git a/tests/test_utility/test_create_verification_token.py b/tests/test_utility/test_create_verification_token.py index e6bdc64..bda3804 100644 --- a/tests/test_utility/test_create_verification_token.py +++ b/tests/test_utility/test_create_verification_token.py @@ -7,10 +7,7 @@ @pytest.fixture def verification_token(): - """ - Fixture to provide a verification token for testing. - This can be used in tests that require a token. - """ + """Fixture to provide a verification token for testing.""" return create_verification_token() diff --git a/tests/test_utility/test_encrypt_field.py b/tests/test_utility/test_encrypt_field.py index 55b0c78..93fe647 100644 --- a/tests/test_utility/test_encrypt_field.py +++ b/tests/test_utility/test_encrypt_field.py @@ -7,19 +7,13 @@ @pytest.fixture def encrypted_email(sample_email): - """ - Fixture to provide an encrypted email for testing. - This can be used in tests that require an encrypted email. - """ + """Fixture to provide an encrypted email for testing.""" return encrypt_email(sample_email) @pytest.fixture def encrypted_phone(sample_phone): - """ - Fixture to provide an encrypted phone number for testing. - This can be used in tests that require an encrypted phone number. - """ + """Fixture to provide an encrypted phone number for testing.""" return encrypt_phone(sample_phone) diff --git a/tests/test_utility/test_hash_field.py b/tests/test_utility/test_hash_field.py index 182445b..a5fe4a3 100644 --- a/tests/test_utility/test_hash_field.py +++ b/tests/test_utility/test_hash_field.py @@ -7,19 +7,13 @@ @pytest.fixture def hashed_email(sample_email): - """ - Fixture to provide a hashed email for testing. - This can be used in tests that require a hashed email. - """ + """Fixture to provide a hashed email for testing.""" return hash_email(sample_email) @pytest.fixture def hashed_phone(sample_phone): - """ - Fixture to provide a hashed phone number for testing. - This can be used in tests that require a hashed phone number. - """ + """Fixture to provide a hashed phone number for testing.""" return hash_phone(sample_phone) diff --git a/tests/test_utility/test_verify_password.py b/tests/test_utility/test_verify_password.py new file mode 100644 index 0000000..ade8ec7 --- /dev/null +++ b/tests/test_utility/test_verify_password.py @@ -0,0 +1,51 @@ +import pytest +from argon2 import PasswordHasher + +from app.utility.security import hash_password, verify_password + +ph = PasswordHasher() + + +@pytest.fixture +def sample_password(): + """Fixture to provide a sample password for testing.""" + return "TestPass123!" + + +def test_verify_password(sample_password): + """ + Test that the password verification works correctly. + This ensures that the password can be hashed and then verified successfully. + """ + hashed_password = hash_password(sample_password) + assert verify_password(sample_password, hashed_password) is True + + +def test_verify_wrong_password(sample_password): + """ + Test that the password verification fails for a wrong password. + This ensures that the verification function does not falsely accept incorrect passwords. + """ + hashed_password = hash_password(sample_password) + assert verify_password("WrongPass123!", hashed_password) is False + + +def test_verify_empty_password(): + """ + Test that the password verification fails for an empty password. + This ensures that the verification function does not accept empty strings as valid passwords. + """ + hashed_password = hash_password("SomePass123!") + hashed_password = hash_password("SomePass123!") + assert verify_password("", hashed_password) is False + assert verify_password("", hashed_password) is False + + +def test_verify_password_tampered(): + """ + Test that the password verification fails if the hash is tampered with. + This ensures that the verification function detects modifications to the hash. + """ + hashed_password = hash_password("AnotherPass!123") + tampered_hash = hashed_password[:-5] + "xyz" # Modify the hash slightly + assert verify_password("AnotherPass!123", tampered_hash) is False From d8678a96e1fe3e248998bc53b807217150857d69 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 19:05:38 +0000 Subject: [PATCH 35/50] Refactor password verification tests to use fixtures for hashed and wrong passwords --- tests/test_utility/test_verify_password.py | 30 +++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/test_utility/test_verify_password.py b/tests/test_utility/test_verify_password.py index ade8ec7..81e97f8 100644 --- a/tests/test_utility/test_verify_password.py +++ b/tests/test_utility/test_verify_password.py @@ -12,40 +12,46 @@ def sample_password(): return "TestPass123!" -def test_verify_password(sample_password): +@pytest.fixture +def hashed_password(sample_password): + """Fixture to provide a hashed password for testing.""" + return hash_password(sample_password) + + +@pytest.fixture +def wrong_password(): + """Fixture to provide a wrong password for testing.""" + return "WrongPass123!" + + +def test_verify_password(sample_password, hashed_password): """ Test that the password verification works correctly. This ensures that the password can be hashed and then verified successfully. """ - hashed_password = hash_password(sample_password) assert verify_password(sample_password, hashed_password) is True -def test_verify_wrong_password(sample_password): +def test_verify_wrong_password(hashed_password, wrong_password): """ Test that the password verification fails for a wrong password. This ensures that the verification function does not falsely accept incorrect passwords. """ - hashed_password = hash_password(sample_password) - assert verify_password("WrongPass123!", hashed_password) is False + assert verify_password(wrong_password, hashed_password) is False -def test_verify_empty_password(): +def test_verify_empty_password(hashed_password): """ Test that the password verification fails for an empty password. This ensures that the verification function does not accept empty strings as valid passwords. """ - hashed_password = hash_password("SomePass123!") - hashed_password = hash_password("SomePass123!") - assert verify_password("", hashed_password) is False assert verify_password("", hashed_password) is False -def test_verify_password_tampered(): +def test_verify_password_tampered(sample_password, hashed_password): """ Test that the password verification fails if the hash is tampered with. This ensures that the verification function detects modifications to the hash. """ - hashed_password = hash_password("AnotherPass!123") tampered_hash = hashed_password[:-5] + "xyz" # Modify the hash slightly - assert verify_password("AnotherPass!123", tampered_hash) is False + assert verify_password(sample_password, tampered_hash) is False From f4e3292ca6bc2709de8dbf9ff3ae089a46f3c652 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 13 Jun 2025 19:40:53 +0000 Subject: [PATCH 36/50] Refactor test client setup in API tests to use FastAPI app instances directly and remove utility functions for versioned requests. --- tests/conftest.py | 17 ------ tests/test_home.py | 14 +++++ tests/test_routes/v1/authentication.py | 17 ++++++ tests/test_routes/v1/test_orders.py | 14 ++++- tests/test_routes/v1/test_products.py | 14 ++++- tests/utils/request.py | 84 -------------------------- 6 files changed, 55 insertions(+), 105 deletions(-) delete mode 100644 tests/conftest.py delete mode 100644 tests/utils/request.py diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e3e386c..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest -from fastapi.testclient import TestClient - -from app.main import app - - -@pytest.fixture -def client(): - """ - Create a FastAPI test client for testing the application. - - Returns: - TestClient: FastAPI test client instance. - """ - client = TestClient(app) - yield client - client.close() diff --git a/tests/test_home.py b/tests/test_home.py index 41ce524..5390b36 100644 --- a/tests/test_home.py +++ b/tests/test_home.py @@ -2,6 +2,20 @@ Test the home endpoint of the API. """ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.routes.home import router as home_router + + +@pytest.fixture +def client(): + """Fixture to create a test client for the FastAPI app with only the home router.""" + app = FastAPI() + app.include_router(home_router) + return TestClient(app) + def test_home(client): """Test the home endpoint.""" diff --git a/tests/test_routes/v1/authentication.py b/tests/test_routes/v1/authentication.py index e69de29..57b7261 100644 --- a/tests/test_routes/v1/authentication.py +++ b/tests/test_routes/v1/authentication.py @@ -0,0 +1,17 @@ +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from app.routes.v1.endpoints import authentication as auth_module + +AUTH_PATH = "app.routes.v1.endpoints.authentication" + + +@pytest.fixture +def client(): + from fastapi import FastAPI + + app = FastAPI() + app.include_router(auth_module.router, prefix="/v1/auth") + return TestClient(app) diff --git a/tests/test_routes/v1/test_orders.py b/tests/test_routes/v1/test_orders.py index 564676e..bc90801 100644 --- a/tests/test_routes/v1/test_orders.py +++ b/tests/test_routes/v1/test_orders.py @@ -4,9 +4,19 @@ This module uses the `v1_get` utility to avoid repeating the API version path. """ +import pytest from fastapi.testclient import TestClient -from tests.utils.request import v1_get +from app.routes.v1.endpoints import orders as orders_module + + +@pytest.fixture +def client(): + from fastapi import FastAPI + + app = FastAPI() + app.include_router(orders_module.router, prefix="/v1/orders") + return TestClient(app) def test_orders(client: TestClient): @@ -14,6 +24,6 @@ def test_orders(client: TestClient): Test that the `/api/v1/orders` endpoint returns a 200 status and responds with a JSON list. """ - response = v1_get(client, "/orders") + response = client.get("/v1/orders") assert response.status_code == 200 assert isinstance(response.json(), list) diff --git a/tests/test_routes/v1/test_products.py b/tests/test_routes/v1/test_products.py index 7733b28..50d400d 100644 --- a/tests/test_routes/v1/test_products.py +++ b/tests/test_routes/v1/test_products.py @@ -4,9 +4,19 @@ This module uses the `v1_get` utility to avoid repeating the API version path. """ +import pytest from fastapi.testclient import TestClient -from tests.utils.request import v1_get +from app.routes.v1.endpoints import products as products_module + + +@pytest.fixture +def client(): + from fastapi import FastAPI + + app = FastAPI() + app.include_router(products_module.router, prefix="/v1/products") + return TestClient(app) def test_products(client: TestClient): @@ -14,6 +24,6 @@ def test_products(client: TestClient): Test that the `/api/v1/products` endpoint returns a 200 status and responds with a JSON list. """ - response = v1_get(client, "/products") + response = client.get("/v1/products") assert response.status_code == 200 assert isinstance(response.json(), list) diff --git a/tests/utils/request.py b/tests/utils/request.py deleted file mode 100644 index ef0fc75..0000000 --- a/tests/utils/request.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -Utility functions to simplify versioned API requests in tests. - -These helpers reduce duplication of versioned API paths like `/api/v1/...`, -improving readability and consistency in test files. -""" - -from fastapi.testclient import TestClient - - -def api_get(client: TestClient, version: str, path: str) -> TestClient: - """ - Perform a GET request to a versioned API path. - - Args: - client (TestClient): FastAPI test client. - version (str): API version, e.g., 'v1' or 'v2'. - path (str): Path to append after the version, e.g., '/orders'. - - Returns: - Response: FastAPI test client response. - """ - return client.get(f"/api/{version}{path}") - - -def v1_get(client: TestClient, path: str) -> TestClient: - """ - Perform a GET request to a v1 API endpoint. - - Args: - client (TestClient): FastAPI test client. - path (str): Path to append after `/api/v1`. - - Returns: - Response: FastAPI test client response. - """ - return api_get(client, "v1", path) - - -def v2_get(client: TestClient, path: str) -> TestClient: - """ - Perform a GET request to a v2 API endpoint. - - Args: - client (TestClient): FastAPI test client. - path (str): Path to append after `/api/v2`. - - Returns: - Response: FastAPI test client response. - """ - return api_get(client, "v2", path) - - -def api_post(client: TestClient, version: str, path: str, *args, **kwargs): - """ - Perform a POST request to a versioned API path. - - Args: - client (TestClient): FastAPI test client. - version (str): API version, e.g., 'v1' or 'v2'. - path (str): Path to append after the version, e.g., '/orders'. - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - - Returns: - Response: FastAPI test client response. - """ - return client.post(f"/api/{version}{path}", *args, **kwargs) - - -def v1_post(client: TestClient, path: str, *args, **kwargs): - """ - Perform a POST request to a v1 API endpoint. - - Args: - client (TestClient): FastAPI test client. - path (str): Path to append after `/api/v1`. - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - - Returns: - Response: FastAPI test client response. - """ - return api_post(client, "v1", path, *args, **kwargs) From 96ae2a3fd02a0c4e6df1cd1febc3d31ecac339c7 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 17 Jun 2025 02:44:27 +0000 Subject: [PATCH 37/50] Implement session and refresh token management in authentication endpoints; add utility functions for token creation and hashing. --- app/routes/v1/endpoints/authentication.py | 160 +++++++++++- app/utility/security.py | 44 +++- requirements.txt | 4 +- tests/test_routes/v1/authentication.py | 17 -- tests/test_routes/v1/test_authentication.py | 260 ++++++++++++++++++++ 5 files changed, 460 insertions(+), 25 deletions(-) delete mode 100644 tests/test_routes/v1/authentication.py create mode 100644 tests/test_routes/v1/test_authentication.py diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 1c32f7b..fa4d08b 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -6,14 +6,17 @@ functions for interacting with the database and handling authentication logic. """ +import json import random import secrets import time +from urllib import request -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pyotp import random_base32 as generate_otp_secret from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from user_agents import parse as parse_user_agent from app.routes.v1.schemas.user.login import UserLogin, UserLogin2FA from app.routes.v1.schemas.user.register import UserRegister @@ -89,11 +92,97 @@ async def get_user_info(db: AsyncSession, email_hash: str): return result.fetchone() +async def save_session_token( + db: AsyncSession, + user_id: int, + session_token: str, + device_info: str, + ip_address: str, +): + """ + Save a session token for a user with device and IP info. + + Args: + db (AsyncSession): The database session. + user_id (int): The user's ID. + session_token (str): The session token to save. + device_info (str): Information about the user's device. + ip_address (str): The user's IP address. + """ + await db.execute( + text( + """ + CALL create_user_session_token( + :p_user_id, + :p_session_token, + :p_device_info, + :p_ip_address + ) + """ + ), + { + "p_user_id": user_id, + "p_session_token": session_token, + "p_device_info": device_info, + "p_ip_address": ip_address, + }, + ) + await db.commit() + + +async def save_refresh_token( + db: AsyncSession, + user_id: int, + session_token: str, + device_info: str, + ip_address: str, +): + """ + Save a session token for a user with device and IP info. + + Args: + db (AsyncSession): The database session. + user_id (int): The user's ID. + session_token (str): The session token to save. + device_info (str): Information about the user's device. + ip_address (str): The user's IP address. + """ + await db.execute( + text( + """ + CALL create_user_refresh_token( + :p_user_id, + :p_session_token, + :p_device_info, + :p_ip_address + ) + """ + ), + { + "p_user_id": user_id, + "p_session_token": session_token, + "p_device_info": device_info, + "p_ip_address": ip_address, + }, + ) + await db.commit() + + +async def delete_refresh_token(): + """Delete a refresh token for a user""" + # TODO + + +async def delete_access_token(): + """Delete an access token for a user""" + # TODO + + # --- Endpoints --- @router.post("/login") -async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): +async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(get_db)): """ Step 1: Verify email and password, check if 2FA is required. @@ -110,6 +199,25 @@ async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): email_hash = hash_email(data.email) password = data.password + # Parse user agent for device info + client_ip = request.client.host + forwarded_ip = request.headers.get("X-Forwarded-For") + ip_address = request.headers.get("X-Real-IP") or request.client.host + user_agent_str = request.headers.get("User-Agent", "") + user_agent = parse_user_agent(user_agent_str) + + device_info = { + "os": user_agent.os.family, + "os_version": user_agent.os.version_string, + "browser": user_agent.browser.family, + "browser_version": user_agent.browser.version_string, + "device": user_agent.device.family, + "is_mobile": user_agent.is_mobile, + "is_tablet": user_agent.is_tablet, + "is_pc": user_agent.is_pc, + "is_bot": user_agent.is_bot, + } + # Verify password password_hash = await get_password_hash_by_email_hash(db, email_hash) if not password_hash or not verify_password(password, password_hash): @@ -147,9 +255,32 @@ async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): "preferred_method": preferred_method, } - # If 2FA is not enabled, return user info/session user_info = await get_user_info(db, email_hash) - return dict(user_info._mapping) + + # After user_info = await get_user_info(db, email_hash) + session_token = secrets.token_urlsafe(32) + refresh_token = secrets.token_urlsafe(32) + + await save_session_token( + db, + user_info.user_id, + session_token, + json.dumps(device_info), + ip_address, + ) + await save_refresh_token( + db, + user_info.user_id, + refresh_token, + json.dumps(device_info), + ip_address, + ) + + return { + **dict(user_info._mapping), + "session_token": session_token, + "refresh_token": refresh_token, + } @router.post("/login/otp") @@ -184,9 +315,26 @@ async def login_otp(data: UserLogin2FA, db: AsyncSession = Depends(get_db)): if not verify_otp(secret, data.otp_code): raise HTTPException(401, "Invalid 2FA code") - # Return user info/session user_info = await get_user_info(db, email_hash) - return dict(user_info._mapping) + + session_token = await save_session_token( + db, + user_info.id, + data.device_info, + data.ip_address, + ) + refresh_token = await save_refresh_token( + db, + user_info.id, + data.device_info, + data.ip_address, + ) + + return { + **dict(user_info._mapping), + "session_token": session_token, + "refresh_token": refresh_token, + } @router.post("/register") diff --git a/app/utility/security.py b/app/utility/security.py index e8e20eb..c13bee6 100644 --- a/app/utility/security.py +++ b/app/utility/security.py @@ -22,6 +22,15 @@ PEPPER = os.getenv("PEPPER", "SuperSecretPepper").encode("utf-8") +def create_token(length: int) -> str: + """ + Generate a secure random token for session management. + Returns: + str: A URL-safe, random token string. + """ + return secrets.token_urlsafe(length) + + def create_verification_token() -> str: """ Generate a secure random token for email verification. @@ -29,7 +38,27 @@ def create_verification_token() -> str: Returns: str: A URL-safe, random token string. """ - return secrets.token_urlsafe(32) + return create_token(32) + + +def create_access_token() -> str: + """ + Generate a secure random token for access control. + + Returns: + str: A URL-safe, random token string. + """ + return create_token(32) + + +def create_refresh_token() -> str: + """ + Generate a secure random token for refresh operations. + + Returns: + str: A URL-safe, random token string. + """ + return create_token(64) def encrypt_field(value: str) -> str: @@ -163,6 +192,19 @@ def hash_phone(phone: str) -> str: return hash_field(phone) +def hash_token(token: str) -> str: + """ + Generate a SHA-256 hash of the token (used for fast lookup). + + Args: + token (str): The token to hash. + + Returns: + str: The SHA-256 hash of the token. + """ + return hash_field(token) + + def encrypt_email(email: str) -> str: """ Encrypt the email address. diff --git a/requirements.txt b/requirements.txt index 3f4d024..fbcf003 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,9 @@ dotenv>=0.9.9 fastapi>=0.115.12 fastapi_mail>=1.5.0 httpx>=0.28.1 -pydantic[email] +pydantic[email]>=2.11.6 pyotp>=2.9.0 +requests>=2.32.4 sqlalchemy>=2.0.41 +user-agents>=2.2.0 uvicorn>=0.34.2 diff --git a/tests/test_routes/v1/authentication.py b/tests/test_routes/v1/authentication.py deleted file mode 100644 index 57b7261..0000000 --- a/tests/test_routes/v1/authentication.py +++ /dev/null @@ -1,17 +0,0 @@ -from unittest.mock import patch - -import pytest -from fastapi.testclient import TestClient - -from app.routes.v1.endpoints import authentication as auth_module - -AUTH_PATH = "app.routes.v1.endpoints.authentication" - - -@pytest.fixture -def client(): - from fastapi import FastAPI - - app = FastAPI() - app.include_router(auth_module.router, prefix="/v1/auth") - return TestClient(app) diff --git a/tests/test_routes/v1/test_authentication.py b/tests/test_routes/v1/test_authentication.py new file mode 100644 index 0000000..5ab7e8f --- /dev/null +++ b/tests/test_routes/v1/test_authentication.py @@ -0,0 +1,260 @@ +import sys +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.routes.v1.endpoints import authentication as auth_module + +AUTH_PATH = "app.routes.v1.endpoints.authentication" + + +@pytest.fixture +def client(): + """ + Returns a FastAPI TestClient with the authentication router included. + """ + from fastapi import FastAPI + + app = FastAPI() + app.include_router(auth_module.router, prefix="/v1/auth") + return TestClient(app) + + +@pytest.fixture(autouse=True) +def patch_auth_dependencies(): + """ + Automatically patches authentication dependencies for all tests. + Provides default mock return values for password hash, password verification, + 2FA secret, user info, and email hashing. + """ + with ( + patch( + f"{AUTH_PATH}.get_password_hash_by_email_hash", new_callable=AsyncMock + ) as get_pw_hash_mock, + patch(f"{AUTH_PATH}.verify_password") as verify_pw_mock, + patch( + f"{AUTH_PATH}.get_2fa_secret", new_callable=AsyncMock + ) as get_2fa_secret_mock, + patch( + f"{AUTH_PATH}.get_user_info", new_callable=AsyncMock + ) as get_user_info_mock, + patch(f"{AUTH_PATH}.hash_email") as hash_email_mock, + ): + get_pw_hash_mock.return_value = "hashed-password" + verify_pw_mock.return_value = True + get_2fa_secret_mock.return_value = None + get_user_info_mock.return_value = AsyncMock(_mapping={"username": "testuser"}) + hash_email_mock.return_value = "dummy-email-hash" + yield { + "get_pw_hash": get_pw_hash_mock, + "verify_pw": verify_pw_mock, + "get_2fa_secret": get_2fa_secret_mock, + "get_user_info": get_user_info_mock, + "hash_email": hash_email_mock, + } + + +@pytest.fixture +def mock_db_and_override(client): + """ + Provides a mock database session and overrides the get_db dependency. + """ + mock_db = AsyncMock() + + async def override_get_db(): + yield mock_db + + client.app.dependency_overrides[auth_module.get_db] = override_get_db + return mock_db + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "username, password", + [ + ("testuser", "password123"), + ("admin", "adminpass"), + ("user123", "userpass"), + ("test.user", "testpass"), + ("first.last", "flpass"), + ], +) +async def test_login_returns_session_tokens( + client, mock_db_and_override, patch_auth_dependencies, username, password +): + """ + Test successful login returns opaque session and refresh tokens when 2FA is not required. + """ + # Patch user info to include user_id for token generation simulation + patch_auth_dependencies["get_user_info"].return_value = AsyncMock( + _mapping={"username": "testuser", "user_id": 42} + ) + + response = client.post( + "/v1/auth/login", json={"email": username, "password": password} + ) + assert response.status_code == 200 + data = response.json() + # Expect opaque tokens in response + assert "session_token" in data + assert "refresh_token" in data + assert data["username"] == "testuser" + assert data["user_id"] == 42 + + +@pytest.mark.asyncio +async def test_login_2fa_required_returns_2fa_token( + client, mock_db_and_override, patch_auth_dependencies +): + """ + Test login returns a 2FA-required response with a temporary token if 2FA is enabled. + """ + + # Simulate 2FA enabled + class Dummy2FASecret: + authentication_secret = "dummysecret" + + patch_auth_dependencies["get_2fa_secret"].return_value = Dummy2FASecret() + + # Simulate available 2FA methods + with ( + patch(f"{AUTH_PATH}.text") as text_mock, + patch(f"{AUTH_PATH}.time") as time_mock, + ): + # Patch DB call for 2FA methods + mock_methods = [ + type("Row", (), {"authentication_method": "TOTP", "is_preferred": True})(), + type("Row", (), {"authentication_method": "SMS", "is_preferred": False})(), + ] + mock_db = mock_db_and_override + mock_execute = AsyncMock() + mock_execute.fetchall.return_value = mock_methods + mock_db.execute.return_value = mock_execute + + response = client.post( + "/v1/auth/login", json={"email": "user2fa", "password": "pw2fa"} + ) + assert response.status_code == 200 + data = response.json() + assert data["2fa_required"] is True + assert "token" in data + assert set(data["methods"]) == {"TOTP", "SMS"} + assert data["preferred_method"] == "TOTP" + + +@pytest.mark.asyncio +async def test_login_invalid_credentials( + client, mock_db_and_override, patch_auth_dependencies +): + """ + Test login with invalid credentials returns 401 and no tokens. + """ + patch_auth_dependencies["verify_pw"].return_value = False + + response = client.post( + "/v1/auth/login", json={"email": "invaliduser", "password": "wrongpass"} + ) + assert response.status_code == 401 + data = response.json() + assert data["detail"] == "Invalid credentials" + + +@pytest.mark.asyncio +async def test_login_missing_fields( + client, mock_db_and_override, patch_auth_dependencies +): + """ + Test login with missing fields returns 422 and no tokens. + """ + response = client.post("/v1/auth/login", json={}) + assert response.status_code == 422 + data = response.json() + assert "detail" in data + + +@pytest.mark.asyncio +async def test_login_otp_success_returns_tokens( + client, mock_db_and_override, patch_auth_dependencies +): + """ + Test /login/otp returns session and refresh tokens on successful OTP verification. + """ + # Simulate valid 2FA session and OTP + patch_auth_dependencies["get_2fa_secret"].return_value = AsyncMock( + authentication_secret="dummysecret" + ) + with patch(f"{AUTH_PATH}.verify_otp") as verify_otp_mock: + verify_otp_mock.return_value = True + patch_auth_dependencies["get_user_info"].return_value = AsyncMock( + _mapping={"username": "testuser", "user_id": 42} + ) + # Simulate valid token in _2fa_sessions + with patch.object( + auth_module, + "_2fa_sessions", + { + "validtoken": { + "email_hash": "dummy-email-hash", + "expires_at": 9999999999, + } + }, + ): + response = client.post( + "/v1/auth/login/otp", + json={"token": "validtoken", "otp_code": "123456"}, + ) + assert response.status_code == 200 + data = response.json() + assert "session_token" in data + assert "refresh_token" in data + assert data["username"] == "testuser" + assert data["user_id"] == 42 + + +@pytest.mark.asyncio +async def test_login_otp_invalid_token( + client, mock_db_and_override, patch_auth_dependencies +): + """ + Test /login/otp with an invalid or expired token returns 401. + """ + with patch.object(auth_module, "_2fa_sessions", {}): + response = client.post( + "/v1/auth/login/otp", + json={"token": "invalidtoken", "otp_code": "123456"}, + ) + assert response.status_code == 401 + data = response.json() + assert "2FA session token" in data["detail"] + + +@pytest.mark.asyncio +async def test_login_otp_invalid_otp( + client, mock_db_and_override, patch_auth_dependencies +): + """ + Test /login/otp with an invalid OTP code returns 401. + """ + patch_auth_dependencies["get_2fa_secret"].return_value = AsyncMock( + authentication_secret="dummysecret" + ) + with patch(f"{AUTH_PATH}.verify_otp") as verify_otp_mock: + verify_otp_mock.return_value = False + with patch.object( + auth_module, + "_2fa_sessions", + { + "validtoken": { + "email_hash": "dummy-email-hash", + "expires_at": sys.maxsize, + } + }, + ): + response = client.post( + "/v1/auth/login/otp", + json={"token": "validtoken", "otp_code": "badotp"}, + ) + assert response.status_code == 401 + data = response.json() + assert "Invalid 2FA code" in data["detail"] From 8ce362953cb5179c24ef97bb7ad5304a069ca222 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 19 Jun 2025 14:55:58 +0000 Subject: [PATCH 38/50] Refactor authentication endpoints to improve device info handling and add payload generation fixtures for login and OTP tests --- app/routes/v1/endpoints/authentication.py | 44 ++++++----- app/routes/v1/schemas/user/login.py | 4 + tests/test_routes/v1/test_authentication.py | 86 +++++++++++++++------ 3 files changed, 91 insertions(+), 43 deletions(-) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index fa4d08b..6cd96eb 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -206,17 +206,19 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge user_agent_str = request.headers.get("User-Agent", "") user_agent = parse_user_agent(user_agent_str) - device_info = { - "os": user_agent.os.family, - "os_version": user_agent.os.version_string, - "browser": user_agent.browser.family, - "browser_version": user_agent.browser.version_string, - "device": user_agent.device.family, - "is_mobile": user_agent.is_mobile, - "is_tablet": user_agent.is_tablet, - "is_pc": user_agent.is_pc, - "is_bot": user_agent.is_bot, - } + device_info = json.dumps( + { + "os": user_agent.os.family, + "os_version": user_agent.os.version_string, + "browser": user_agent.browser.family, + "browser_version": user_agent.browser.version_string, + "device": user_agent.device.family, + "is_mobile": user_agent.is_mobile, + "is_tablet": user_agent.is_tablet, + "is_pc": user_agent.is_pc, + "is_bot": user_agent.is_bot, + } + ) # Verify password password_hash = await get_password_hash_by_email_hash(db, email_hash) @@ -240,7 +242,7 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge text("SELECT * FROM get_user_2fa_methods_by_email_hash(:email_hash)"), {"email_hash": email_hash}, ) - methods_rows = result.fetchall() + methods_rows = await result.fetchall() methods = [row.authentication_method for row in methods_rows] preferred_method = next( (row.authentication_method for row in methods_rows if row.is_preferred), @@ -257,7 +259,6 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge user_info = await get_user_info(db, email_hash) - # After user_info = await get_user_info(db, email_hash) session_token = secrets.token_urlsafe(32) refresh_token = secrets.token_urlsafe(32) @@ -265,14 +266,14 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge db, user_info.user_id, session_token, - json.dumps(device_info), + device_info, ip_address, ) await save_refresh_token( db, user_info.user_id, refresh_token, - json.dumps(device_info), + device_info, ip_address, ) @@ -317,19 +318,22 @@ async def login_otp(data: UserLogin2FA, db: AsyncSession = Depends(get_db)): user_info = await get_user_info(db, email_hash) - session_token = await save_session_token( + session_token = secrets.token_urlsafe(32) + refresh_token = secrets.token_urlsafe(32) + await save_session_token( db, - user_info.id, + user_info.user_id, + session_token, data.device_info, data.ip_address, ) - refresh_token = await save_refresh_token( + await save_refresh_token( db, - user_info.id, + user_info.user_id, + refresh_token, data.device_info, data.ip_address, ) - return { **dict(user_info._mapping), "session_token": session_token, diff --git a/app/routes/v1/schemas/user/login.py b/app/routes/v1/schemas/user/login.py index 76222ac..43a2360 100644 --- a/app/routes/v1/schemas/user/login.py +++ b/app/routes/v1/schemas/user/login.py @@ -19,6 +19,8 @@ class UserLogin(BaseModel): email: str password: str + device_info: str | None = None + ip_address: str class UserLogin2FA(BaseModel): @@ -32,3 +34,5 @@ class UserLogin2FA(BaseModel): otp_code: int token: str + device_info: str | None = None + ip_address: str diff --git a/tests/test_routes/v1/test_authentication.py b/tests/test_routes/v1/test_authentication.py index 5ab7e8f..b80de96 100644 --- a/tests/test_routes/v1/test_authentication.py +++ b/tests/test_routes/v1/test_authentication.py @@ -69,6 +69,46 @@ async def override_get_db(): return mock_db +@pytest.fixture +def login_payload(): + """Returns a function to generate login payloads.""" + + def _payload( + email="testuser", + password="password123", + ip_address="127.0.0.1", + device_info="test-device", + ): + return { + "email": email, + "password": password, + "ip_address": ip_address, + "device_info": device_info, + } + + return _payload + + +@pytest.fixture +def otp_payload(): + """Returns a function to generate OTP login payloads.""" + + def _payload( + token="validtoken", + otp_code=123456, + ip_address="127.0.0.1", + device_info="test-device", + ): + return { + "token": token, + "otp_code": otp_code, + "ip_address": ip_address, + "device_info": device_info, + } + + return _payload + + @pytest.mark.asyncio @pytest.mark.parametrize( "username, password", @@ -81,7 +121,12 @@ async def override_get_db(): ], ) async def test_login_returns_session_tokens( - client, mock_db_and_override, patch_auth_dependencies, username, password + client, + mock_db_and_override, + patch_auth_dependencies, + username, + password, + login_payload, ): """ Test successful login returns opaque session and refresh tokens when 2FA is not required. @@ -91,9 +136,7 @@ async def test_login_returns_session_tokens( _mapping={"username": "testuser", "user_id": 42} ) - response = client.post( - "/v1/auth/login", json={"email": username, "password": password} - ) + response = client.post("/v1/auth/login", json=login_payload()) assert response.status_code == 200 data = response.json() # Expect opaque tokens in response @@ -105,7 +148,7 @@ async def test_login_returns_session_tokens( @pytest.mark.asyncio async def test_login_2fa_required_returns_2fa_token( - client, mock_db_and_override, patch_auth_dependencies + client, mock_db_and_override, patch_auth_dependencies, login_payload ): """ Test login returns a 2FA-required response with a temporary token if 2FA is enabled. @@ -132,9 +175,8 @@ class Dummy2FASecret: mock_execute.fetchall.return_value = mock_methods mock_db.execute.return_value = mock_execute - response = client.post( - "/v1/auth/login", json={"email": "user2fa", "password": "pw2fa"} - ) + response = client.post("/v1/auth/login", json=login_payload()) + assert response.status_code == 200 data = response.json() assert data["2fa_required"] is True @@ -145,7 +187,7 @@ class Dummy2FASecret: @pytest.mark.asyncio async def test_login_invalid_credentials( - client, mock_db_and_override, patch_auth_dependencies + client, mock_db_and_override, patch_auth_dependencies, login_payload ): """ Test login with invalid credentials returns 401 and no tokens. @@ -153,8 +195,10 @@ async def test_login_invalid_credentials( patch_auth_dependencies["verify_pw"].return_value = False response = client.post( - "/v1/auth/login", json={"email": "invaliduser", "password": "wrongpass"} + "/v1/auth/login", + json=login_payload(email="invaliduser", password="wrongpassword"), ) + assert response.status_code == 401 data = response.json() assert data["detail"] == "Invalid credentials" @@ -175,7 +219,7 @@ async def test_login_missing_fields( @pytest.mark.asyncio async def test_login_otp_success_returns_tokens( - client, mock_db_and_override, patch_auth_dependencies + client, mock_db_and_override, patch_auth_dependencies, otp_payload ): """ Test /login/otp returns session and refresh tokens on successful OTP verification. @@ -200,10 +244,8 @@ async def test_login_otp_success_returns_tokens( } }, ): - response = client.post( - "/v1/auth/login/otp", - json={"token": "validtoken", "otp_code": "123456"}, - ) + response = client.post("/v1/auth/login/otp", json=otp_payload()) + assert response.status_code == 200 data = response.json() assert "session_token" in data @@ -214,16 +256,16 @@ async def test_login_otp_success_returns_tokens( @pytest.mark.asyncio async def test_login_otp_invalid_token( - client, mock_db_and_override, patch_auth_dependencies + client, mock_db_and_override, patch_auth_dependencies, otp_payload ): """ Test /login/otp with an invalid or expired token returns 401. """ with patch.object(auth_module, "_2fa_sessions", {}): response = client.post( - "/v1/auth/login/otp", - json={"token": "invalidtoken", "otp_code": "123456"}, + "/v1/auth/login/otp", json=otp_payload(token="invalidtoken") ) + assert response.status_code == 401 data = response.json() assert "2FA session token" in data["detail"] @@ -231,7 +273,7 @@ async def test_login_otp_invalid_token( @pytest.mark.asyncio async def test_login_otp_invalid_otp( - client, mock_db_and_override, patch_auth_dependencies + client, mock_db_and_override, patch_auth_dependencies, otp_payload ): """ Test /login/otp with an invalid OTP code returns 401. @@ -251,10 +293,8 @@ async def test_login_otp_invalid_otp( } }, ): - response = client.post( - "/v1/auth/login/otp", - json={"token": "validtoken", "otp_code": "badotp"}, - ) + response = client.post("/v1/auth/login/otp", json=otp_payload()) + assert response.status_code == 401 data = response.json() assert "Invalid 2FA code" in data["detail"] From 40c94451bb51be6599397aeec581f8c6c8ef421a Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 19 Jun 2025 15:06:18 +0000 Subject: [PATCH 39/50] Refactor authentication endpoints to streamline device info handling and remove unnecessary fields from login payloads in schemas and tests. --- app/routes/v1/endpoints/authentication.py | 46 ++++++++++++++------- app/routes/v1/schemas/user/login.py | 4 -- tests/test_routes/v1/test_authentication.py | 18 +------- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 6cd96eb..46e9c91 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -204,19 +204,16 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge forwarded_ip = request.headers.get("X-Forwarded-For") ip_address = request.headers.get("X-Real-IP") or request.client.host user_agent_str = request.headers.get("User-Agent", "") - user_agent = parse_user_agent(user_agent_str) - + ua = parse_user_agent(user_agent_str) device_info = json.dumps( { - "os": user_agent.os.family, - "os_version": user_agent.os.version_string, - "browser": user_agent.browser.family, - "browser_version": user_agent.browser.version_string, - "device": user_agent.device.family, - "is_mobile": user_agent.is_mobile, - "is_tablet": user_agent.is_tablet, - "is_pc": user_agent.is_pc, - "is_bot": user_agent.is_bot, + "family": ua.device.family, + "brand": ua.device.brand, + "model": ua.device.model, + "is_mobile": ua.is_mobile, + "is_tablet": ua.is_tablet, + "is_pc": ua.is_pc, + "is_bot": ua.is_bot, } ) @@ -285,7 +282,9 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge @router.post("/login/otp") -async def login_otp(data: UserLogin2FA, db: AsyncSession = Depends(get_db)): +async def login_otp( + data: UserLogin2FA, request: Request, db: AsyncSession = Depends(get_db) +): """ Step 2: Verify OTP code and return user info/session. @@ -298,6 +297,21 @@ async def login_otp(data: UserLogin2FA, db: AsyncSession = Depends(get_db)): Raises: HTTPException: If the session token is invalid/expired, 2FA is not enabled, or OTP is invalid. """ + user_agent_str = request.headers.get("User-Agent", "") + ua = parse_user_agent(user_agent_str) + device_info = json.dumps( + { + "family": ua.device.family, + "brand": ua.device.brand, + "model": ua.device.model, + "is_mobile": ua.is_mobile, + "is_tablet": ua.is_tablet, + "is_pc": ua.is_pc, + "is_bot": ua.is_bot, + } + ) + ip_address = request.headers.get("X-Real-IP") or request.client.host + # Validate the temporary session token session = _2fa_sessions.get(data.token) @@ -324,15 +338,15 @@ async def login_otp(data: UserLogin2FA, db: AsyncSession = Depends(get_db)): db, user_info.user_id, session_token, - data.device_info, - data.ip_address, + device_info, + ip_address, ) await save_refresh_token( db, user_info.user_id, refresh_token, - data.device_info, - data.ip_address, + device_info, + ip_address, ) return { **dict(user_info._mapping), diff --git a/app/routes/v1/schemas/user/login.py b/app/routes/v1/schemas/user/login.py index 43a2360..76222ac 100644 --- a/app/routes/v1/schemas/user/login.py +++ b/app/routes/v1/schemas/user/login.py @@ -19,8 +19,6 @@ class UserLogin(BaseModel): email: str password: str - device_info: str | None = None - ip_address: str class UserLogin2FA(BaseModel): @@ -34,5 +32,3 @@ class UserLogin2FA(BaseModel): otp_code: int token: str - device_info: str | None = None - ip_address: str diff --git a/tests/test_routes/v1/test_authentication.py b/tests/test_routes/v1/test_authentication.py index b80de96..76430cc 100644 --- a/tests/test_routes/v1/test_authentication.py +++ b/tests/test_routes/v1/test_authentication.py @@ -73,17 +73,10 @@ async def override_get_db(): def login_payload(): """Returns a function to generate login payloads.""" - def _payload( - email="testuser", - password="password123", - ip_address="127.0.0.1", - device_info="test-device", - ): + def _payload(email="testuser", password="password123"): return { "email": email, "password": password, - "ip_address": ip_address, - "device_info": device_info, } return _payload @@ -93,17 +86,10 @@ def _payload( def otp_payload(): """Returns a function to generate OTP login payloads.""" - def _payload( - token="validtoken", - otp_code=123456, - ip_address="127.0.0.1", - device_info="test-device", - ): + def _payload(token="validtoken", otp_code=123456): return { "token": token, "otp_code": otp_code, - "ip_address": ip_address, - "device_info": device_info, } return _payload From a9c96d00fe1ca2b6eb6d0f1ba3d9ae52f8f34141 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 19 Jun 2025 17:20:21 +0000 Subject: [PATCH 40/50] Refactor login tests to simplify function signatures by removing unnecessary parameters. --- tests/test_routes/v1/test_authentication.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/test_routes/v1/test_authentication.py b/tests/test_routes/v1/test_authentication.py index 76430cc..79c5af0 100644 --- a/tests/test_routes/v1/test_authentication.py +++ b/tests/test_routes/v1/test_authentication.py @@ -191,9 +191,7 @@ async def test_login_invalid_credentials( @pytest.mark.asyncio -async def test_login_missing_fields( - client, mock_db_and_override, patch_auth_dependencies -): +async def test_login_missing_fields(client, mock_db_and_override): """ Test login with missing fields returns 422 and no tokens. """ @@ -241,9 +239,7 @@ async def test_login_otp_success_returns_tokens( @pytest.mark.asyncio -async def test_login_otp_invalid_token( - client, mock_db_and_override, patch_auth_dependencies, otp_payload -): +async def test_login_otp_invalid_token(client, otp_payload): """ Test /login/otp with an invalid or expired token returns 401. """ @@ -258,9 +254,7 @@ async def test_login_otp_invalid_token( @pytest.mark.asyncio -async def test_login_otp_invalid_otp( - client, mock_db_and_override, patch_auth_dependencies, otp_payload -): +async def test_login_otp_invalid_otp(client, patch_auth_dependencies, otp_payload): """ Test /login/otp with an invalid OTP code returns 401. """ From 3a9da42166683607d37e6aae38e3cdd5ea1af292 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 19 Jun 2025 17:23:08 +0000 Subject: [PATCH 41/50] Refactor device info extraction and session creation in login endpoint for improved clarity and reusability. --- app/routes/v1/endpoints/authentication.py | 63 +++++++++++++++++------ 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 46e9c91..9933d1c 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -178,6 +178,50 @@ async def delete_access_token(): # TODO +def get_device_info_and_ip(request: Request): + """Extract device info and IP address from the request.""" + user_agent_str = request.headers.get("User-Agent", "") + ua = parse_user_agent(user_agent_str) + device_info = json.dumps( + { + "family": ua.device.family, + "brand": ua.device.brand, + "model": ua.device.model, + "is_mobile": ua.is_mobile, + "is_tablet": ua.is_tablet, + "is_pc": ua.is_pc, + "is_bot": ua.is_bot, + } + ) + ip_address = request.headers.get("X-Real-IP") or request.client.host + return device_info, ip_address + + +async def create_and_return_session(db, user_info, device_info, ip_address): + """Create session and refresh tokens, save them, and return user info with tokens.""" + session_token = secrets.token_urlsafe(32) + refresh_token = secrets.token_urlsafe(32) + await save_session_token( + db, + user_info.user_id, + session_token, + device_info, + ip_address, + ) + await save_refresh_token( + db, + user_info.user_id, + refresh_token, + device_info, + ip_address, + ) + return { + **dict(user_info._mapping), + "session_token": session_token, + "refresh_token": refresh_token, + } + + # --- Endpoints --- @@ -200,22 +244,7 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge password = data.password # Parse user agent for device info - client_ip = request.client.host - forwarded_ip = request.headers.get("X-Forwarded-For") - ip_address = request.headers.get("X-Real-IP") or request.client.host - user_agent_str = request.headers.get("User-Agent", "") - ua = parse_user_agent(user_agent_str) - device_info = json.dumps( - { - "family": ua.device.family, - "brand": ua.device.brand, - "model": ua.device.model, - "is_mobile": ua.is_mobile, - "is_tablet": ua.is_tablet, - "is_pc": ua.is_pc, - "is_bot": ua.is_bot, - } - ) + device_info, ip_address = get_device_info_and_ip(request) # Verify password password_hash = await get_password_hash_by_email_hash(db, email_hash) @@ -249,7 +278,7 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge if methods: return { "2fa_required": True, - "token": temp_token, # Return token instead of email_hash + "token": temp_token, "methods": methods, "preferred_method": preferred_method, } From 0c3b014484e0d10cefe44ebf720806364113ea2f Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 19 Jun 2025 19:12:18 +0000 Subject: [PATCH 42/50] Add user agent handling to session token saving and login process --- .devcontainer/devcontainer.json | 3 ++ app/routes/v1/endpoints/authentication.py | 47 +++++++++++++++-------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4706358..dfdbda9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,6 +14,9 @@ ] } }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + }, "postStartCommand": "pip3 install --user -r requirements-dev.txt", "postAttachCommand": "python3 -m pytest tests", "remoteUser": "vscode" diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 9933d1c..4c3b405 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -98,6 +98,7 @@ async def save_session_token( session_token: str, device_info: str, ip_address: str, + user_agent: str, ): """ Save a session token for a user with device and IP info. @@ -116,7 +117,8 @@ async def save_session_token( :p_user_id, :p_session_token, :p_device_info, - :p_ip_address + :p_ip_address, + :p_user_agent ) """ ), @@ -125,6 +127,7 @@ async def save_session_token( "p_session_token": session_token, "p_device_info": device_info, "p_ip_address": ip_address, + "p_user_agent": user_agent, }, ) await db.commit() @@ -136,6 +139,7 @@ async def save_refresh_token( session_token: str, device_info: str, ip_address: str, + user_agent: str, ): """ Save a session token for a user with device and IP info. @@ -154,7 +158,8 @@ async def save_refresh_token( :p_user_id, :p_session_token, :p_device_info, - :p_ip_address + :p_ip_address, + :p_user_agent ) """ ), @@ -163,6 +168,7 @@ async def save_refresh_token( "p_session_token": session_token, "p_device_info": device_info, "p_ip_address": ip_address, + "p_user_agent": user_agent, }, ) await db.commit() @@ -194,29 +200,36 @@ def get_device_info_and_ip(request: Request): } ) ip_address = request.headers.get("X-Real-IP") or request.client.host - return device_info, ip_address + return device_info, ip_address, user_agent_str + + +def filter_user_fields(user_dict, fields): + return {k: user_dict[k] for k in fields if k in user_dict} async def create_and_return_session(db, user_info, device_info, ip_address): - """Create session and refresh tokens, save them, and return user info with tokens.""" + """Create session and refresh tokens, save them, and return selected user info with tokens.""" session_token = secrets.token_urlsafe(32) refresh_token = secrets.token_urlsafe(32) + await save_session_token( - db, - user_info.user_id, - session_token, - device_info, - ip_address, + db, user_info.user_id, session_token, device_info, ip_address ) await save_refresh_token( - db, - user_info.user_id, - refresh_token, - device_info, - ip_address, + db, user_info.user_id, refresh_token, device_info, ip_address ) + + user_dict = dict(user_info._mapping) + selected_fields = [ + "username", + "discriminator", + "language_id", + "display_role", + "created_at", + ] + return { - **dict(user_info._mapping), + **filter_user_fields(user_dict, selected_fields), "session_token": session_token, "refresh_token": refresh_token, } @@ -244,7 +257,7 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge password = data.password # Parse user agent for device info - device_info, ip_address = get_device_info_and_ip(request) + device_info, ip_address, user_agent_str = get_device_info_and_ip(request) # Verify password password_hash = await get_password_hash_by_email_hash(db, email_hash) @@ -294,6 +307,7 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge session_token, device_info, ip_address, + user_agent_str, ) await save_refresh_token( db, @@ -301,6 +315,7 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge refresh_token, device_info, ip_address, + user_agent_str, ) return { From 5a04243dd31b923c05de03058bbe2bfde84f1495 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 19 Jun 2025 19:25:40 +0000 Subject: [PATCH 43/50] Remove unused TODO functions as the Database will handle the removal of old session and refresh tokens --- app/routes/v1/endpoints/authentication.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 4c3b405..5c5eae5 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -10,7 +10,6 @@ import random import secrets import time -from urllib import request from fastapi import APIRouter, Depends, HTTPException, Request from pyotp import random_base32 as generate_otp_secret @@ -174,16 +173,6 @@ async def save_refresh_token( await db.commit() -async def delete_refresh_token(): - """Delete a refresh token for a user""" - # TODO - - -async def delete_access_token(): - """Delete an access token for a user""" - # TODO - - def get_device_info_and_ip(request: Request): """Extract device info and IP address from the request.""" user_agent_str = request.headers.get("User-Agent", "") From f3f595c2e160f4ca5a1e3fef7f708b4a49c1507c Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 20 Jun 2025 02:14:56 +0000 Subject: [PATCH 44/50] Add user agent string to login OTP session and refresh token saving --- app/routes/v1/endpoints/authentication.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 5c5eae5..716d558 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -373,6 +373,7 @@ async def login_otp( session_token, device_info, ip_address, + user_agent_str, ) await save_refresh_token( db, @@ -380,6 +381,7 @@ async def login_otp( refresh_token, device_info, ip_address, + user_agent_str, ) return { **dict(user_info._mapping), From d4439b17176e8a133240f9fddce2b4a3c3be0cfe Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 20 Jun 2025 02:20:02 +0000 Subject: [PATCH 45/50] Add greenlet dependency to requirements for improved concurrency support --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index fbcf003..1d48c3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ cryptography>=45.0.3 dotenv>=0.9.9 fastapi>=0.115.12 fastapi_mail>=1.5.0 +greenlet>=3.2.3 httpx>=0.28.1 pydantic[email]>=2.11.6 pyotp>=2.9.0 From fe294f313307addcaf5e75a504d2265180bb34cb Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 20 Jun 2025 02:29:40 +0000 Subject: [PATCH 46/50] Refactor session creation to include user agent string and update tests to remove user_id assertions --- app/routes/v1/endpoints/authentication.py | 78 +++------------------ tests/test_routes/v1/test_authentication.py | 6 +- 2 files changed, 12 insertions(+), 72 deletions(-) diff --git a/app/routes/v1/endpoints/authentication.py b/app/routes/v1/endpoints/authentication.py index 716d558..e82b4a0 100644 --- a/app/routes/v1/endpoints/authentication.py +++ b/app/routes/v1/endpoints/authentication.py @@ -196,16 +196,18 @@ def filter_user_fields(user_dict, fields): return {k: user_dict[k] for k in fields if k in user_dict} -async def create_and_return_session(db, user_info, device_info, ip_address): +async def create_and_return_session( + db, user_info, device_info, ip_address, user_agent_str +): """Create session and refresh tokens, save them, and return selected user info with tokens.""" session_token = secrets.token_urlsafe(32) refresh_token = secrets.token_urlsafe(32) await save_session_token( - db, user_info.user_id, session_token, device_info, ip_address + db, user_info.user_id, session_token, device_info, ip_address, user_agent_str ) await save_refresh_token( - db, user_info.user_id, refresh_token, device_info, ip_address + db, user_info.user_id, refresh_token, device_info, ip_address, user_agent_str ) user_dict = dict(user_info._mapping) @@ -286,33 +288,10 @@ async def login(data: UserLogin, request: Request, db: AsyncSession = Depends(ge } user_info = await get_user_info(db, email_hash) - - session_token = secrets.token_urlsafe(32) - refresh_token = secrets.token_urlsafe(32) - - await save_session_token( - db, - user_info.user_id, - session_token, - device_info, - ip_address, - user_agent_str, - ) - await save_refresh_token( - db, - user_info.user_id, - refresh_token, - device_info, - ip_address, - user_agent_str, + return await create_and_return_session( + db, user_info, device_info, ip_address, user_agent_str ) - return { - **dict(user_info._mapping), - "session_token": session_token, - "refresh_token": refresh_token, - } - @router.post("/login/otp") async def login_otp( @@ -330,30 +309,14 @@ async def login_otp( Raises: HTTPException: If the session token is invalid/expired, 2FA is not enabled, or OTP is invalid. """ - user_agent_str = request.headers.get("User-Agent", "") - ua = parse_user_agent(user_agent_str) - device_info = json.dumps( - { - "family": ua.device.family, - "brand": ua.device.brand, - "model": ua.device.model, - "is_mobile": ua.is_mobile, - "is_tablet": ua.is_tablet, - "is_pc": ua.is_pc, - "is_bot": ua.is_bot, - } - ) - ip_address = request.headers.get("X-Real-IP") or request.client.host + device_info, ip_address, user_agent_str = get_device_info_and_ip(request) - # Validate the temporary session token session = _2fa_sessions.get(data.token) - if not session or session["expires_at"] < time.time(): raise HTTPException(401, "Invalid or expired 2FA session token") email_hash = session["email_hash"] - # Get 2FA secret and method row = await get_2fa_secret(db, email_hash) secret = row.authentication_secret if row else None @@ -364,30 +327,9 @@ async def login_otp( raise HTTPException(401, "Invalid 2FA code") user_info = await get_user_info(db, email_hash) - - session_token = secrets.token_urlsafe(32) - refresh_token = secrets.token_urlsafe(32) - await save_session_token( - db, - user_info.user_id, - session_token, - device_info, - ip_address, - user_agent_str, - ) - await save_refresh_token( - db, - user_info.user_id, - refresh_token, - device_info, - ip_address, - user_agent_str, + return await create_and_return_session( + db, user_info, device_info, ip_address, user_agent_str ) - return { - **dict(user_info._mapping), - "session_token": session_token, - "refresh_token": refresh_token, - } @router.post("/register") diff --git a/tests/test_routes/v1/test_authentication.py b/tests/test_routes/v1/test_authentication.py index 79c5af0..955c50b 100644 --- a/tests/test_routes/v1/test_authentication.py +++ b/tests/test_routes/v1/test_authentication.py @@ -119,7 +119,7 @@ async def test_login_returns_session_tokens( """ # Patch user info to include user_id for token generation simulation patch_auth_dependencies["get_user_info"].return_value = AsyncMock( - _mapping={"username": "testuser", "user_id": 42} + _mapping={"username": "testuser"} ) response = client.post("/v1/auth/login", json=login_payload()) @@ -129,7 +129,6 @@ async def test_login_returns_session_tokens( assert "session_token" in data assert "refresh_token" in data assert data["username"] == "testuser" - assert data["user_id"] == 42 @pytest.mark.asyncio @@ -215,7 +214,7 @@ async def test_login_otp_success_returns_tokens( with patch(f"{AUTH_PATH}.verify_otp") as verify_otp_mock: verify_otp_mock.return_value = True patch_auth_dependencies["get_user_info"].return_value = AsyncMock( - _mapping={"username": "testuser", "user_id": 42} + _mapping={"username": "testuser"} ) # Simulate valid token in _2fa_sessions with patch.object( @@ -235,7 +234,6 @@ async def test_login_otp_success_returns_tokens( assert "session_token" in data assert "refresh_token" in data assert data["username"] == "testuser" - assert data["user_id"] == 42 @pytest.mark.asyncio From 6b16548781587a729cf4af303018e09d2b0bd257 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 20 Jun 2025 02:34:07 +0000 Subject: [PATCH 47/50] Refactor test_login_2fa_required_returns_2fa_token to simplify patching of dependencies --- tests/test_routes/v1/test_authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_routes/v1/test_authentication.py b/tests/test_routes/v1/test_authentication.py index 955c50b..93169d1 100644 --- a/tests/test_routes/v1/test_authentication.py +++ b/tests/test_routes/v1/test_authentication.py @@ -147,8 +147,8 @@ class Dummy2FASecret: # Simulate available 2FA methods with ( - patch(f"{AUTH_PATH}.text") as text_mock, - patch(f"{AUTH_PATH}.time") as time_mock, + patch(f"{AUTH_PATH}.text"), + patch(f"{AUTH_PATH}.time"), ): # Patch DB call for 2FA methods mock_methods = [ From 11987f10059d808ed5145de2c59afb3b16e7ed72 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 20 Jun 2025 19:46:34 +0000 Subject: [PATCH 48/50] Update version constraints in requirements.txt for better dependency management --- requirements.txt | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1d48c3f..d3c332e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ -argon2-cffi>=25.1.0 -asyncpg>=0.30.0 -cryptography>=45.0.3 -dotenv>=0.9.9 -fastapi>=0.115.12 -fastapi_mail>=1.5.0 -greenlet>=3.2.3 -httpx>=0.28.1 -pydantic[email]>=2.11.6 -pyotp>=2.9.0 -requests>=2.32.4 -sqlalchemy>=2.0.41 -user-agents>=2.2.0 -uvicorn>=0.34.2 +argon2-cffi>=25.1.0,<26 +asyncpg>=0.30.0,<1 +cryptography>=45.0.3,<46 +dotenv>=0.9.9,<1 +fastapi>=0.115.12,<1 +fastapi_mail>=1.5.0,<2 +greenlet>=3.2.3,<4 +httpx>=0.28.1,<1 +pydantic[email]>=2.11.6,<3 +pyotp>=2.9.0,<3 +requests>=2.32.4,<3 +sqlalchemy>=2.0.41,<3 +user-agents>=2.2.0,<3 +uvicorn>=0.34.2,<1 From 251c813489260eeab79894e2e4e4344ff15af97b Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 20 Jun 2025 19:46:38 +0000 Subject: [PATCH 49/50] Add unit tests for sanitize_username function to validate username sanitization logic --- tests/test_utility/test_sanitize_username.py | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 tests/test_utility/test_sanitize_username.py diff --git a/tests/test_utility/test_sanitize_username.py b/tests/test_utility/test_sanitize_username.py new file mode 100644 index 0000000..9306a74 --- /dev/null +++ b/tests/test_utility/test_sanitize_username.py @@ -0,0 +1,24 @@ +import pytest + +from app.utility.string_utils import sanitize_username + + +@pytest.mark.parametrize( + "input_username,expected", + [ + ("validUser_123", "validUser_123"), + ("user.name", "user_name"), + ("user-name", "user_name"), + ("user name", "user_name"), + ("user@domain.com", "user_domain_com"), + ("user!$%^&*()", "user________"), + ("", ""), + ("___", "___"), + ("user__name", "user__name"), + ("user\nname", "user_name"), + ("user\tname", "user_name"), + ("user/\\name", "user__name"), + ], +) +def test_sanitize_username(input_username, expected): + assert sanitize_username(input_username) == expected From f3035267e893e65427d6463cfc652817697dcb3e Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 20 Jun 2025 19:56:01 +0000 Subject: [PATCH 50/50] Update version constraints in requirements-dev.txt for build and testing dependencies --- requirements-dev.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index a7d5c9c..b236181 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -r requirements.txt -build>=1.2.0 -pytest>=8.3.5 -pytest-asyncio>=1.0.0 +build>=1.2.0,<2 +pytest>=8.3.5,<9 +pytest-asyncio>=1.0.0,<2