diff --git a/src/backend/app/auth/auth_deps.py b/src/backend/app/auth/auth_deps.py index 4bea8abde..8ee41db60 100644 --- a/src/backend/app/auth/auth_deps.py +++ b/src/backend/app/auth/auth_deps.py @@ -19,16 +19,19 @@ """Auth dependencies, for restricted routes and cookie handling.""" from time import time -from typing import Optional +from typing import Annotated, Optional import jwt -from fastapi import Header, HTTPException, Request, Response +from fastapi import Depends, Header, HTTPException, Request, Response from fastapi.responses import JSONResponse from loguru import logger as log +from psycopg import Connection from app.auth.auth_schemas import AuthUser from app.config import settings +from app.db.database import db_conn from app.db.enums import HTTPStatus, UserRole +from app.db.models import DbUser ### Cookie / Token Handling @@ -262,7 +265,9 @@ async def refresh_cookies( async def login_required( - request: Request, access_token: str = Header(None) + db: Annotated[Connection, Depends(db_conn)], + request: Request, + access_token: str = Header(None), ) -> AuthUser: """Dependency for endpoints requiring login.""" if settings.DEBUG: @@ -273,11 +278,13 @@ async def login_required( request, settings.cookie_name, # FMTM cookie ) - return await _authenticate_user(extracted_token) + return await _authenticate_user(db, extracted_token) async def mapper_login_required( - request: Request, access_token: str = Header(None) + db: Annotated[Connection, Depends(db_conn)], + request: Request, + access_token: str = Header(None), ) -> AuthUser: """Dependency for mapper frontend login.""" if settings.DEBUG: @@ -292,8 +299,7 @@ async def mapper_login_required( # Verify login and continue if extracted_token: - print("mapper") - return await _authenticate_user(extracted_token) + return await _authenticate_user(db, extracted_token) # Else user has no token, so we provide login data automatically username = "svcfmtm" @@ -305,7 +311,7 @@ async def mapper_login_required( return AuthUser(**temp_user) -async def _authenticate_user(access_token: Optional[str]) -> AuthUser: +async def _authenticate_user(db: Connection, access_token: Optional[str]) -> AuthUser: """Authenticate user by verifying the access token.""" if not access_token: raise HTTPException( @@ -322,4 +328,5 @@ async def _authenticate_user(access_token: Optional[str]) -> AuthUser: detail="Access token not valid", ) from e + await DbUser.update_last_active(db=db, user_id=int(token_data["sub"].split("|")[1])) return AuthUser(**token_data) diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py index 1d7ee7176..faae34470 100644 --- a/src/backend/app/db/models.py +++ b/src/backend/app/db/models.py @@ -22,7 +22,7 @@ """ import json -from datetime import timedelta +from datetime import datetime, timedelta, timezone from io import BytesIO from re import sub from typing import TYPE_CHECKING, Annotated, Optional, Self @@ -160,6 +160,7 @@ class DbUser(BaseModel): tasks_invalidated: Optional[int] = None projects_mapped: Optional[list[int]] = None registered_at: Optional[AwareDatetime] = None + last_active_at: Optional[AwareDatetime] = None # Relationships project_roles: Optional[dict[int, ProjectRole]] = None # project:role pairs @@ -312,6 +313,20 @@ async def create( return new_user + @classmethod + async def update_last_active(cls, db: Connection, user_id: int) -> None: + """Update the last active timestamp.""" + async with db.cursor(row_factory=class_row(cls)) as cur: + await cur.execute( + """ + UPDATE users + SET last_active_at = %(now)s + WHERE id = %(user_id)s; + """, + {"now": datetime.now(timezone.utc), "user_id": user_id}, + ) + log.info(f"User ({user_id}) last active timestamp updated.") + class DbOrganisation(BaseModel): """Table organisations."""