From 9aab6678449e02689ee40146a9346d9c2f5d75e9 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Mon, 9 Jun 2025 14:19:44 +0000 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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