Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement user session management with jwt and database persistence #1212

Open
wants to merge 29 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
85a8083
feat: add UserSession model
Tha-Orakkle Mar 1, 2025
aa7af95
update: make /register and /login endpoint to create a user session o…
Tha-Orakkle Mar 1, 2025
9ba7e4a
feat: create endpoints for user session
Tha-Orakkle Mar 1, 2025
4cd81eb
feat: create session service for the UserSession model
Tha-Orakkle Mar 1, 2025
db7ed21
feat: add schema UserCreate for the UserSession
Tha-Orakkle Mar 1, 2025
bd2425c
feat: add session router to api_version_one
Tha-Orakkle Mar 1, 2025
4a43028
update: check if refresh token is revoked in verify_refresh_token method
Tha-Orakkle Mar 1, 2025
034744d
feat: helper functions to build data for the user session
Tha-Orakkle Mar 1, 2025
e130c43
feat: add UserSession model
Tha-Orakkle Mar 1, 2025
cb794dd
update: change expires_at field to Datetime
Tha-Orakkle Mar 1, 2025
3cc09fa
fix: remove typecast to str in get_session_schema_data when called
Tha-Orakkle Mar 1, 2025
96c4435
update: change expires_at in schema to datetime
Tha-Orakkle Mar 1, 2025
74eab92
fix: format datetime before comparison
Tha-Orakkle Mar 1, 2025
7dd152d
feat: add background task to clear sessions table in db to celery
Tha-Orakkle Mar 1, 2025
a806a04
chore: add depencies for user session management
Tha-Orakkle Mar 1, 2025
b21aa38
doc: add steps to run background tasks with celery
Tha-Orakkle Mar 1, 2025
96d83fa
fix: create user session asynchronously to prevent latency
Tha-Orakkle Mar 2, 2025
705e228
feat: delete user session when user logout
Tha-Orakkle Mar 3, 2025
d8f4f0f
update: revoke sessions when delete and delete_all methods are called
Tha-Orakkle Mar 3, 2025
2940aa4
update: make celery run task every day by midnight
Tha-Orakkle Mar 3, 2025
2c79c94
update: make session service receive db arg for all its methods
Tha-Orakkle Mar 3, 2025
a285ff8
update: add content to delete endpoints
Tha-Orakkle Mar 3, 2025
53177de
update: add session creation functionality for signin test
Tha-Orakkle Mar 3, 2025
c30be71
test: add test for user sessions get endpoints
Tha-Orakkle Mar 3, 2025
928c6bc
test: add test for user sessions delete endpoints
Tha-Orakkle Mar 3, 2025
fe91727
test: test that different refresh token is generated and first is rev…
Tha-Orakkle Mar 3, 2025
ef66ce2
test: add session creation to test_signin
Tha-Orakkle Mar 3, 2025
1d8e3f2
test: add user session creation to test_upload_profile_image
Tha-Orakkle Mar 3, 2025
ef916aa
fix: fix no fixture session found error
Tha-Orakkle Mar 3, 2025
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
240 changes: 154 additions & 86 deletions README.md

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions api/utils/celery_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from celery import Celery
from celery.schedules import crontab
from datetime import datetime, timezone

from api.utils.settings import settings

celery_app = Celery('api', broker='redis://localhost:6379/0', backend='redis://localhost:6379/0')

celery_app.conf.beat_schedule = {
"clean_db_every_day": {
"task": "api.utils.celery_config.clean_expired_and_revoked_tokens_from_sessions_table",
"schedule": crontab(hour=0, minute=0) # run everyday at midnight
}
}

celery_app.conf.timezone = "UTC"

# Additional configurations
celery_app.conf.update(
task_serializer='json',
result_serializer='json',
accept_content=['json'],
task_acks_late=True,
worker_prefetch_multiplier=1,
task_time_limit=300,
task_soft_time_limit=180,
)

@celery_app.task
def clean_expired_and_revoked_tokens_from_sessions_table():
"""Clean expired and revoked tokens from the sessions table."""
from api.db.database import get_db
from api.v1.models.session import UserSession
from sqlalchemy import cast, DateTime

db = next(get_db())
try:
current_time = datetime.now(timezone.utc)
db.query(UserSession).filter(UserSession.is_revoked == True).delete()
db.query(UserSession).filter(cast(UserSession.expires_at, DateTime) < current_time).delete()
db.commit()
except Exception as e:
db.rollback()
raise e
finally:
db.close()
73 changes: 73 additions & 0 deletions api/utils/session_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import httpx
from fastapi import Request, BackgroundTasks
from sqlalchemy.orm import Session

from api.db.database import get_db
from api.v1.schemas.session import SessionCreate
from api.v1.services.session import session_service
from api.utils.client_helpers import get_ip_address


async def get_ip_location(ip):
""" Get IP location.

Args:
ip (str): IP address
"""
country = region = "Unknown"

try:
async with httpx.AsyncClient() as client:
response = await client.get(f"https://ipinfo.io/{ip}/json/", timeout=10)
response.raise_for_status()
data = response.json()
region = data.get("region", "Unknown")
country = data.get("country", "Unknown")
except httpx.RequestError as exc:
print(f"An error occurred while requesting {exc.request.url!r}.")
except httpx.HTTPStatusError as exc:
print(f"Error response {exc.response.status_code} while requesting {exc.request.url!r}.")
except Exception as exc:
print(f"An unexpected error occurred: {exc}")

return f"{region}, {country}"

async def get_session_schema_data(request: Request, refresh_token: str = "", expires_at: str = ""):
"""Get session schema data.

Args:
request (Request): Request object
refresh_token (str): Refresh token
expires_at (str): Expiry date
"""
ip = get_ip_address(request)
return SessionCreate(
ip_address=ip,
location=await get_ip_location(ip),
device=request.headers.get("User-Agent"),
is_revoked=False,
refresh_token=refresh_token,
expires_at=expires_at
)

async def create_session_for_user(
db: Session,
request: Request,
user_id: str,
refresh_token: str = "",
expires_at: str = "",
):
"""Create session for user.

Args:
request (Request): Request object
db: Database session
refresh_token (str): Refresh token
expires_at (str): Expiry date
"""
session_data: SessionCreate = await get_session_schema_data(
request,
refresh_token=refresh_token,
expires_at=expires_at,
)
session_service.create(db, schema=session_data, user_id=user_id)
2 changes: 1 addition & 1 deletion api/v1/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@
from api.v1.models.wishlist import Wishlist
from api.v1.models.totp_device import TOTPDevice
from api.v1.models.bookmark import Bookmark

from api.v1.models.session import UserSession
23 changes: 23 additions & 0 deletions api/v1/models/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""The Session Model."""

from api.v1.models.base_model import BaseTableModel
from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean, text
from sqlalchemy.orm import relationship


class UserSession(BaseTableModel):
__tablename__ = "sessions"

user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
ip_address = Column(String, nullable=False)
location = Column(String, nullable=True)
device = Column(String, nullable=True)
is_revoked = Column(Boolean, server_default=text("false"))
refresh_token = Column(String, nullable=False)
expires_at = Column(DateTime, nullable=False)

user = relationship("User", back_populates="sessions")

def __str__(self):
return f"{self.user_id} - {self.ip_address}"
4 changes: 4 additions & 0 deletions api/v1/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class User(BaseTableModel):
profile = relationship(
"Profile", uselist=False, back_populates="user", cascade="all, delete-orphan"
)

organisations = relationship(
"Organisation", secondary=user_organisation_roles, back_populates="users"
)
Expand Down Expand Up @@ -117,6 +118,9 @@ class User(BaseTableModel):
"Bookmark", back_populates="user", cascade="delete"
)

sessions = relationship(
"UserSession", back_populates="user", cascade="all, delete-orphan"
)
def to_dict(self):
obj_dict = super().to_dict()
obj_dict.pop("password")
Expand Down
2 changes: 2 additions & 0 deletions api/v1/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from api.v1.routes.privacy import privacies
from api.v1.routes.settings import settings
from api.v1.routes.terms_and_conditions import terms_and_conditions
from api.v1.routes.session import session_router
from api.v1.routes.stripe import subscription_
from api.v1.routes.wishlist import wishlist

Expand Down Expand Up @@ -98,3 +99,4 @@
api_version_one.include_router(product_comment)
api_version_one.include_router(subscription_)
api_version_one.include_router(wishlist)
api_version_one.include_router(session_router)
40 changes: 34 additions & 6 deletions api/v1/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import datetime as dt
from datetime import timedelta
from fastapi.responses import JSONResponse
from jose import ExpiredSignatureError, JWTError
Expand All @@ -21,6 +22,8 @@
from api.core.dependencies.email_sender import send_email
from api.utils.success_response import auth_response, success_response
from api.utils.send_mail import send_magic_link
from api.utils.settings import settings
from api.utils.session_helpers import create_session_for_user
from api.v1.models import User
from api.v1.schemas.user import Token, UserEmailSender
from api.v1.schemas.user import (
Expand All @@ -31,7 +34,6 @@
UserData2,
)
from api.v1.schemas.token import TokenRequest

from api.v1.schemas.user import (MagicLinkRequest,
ChangePasswordSchema,
AuthMeResponse)
Expand All @@ -50,6 +52,7 @@
)
from api.v1.services.totp import totp_service
from api.utils.settings import settings
from api.v1.services.session import session_service

auth = APIRouter(prefix="/auth", tags=["Authentication"])

Expand All @@ -75,14 +78,9 @@ def register(
# Create user account
user = user_service.create(db=db, schema=user_schema)


verification_token = user_service.create_verification_token(user.id)
verification_link = f"{base_url}/api/v1/auth/verify-email?token={verification_token}"

access_token = user_service.create_access_token(user_id=user.id)
refresh_token = user_service.create_refresh_token(user_id=user.id)
cta_link = "https://anchor-python.teams.hng.tech/about-us"

# create an organization for the user
org = CreateUpdateOrganisation(
name=f"{user.email}'s Organisation", email=user.email
Expand All @@ -95,6 +93,18 @@ def register(
refresh_token = user_service.create_refresh_token(user_id=user.id)
cta_link = f"{settings.ANCHOR_PYTHON_BASE_URL}/about-us"

# create session for user
expires = dt.datetime.now(dt.timezone.utc) + (dt.timedelta(
days=settings.JWT_REFRESH_EXPIRY) - dt.timedelta(seconds=1)
)
background_tasks.add_task(
create_session_for_user,
db=db,
request=request,
user_id=user.id,
refresh_token=refresh_token,
expires_at=expires
)

# Send email in the background
background_tasks.add_task(
Expand Down Expand Up @@ -246,6 +256,19 @@ def login(request: Request, login_request: LoginRequest, background_tasks: Backg
access_token = user_service.create_access_token(user_id=user.id)
refresh_token = user_service.create_refresh_token(user_id=user.id)

# create session for user
expires = dt.datetime.now(dt.timezone.utc) + (dt.timedelta(
days=settings.JWT_REFRESH_EXPIRY) - dt.timedelta(seconds=1)
)
background_tasks.add_task(
create_session_for_user,
db=db,
request=request,
user_id=user.id,
refresh_token=refresh_token,
expires_at=expires
)

# Background task for email notification
logger.info(f"Queueing login notification for {user.email} in the background...")
background_tasks.add_task(send_login_notification, user, request)
Expand Down Expand Up @@ -285,6 +308,11 @@ def logout(
):
"""Endpoint to log a user out of their account"""

# logout/delete current user session
current_refresh_token = request.cookies.get("refresh_token")
session_service.logout_session(db, current_user.id, current_refresh_token)


response = success_response(status_code=200, message="User logged put successfully")

# Delete refresh token from cookies
Expand Down
89 changes: 89 additions & 0 deletions api/v1/routes/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from sqlalchemy.orm import Session

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse

from api.v1.models import User
from api.db.database import get_db
from api.utils.success_response import success_response
from api.v1.services.user import user_service
from api.v1.services.session import session_service


session_router = APIRouter(prefix="/sessions", tags=["sessions"])

@session_router.get("/", response_model=success_response)
def get_all_sessions(
db: Session = Depends(get_db),
current_user: User = Depends(user_service.get_current_user),
):
"""
Endpoint to get all sessions.

args:
- db: the database session
- current_user: current authenticated user
"""
sessions = session_service.fetch_all(db, current_user.id)
return success_response(
status_code=status.HTTP_200_OK,
message="Sessions retrieved successfully",
data=jsonable_encoder(sessions, exclude={"refresh_token"})
)

@session_router.get('/{session_id}', response_model=success_response)
def get_session(
session_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(user_service.get_current_user)
):
session = session_service.fetch(db, current_user.id, session_id)
return success_response(
status_code=status.HTTP_200_OK,
message="Session retrieved successfully",
data=jsonable_encoder(session, exclude={"refresh_token"})
)

@session_router.delete('/{session_id}', status_code=status.HTTP_204_NO_CONTENT)
def delete_session(
session_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(user_service.get_current_user)
):
"""
Endpoint to delete a session.

args:
- session_id (str): ID of the session
- db: the database session
- current_user: current authenticated user
"""
session_service.delete(db, current_user.id, session_id)
response_data = {
"status": "success",
"status_code": 204,
"message": "Session deleted successfully",
"data": {}
}
return JSONResponse(
status_code=status.HTTP_204_NO_CONTENT,
content=jsonable_encoder(response_data)
)

@session_router.delete('/', status_code=status.HTTP_204_NO_CONTENT)
def delete_all_sessions(
db: Session = Depends(get_db),
current_user: User = Depends(user_service.get_current_user)
):
session_service.delete_all(db, current_user.id)
response_data = {
"status": "success",
"status_code": 204,
"message": "Sessions deleted successfully",
"data": {}
}
return JSONResponse(
status_code=status.HTTP_204_NO_CONTENT,
content=jsonable_encoder(response_data)
)
10 changes: 10 additions & 0 deletions api/v1/schemas/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from datetime import datetime
from pydantic import BaseModel, Field

class SessionCreate(BaseModel):
ip_address: str
location: str = None
device: str = None
is_revoked: bool = False
refresh_token: str
expires_at: datetime
Loading
Loading