Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .coverage
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Add Basic User Model
"""Add User Model with basic fields

Revision ID: c7bf14d1de71
Revision ID: eae7f8b6a379
Revises:
Create Date: 2025-11-28 11:24:40.924617
Create Date: 2026-05-20 09:39:09.338525

"""
from typing import Sequence, Union
Expand All @@ -12,7 +12,7 @@
from alembic import op # type: ignore

# revision identifiers, used by Alembic.
revision: str = "c7bf14d1de71"
revision: str = "eae7f8b6a379"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
Expand All @@ -24,7 +24,8 @@ def upgrade() -> None:
op.create_table(
"users",
sa.Column("email", sa.String(), nullable=False),
sa.Column("password", sa.String(), nullable=False),
sa.Column("password_hash", sa.String(), nullable=False),
sa.Column("is_verified", sa.Boolean(), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column(
"date_created",
Expand Down
5 changes: 3 additions & 2 deletions app/models/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING

from sqlalchemy import String
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column

from app.models._base import AbstractBase
Expand All @@ -13,4 +13,5 @@ class User(AbstractBase):
__tablename__ = "users"

email: Mapped[str] = mapped_column(String, unique=True, index=True)
password: Mapped[str]
password_hash: Mapped[str]
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
2 changes: 1 addition & 1 deletion app/models/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ class Meta: # type: ignore
sqlalchemy_session_persistence = "commit"

email = factory.faker.Faker("email")
password = get_password_hash("password")
password_hash = get_password_hash("password")
9 changes: 9 additions & 0 deletions app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ async def activate_user(
return await auth_services.activate_user(payload, db, bg_task)


@router.post("/resend_activation")
async def resend_activation_code(
db: DBDep,
email: EmailBody,
bg_task: BackgroundTasks, # needed to send verification/welcome email
):
return await auth_services.resend_activation_code(email, bg_task, db)


@router.post("/initiate_password_reset")
async def initiate_password_reset(
db: DBDep,
Expand Down
13 changes: 12 additions & 1 deletion app/routers/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,25 @@ async def test_signup_fails(


async def test_activate_user(client: AsyncClient, user: UserDB):
redis_manager.cache_json_item(f"verification-code-{user.email}", {"code": "000000"})
redis_manager.cache_json_item(f"activation-code-{user.email}", {"code": "000000"})
response: Response = await client.post(
"/v1/auth/activation", json={"code": "000000", "email": user.email}
)

assert response.status_code == 200
assert response.json().get("detail") == "Email Activation Successful"


async def test_resend_activation_code(client: AsyncClient, user: UserDB):
redis_manager.cache_json_item(f"activation-code-{user.email}", {"code": "000000"})
response: Response = await client.post(
"/v1/auth/resend_activation", json={"email": user.email}
)

assert response.status_code == 200
assert response.json().get("detail") == "Activation Code Sent"


async def test_initiate_password_reset(client: AsyncClient, signup_data: dict):
data = {"email": signup_data["email"]}
response: Response = await client.post(
Expand Down
69 changes: 56 additions & 13 deletions app/services/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import UTC, datetime, timedelta
from random import randint
from random import choices
from typing import Literal, cast

import bcrypt
Expand Down Expand Up @@ -42,6 +42,11 @@ def get_password_hash(password: str):
).decode()


def generate_random_code(n: int = 4) -> str:
code = choices("0123456789", k=n)
return "".join(code)


async def get_user(email: EmailStr, session: AsyncSession) -> UserDB | None:
stmt = select(UserDB).where(func.lower(UserDB.email) == email.lower())
result = await session.execute(stmt)
Expand All @@ -59,7 +64,7 @@ async def create_user(
hashed_password = get_password_hash(user_data.password)
new_user = UserDB(
email=user_data.email,
password=hashed_password,
password_hash=hashed_password,
)

session.add(new_user)
Expand Down Expand Up @@ -110,7 +115,7 @@ async def resend_verification_code(
logger.warning(f"Email Verification Requested for invalid user {email}")
return {"detail": "Verification code resent"}

code = str(randint(1000, 9999))
code = generate_random_code(4)
redis_manager.cache_json_item(
key=f"verification-code-{email}", value={"code": code}
)
Expand All @@ -134,10 +139,8 @@ async def initiate_password_reset(
if not user:
return {"detail": "Password Reset Code Sent"}

code = str(randint(1000, 9999))
redis_manager.cache_json_item(
key=f"reset-code-{email}", value={"code": code, "email": email}, ttl=60 * 30
)
code = generate_random_code(6)
redis_manager.cache_json_item(f"reset-code-{email}", {"code": code}, ttl=60 * 30)

background_task.add_task(
send_mail,
Expand Down Expand Up @@ -165,7 +168,7 @@ async def reset_password(
stmt = (
update(UserDB)
.where(UserDB.email == reset_data.email)
.values(password=hashed_password)
.values(password_hash=hashed_password)
.execution_options(synchronize_session="fetch")
)

Expand All @@ -184,15 +187,19 @@ async def update_user(
new_password: str | None = data.pop("new_password", None)
if new_password:
user = await authenticate_user(
username=email, password=cast(str, old_password), session=session
username=email,
# NOTE: This can not fail since there's a validation on pydantic model to ensure old_password
# is passed when the new_passowrd field is passed too
password=cast(str, old_password),
session=session,
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect Old Password",
headers={"WWW-Authenticate": "Bearer"},
)
data.update({"password": get_password_hash(new_password)})
data.update({"password_hash": get_password_hash(new_password)})

stmt = (
update(UserDB)
Expand Down Expand Up @@ -238,27 +245,63 @@ async def authenticate_user(
user: UserDB | None = await get_user(username, session)
if not user:
return False
if not verify_password(password, user.password):
if not verify_password(password, user.password_hash):
return False
return user


async def signup_user(
data: auth_schema.UserSignUpData,
session: AsyncSession,
background_task: BackgroundTasks,
bg_task: BackgroundTasks,
):
user = await create_user(data, session)

# TODO: Send Activation Code Email here too
code = generate_random_code(6)
redis_manager.cache_json_item(
f"activation-code-{data.email}", {"code": code}, ttl=60 * 30
)

bg_task.add_task(
send_mail,
subject="Activation Code",
receipients=[user.email],
payload={"username": user.email.split("@")[0], "code": code},
template="auth/verification.html",
)
return user


async def resend_activation_code(
email: str, bg_task: BackgroundTasks, session: AsyncSession
):
user = await get_user(email, session)

if not user:
return {"detail": "Activation Code Sent"}

code = generate_random_code(6)
redis_manager.cache_json_item(
f"activation-code-{email}", {"code": code}, ttl=60 * 30
)

bg_task.add_task(
send_mail,
subject="Activation Code",
receipients=[user.email],
payload={"username": user.email.split("@")[0], "code": code},
template="auth/verification.html",
)
return {"detail": "Activation Code Sent"}


async def activate_user(
verification_data: auth_schema.UserVerificationModel,
session: AsyncSession,
bg_task: BackgroundTasks,
):
data = redis_manager.get_json_item(f"verification-code-{verification_data.email}")
data = redis_manager.get_json_item(f"activation-code-{verification_data.email}")

if not data or data.get("code") != verification_data.code:
raise HTTPException(status_code=400, detail="Invalid Reset Code")
Expand Down
59 changes: 58 additions & 1 deletion app/services/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ async def test_update_user(user: UserDB, session: AsyncSession):
session,
)
assert isinstance(updated_user, UserDB)
assert auth_services.verify_password("new_password", updated_user.password)
assert auth_services.verify_password("new_password", updated_user.password_hash)


async def test_update_user_fails(user: UserDB, session: AsyncSession):
Expand Down Expand Up @@ -229,6 +229,63 @@ async def test_signup_user(session: AsyncSession):
assert result.email == email


@pytest.mark.parametrize(
"code,response_message",
[
("111111", "Invalid Reset Code"),
("000000", "Email Activation Successful"),
],
)
async def test_activate_user(
user: UserDB, session: AsyncSession, code: str, response_message: str
):
redis_manager.cache_json_item(f"activation-code-{user.email}", {"code": "000000"})
if code != "000000":
with pytest.raises(HTTPException) as err:
result = await auth_services.activate_user(
auth_schemas.UserVerificationModel(
email=user.email,
code=code,
),
session,
BackgroundTasks(),
)
assert err.value.detail == response_message

await session.delete(user)
await session.commit()

with pytest.raises(HTTPException) as err:
result = await auth_services.activate_user(
auth_schemas.UserVerificationModel(
email=user.email,
code=code,
),
session,
BackgroundTasks(),
)
assert err.value.detail == response_message

else:
result = await auth_services.activate_user(
auth_schemas.UserVerificationModel(
email=user.email,
code=code,
),
session,
BackgroundTasks(),
)

assert result == {"detail": response_message}


async def test_resend_activation_code(user: UserDB, session: AsyncSession):
result = await auth_services.resend_activation_code(
user.email, BackgroundTasks(), session
)
assert result == {"detail": "Activation Code Sent"}


async def test_signin_user(user: UserDB, session: AsyncSession):
result = await auth_services.signin_user(
auth_schemas.UserSignInData(email=user.email, password="password"), session
Expand Down
Loading