From 82679c6d1b4b4eb9e9768d7ca49324169b5a9875 Mon Sep 17 00:00:00 2001 From: Brian Obot Date: Wed, 20 May 2026 10:51:08 +0100 Subject: [PATCH] Refactor the Authentication Endpoints to include Verification on Sign up --- .coverage | Bin 53248 -> 53248 bytes ...6a379_add_user_model_with_basic_fields.py} | 11 +-- app/models/auth.py | 5 +- app/models/tests/factories.py | 2 +- app/routers/auth.py | 9 +++ app/routers/tests/test_auth.py | 13 +++- app/services/auth.py | 69 ++++++++++++++---- app/services/tests/test_auth.py | 59 ++++++++++++++- 8 files changed, 145 insertions(+), 23 deletions(-) rename alembic/versions/{c7bf14d1de71_add_basic_user_model.py => eae7f8b6a379_add_user_model_with_basic_fields.py} (83%) diff --git a/.coverage b/.coverage index 7f0c5cc5a117e45bb65f6b312bca54e4a4a091c5..9d463891a133fab5724a5e02a2bb9bf23c999222 100644 GIT binary patch delta 558 zcmZozz}&Eac|wxX)Dyh?VGKNM5e)osyi)wSJn1|ix##d$aE5Ycvg>f_u|;g0c!hOy zFWVtDt`-3%c5zWr#t!Dqk-V1~jTL}ES64wnJtMI=LtUXHwW0(h1P4Gl51@=fewso_ zMyf(eVoBm=E&j_2j3%4^>9YrT^6@b6|KNYm|CIka|7rfc{44qA@K53I>ho9~||FYNNHXP04NKBP7)U#af2;ZT?VFq3{GV@p=iA*ceVdwJcRu&gXO=zuoI*eaB4X=m z_Q_Cp1z_7y ZcspJm7LW{c)qLUe<^SJqe%deX005m0xj_H` delta 543 zcmZozz}&Eac|wv>)&*YvFa{pBUIzX+UVnaFo;kc~+!uLrIlH*$u_tmSv-NHk6yRjr z9Km*ojjK7DiCtV&l(AiMvkLEJ#?3nX7Zn(dHviLS5Afu4VBr73|DOLT-($Y(d?)#~ z@~z-oz&Dkzo3EL#k}sDpl{c9;nm2&gkXMseidTr2gO`E(ANMz|FI=y<9&ugbI>NPu zYZcc*P6wcUpEyLD;*?oAIa}@C?e);Nv^@OB=KE&`j)@ZunK_tvCQg)R?3g%l0!IS_ z10w^+0*8qcW0@VC5+=*`d75*`urP88v2NNX-&ilr%E;MVC;jek(K*ZAcjW5!{eDuk zGiKkXO8uX+;(wk=pLhP5`E>udJpK2q6DLNnGco`TWV4?*(VIyDXr`Jy&}cR(pmBUs zjE(h@V8eLdy}v%~`176B^V-Ybn}0hu|IIo7ckj|8?_YoaX5QV;^TVexFx=n%bK~yX zx{QevPcs9Jn0&8KDcs)pxlQ_WJ2nxJaa@eD!a&+a{N2w#Z{POT+U5WH@^f9be)Z?- zyyE%K-`8>q0i{L6*46BhVG{)D5Cu9w0PFzaU;7@_{QUIvY4y%MbM{W0n2+IXu$__p z`izW|JNsps&(*U{UfD0F@bULcSuR$fVPbr7pX2R#8|zuX1~OO8FFs%X|Lw$y??E~@ JKkb)x008rB$VLDF 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