diff --git a/.coverage b/.coverage index 7f0c5cc..9d46389 100644 Binary files a/.coverage and b/.coverage differ diff --git a/alembic/versions/c7bf14d1de71_add_basic_user_model.py b/alembic/versions/eae7f8b6a379_add_user_model_with_basic_fields.py similarity index 83% rename from alembic/versions/c7bf14d1de71_add_basic_user_model.py rename to alembic/versions/eae7f8b6a379_add_user_model_with_basic_fields.py index 4ea3683..9169a00 100644 --- a/alembic/versions/c7bf14d1de71_add_basic_user_model.py +++ b/alembic/versions/eae7f8b6a379_add_user_model_with_basic_fields.py @@ -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 @@ -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 @@ -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", diff --git a/app/models/auth.py b/app/models/auth.py index 88203bb..b37797f 100644 --- a/app/models/auth.py +++ b/app/models/auth.py @@ -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 @@ -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) diff --git a/app/models/tests/factories.py b/app/models/tests/factories.py index 4d0b518..ce3453c 100644 --- a/app/models/tests/factories.py +++ b/app/models/tests/factories.py @@ -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") diff --git a/app/routers/auth.py b/app/routers/auth.py index 98cdf07..42db779 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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, diff --git a/app/routers/tests/test_auth.py b/app/routers/tests/test_auth.py index e1b0dca..a569ea2 100644 --- a/app/routers/tests/test_auth.py +++ b/app/routers/tests/test_auth.py @@ -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( diff --git a/app/services/auth.py b/app/services/auth.py index 531662a..f9e86f9 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -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 @@ -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) @@ -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) @@ -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} ) @@ -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, @@ -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") ) @@ -184,7 +187,11 @@ 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( @@ -192,7 +199,7 @@ async def update_user( 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) @@ -238,7 +245,7 @@ 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 @@ -246,19 +253,55 @@ async def authenticate_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") diff --git a/app/services/tests/test_auth.py b/app/services/tests/test_auth.py index 206a4f2..4f752af 100644 --- a/app/services/tests/test_auth.py +++ b/app/services/tests/test_auth.py @@ -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): @@ -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