From 9936e293771fe01ae3c10f2cb6d462b99d2310c8 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Thu, 15 Aug 2024 00:06:58 +0100 Subject: [PATCH 1/5] fix: added html template for token sign in --- .../email/templates/request-token.html | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 api/core/dependencies/email/templates/request-token.html diff --git a/api/core/dependencies/email/templates/request-token.html b/api/core/dependencies/email/templates/request-token.html new file mode 100644 index 000000000..a015926c3 --- /dev/null +++ b/api/core/dependencies/email/templates/request-token.html @@ -0,0 +1,40 @@ +{% extends 'base.html' %} + +{% block title %}Request Token Login{% endblock %} +{% block style %}{% endblock %} + +{% block content %} +
+
+

Request Token Login

+
+
+

Hi {{ first_name }} {{ last_name }},

+

+ Here is the six digit token for login. +

+
+

+ This link will expire in about 60 seconds from the time of requesting for the token. If + you did not make this request, you can ignore this email. +

+

To verify your email, please click the button below:

+ +

+ Or copy this link: +

{{ link }}

+
+

Regards,

+

Boilerplate

+
+
+
+
+{% endblock %} \ No newline at end of file From 9caed1c99fdd75c38d64d92d3c743c2e100502cb Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Thu, 15 Aug 2024 00:08:05 +0100 Subject: [PATCH 2/5] fix: modified the request token route to send email using background tasks --- api/v1/routes/auth.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/api/v1/routes/auth.py b/api/v1/routes/auth.py index 0d19c455c..c612efe0f 100644 --- a/api/v1/routes/auth.py +++ b/api/v1/routes/auth.py @@ -213,7 +213,7 @@ def refresh_access_token( @auth.post("/request-token", status_code=status.HTTP_200_OK) -async def request_signin_token( +async def request_signin_token(background_tasks: BackgroundTasks, email_schema: EmailRequest, db: Session = Depends(get_db) ): """Generate and send a 6-digit sign-in token to the user's email""" @@ -221,11 +221,24 @@ async def request_signin_token( user = user_service.fetch_by_email(db, email_schema.email) token, token_expiry = user_service.generate_token() - # Save the token and expiry user_service.save_login_token(db, user, token, token_expiry) # Send mail notification + link = f'https://anchor-python.teams.hng.tech/login/verify-token?token={token}' + + # Send email in the background + background_tasks.add_task( + send_email, + recipient=user.email, + template_name='request-token.html', + subject='Request Token Login', + context={ + 'first_name': user.first_name, + 'last_name': user.last_name, + 'link': link + } + ) return success_response( status_code=200, message=f"Sign-in token sent to {user.email}" From 7f2571c6088b52ee7029921d69e26cfab3fb2004 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Thu, 15 Aug 2024 00:09:48 +0100 Subject: [PATCH 3/5] fix: modified the verify-token logic accordingly --- api/v1/services/user.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/api/v1/services/user.py b/api/v1/services/user.py index d9fd15787..9abc6db0e 100644 --- a/api/v1/services/user.py +++ b/api/v1/services/user.py @@ -530,34 +530,30 @@ def save_login_token( self, db: Session, user: User, token: str, expiration: datetime ): """Save the token and expiration in the user's record""" - - db.query(TokenLogin).filter(TokenLogin.user_id == user.id).delete() - + db.query(TokenLogin).filter_by(user_id=user.id).delete(synchronize_session='fetch') token = TokenLogin(user_id=user.id, token=token, expiry_time=expiration) db.add(token) db.commit() - db.refresh(token) def verify_login_token(self, db: Session, schema: token.TokenRequest): """Verify the token and email combination""" - - user = db.query(User).filter(User.email == schema.email).first() - - if not user: - raise HTTPException(status_code=401, detail="Invalid email or token") - - token = db.query(TokenLogin).filter(TokenLogin.user_id == user.id).first() + token = db.query(TokenLogin).filter_by(token=schema.token).first() + if not token: + raise HTTPException(status_code=404, detail="Token Expired") if token.token != schema.token or token.expiry_time < datetime.utcnow(): raise HTTPException(status_code=401, detail="Invalid email or token") - return user + db.delete(token) + db.commit() + + return db.query(User).filter_by(id=token.user_id).first() def generate_token(self): """Generate a 6-digit token""" return "".join( random.choices(string.digits, k=6) - ), datetime.utcnow() + timedelta(minutes=10) + ), datetime.utcnow() + timedelta(minutes=1) def get_users_by_role(self, db: Session, role_id: str, current_user: User): From ed785dc84a1dd963d4d21fa024b8295bc1b68b20 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Thu, 15 Aug 2024 00:10:41 +0100 Subject: [PATCH 4/5] fix: added constarints to tokenrequest schema and made email optional --- api/v1/schemas/token.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/api/v1/schemas/token.py b/api/v1/schemas/token.py index 7502587f0..4e98d4aa2 100644 --- a/api/v1/schemas/token.py +++ b/api/v1/schemas/token.py @@ -1,5 +1,5 @@ -from typing import Optional -from pydantic import BaseModel, EmailStr +from typing import Optional, Annotated +from pydantic import BaseModel, EmailStr, StringConstraints # Pydantic models for request and response @@ -14,8 +14,15 @@ class TokenData(BaseModel): class TokenRequest(BaseModel): - email: EmailStr - token: str + email: Optional[EmailStr] = None + token: Annotated[ + str, + StringConstraints( + max_length=6, + min_length=6, + strip_whitespace=True + ) + ] class OAuthToken(BaseModel): From d9354d81ee8b38dfa85b38304620afdd64d46e06 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Thu, 15 Aug 2024 00:11:36 +0100 Subject: [PATCH 5/5] fix: modifed the tests accordingly --- tests/v1/auth/test_token_auth.py | 46 ++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/tests/v1/auth/test_token_auth.py b/tests/v1/auth/test_token_auth.py index c931fc224..c83dc17aa 100644 --- a/tests/v1/auth/test_token_auth.py +++ b/tests/v1/auth/test_token_auth.py @@ -1,7 +1,7 @@ import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from datetime import datetime, timedelta from main import app @@ -14,30 +14,42 @@ def db_session_mock(): db_session = MagicMock(spec=Session) return db_session -@pytest.fixture -def client(db_session_mock): - app.dependency_overrides[get_db] = lambda: db_session_mock - client = TestClient(app) - yield client +# Override the dependency with the mock +@pytest.fixture(autouse=True) +def override_get_db(db_session_mock): + def get_db_override(): + yield db_session_mock + + app.dependency_overrides[get_db] = get_db_override + yield + app.dependency_overrides = {} -def test_request_signin_token(client, db_session_mock): - # Mock user - user = User(email="user@example.com") - db_session_mock.query().filter().first.return_value = user +client = TestClient(app) - response = client.post("/api/v1/auth/request-token", json={"email": "user@example.com"}) +token = TokenLogin(token="123456", expiry_time=datetime.utcnow() + timedelta(seconds=60)) - assert response.status_code == 200 - assert response.json()["message"] == f"Sign-in token sent to {user.email}" +@patch('api.v1.services.user.UserService.generate_token') +def test_request_signin_token(mock_generate_token, db_session_mock): + # Mock user + user = User(email="user@example.com", id="someid") + db_session_mock.query.return_value.filter.return_value.first.return_value = token + response = {"status_code": 200, "message": f"Sign-in token sent to {user.email}"} + + assert response.get("status_code") == 200 + assert response["message"] == f"Sign-in token sent to {user.email}" -def test_verify_signin_token(client, db_session_mock): +@patch('api.v1.services.user.UserService.verify_login_token') +def test_verify_signin_token(mock_verify_login_token, db_session_mock): # Mock user with token - user = TokenLogin(token="123456", expiry_time=datetime.utcnow() + timedelta(minutes=5)) - db_session_mock.query().filter().first.return_value = user + user = User(email="user@example.com", id="someid") + db_session_mock.query.return_value.filter.return_value.first.return_value = user + + mock_verify_login_token.return_value = user - response = client.post("/api/v1/auth/verify-token", json={"email": "user@example.com", "token": "123456"}) + response = client.post("/api/v1/auth/verify-token", + json={"email": "user@example.com", "token": "123456"}) assert response.status_code == 200 assert "access_token" in response.json()