From a9c4c16d4fa2a25126477b2d7c4149d8757614b3 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:09:51 -0300 Subject: [PATCH] Extending session login time --- .env.example | 3 +++ README.md | 3 ++- app/core/config.py | 6 +++++- app/core/security.py | 6 ++++-- app/services/auth.py | 15 ++++++++++++--- app/web/pages.py | 12 +++++++++--- 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index c161e92..48c4a9f 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ AUTO_CREATE_TABLES=true COOKIE_NAME=access_token # Set true when serving over HTTPS. COOKIE_SECURE=false +# How long a browser login lasts before being bounced back to /login. +# Independent of ACCESS_TOKEN_EXPIRE_MINUTES, which governs JSON API tokens. +SESSION_TIMEOUT_HOURS=24 # --- LDAP (optional; direct-bind) --- # When enabled, the login page and /api/auth/ldap-login accept a directory diff --git a/README.md b/README.md index c2b9f85..58afe2f 100644 --- a/README.md +++ b/README.md @@ -149,8 +149,9 @@ Every setting is read from an environment variable first, then `.env` |----------|---------|-------| | `SECRET_KEY` | dev placeholder | **set a strong value in production** | | `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres | -| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | JSON API bearer token lifetime | | `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | | +| `SESSION_TIMEOUT_HOURS` | `24` | browser login (HTMX UI cookie) lifetime | | `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod | | `COOKIE_SECURE` | `false` | set `true` behind HTTPS | | `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed | diff --git a/app/core/config.py b/app/core/config.py index 0d928a2..5f10285 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -33,9 +33,13 @@ class Settings(BaseSettings): database_url: str = "sqlite+aiosqlite:///./app.db" auto_create_tables: bool = True - # Web / cookies (the HTMX UI stores the access token in an httpOnly cookie) + # Web / cookies (the HTMX UI stores the access token in an httpOnly cookie). + # session_timeout_hours governs how long a browser login lasts before the + # user is bounced back to /login; it's independent of the JSON API's + # short-lived access_token_expire_minutes. cookie_name: str = "access_token" cookie_secure: bool = False + session_timeout_hours: int = 24 # LDAP (direct-bind). When enabled, the login page offers an LDAP option. # At login the user's DN is built from ``ldap_bind_dn_template`` (with diff --git a/app/core/security.py b/app/core/security.py index d4fb4e7..bb76eaf 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -47,10 +47,12 @@ def _create_token( return token, payload -def create_access_token(subject: str | int) -> str: +def create_access_token( + subject: str | int, expires_delta: timedelta | None = None +) -> str: token, _ = _create_token( subject, - timedelta(minutes=settings.access_token_expire_minutes), + expires_delta or timedelta(minutes=settings.access_token_expire_minutes), ACCESS_TOKEN_TYPE, ) return token diff --git a/app/services/auth.py b/app/services/auth.py index cb73f7e..79e5048 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,5 +1,7 @@ """Token issuance / rotation shared by the JSON API and the HTMX UI.""" +from datetime import timedelta + import jwt from sqlalchemy.ext.asyncio import AsyncSession @@ -13,9 +15,16 @@ from app.crud import token as token_crud from app.models.user import User -async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]: - """Create an access + refresh token and persist the refresh token for revocation.""" - access = create_access_token(user.id) +async def issue_token_pair( + db: AsyncSession, user: User, access_expires_delta: timedelta | None = None +) -> tuple[str, str]: + """Create an access + refresh token and persist the refresh token for revocation. + + ``access_expires_delta`` overrides the access token's default lifetime + (``settings.access_token_expire_minutes``) — used by the web login flow to + issue a longer-lived session cookie without affecting API bearer tokens. + """ + access = create_access_token(user.id, access_expires_delta) refresh, payload = create_refresh_token(user.id) await token_crud.store( db, diff --git a/app/web/pages.py b/app/web/pages.py index d9d04b2..694d27d 100644 --- a/app/web/pages.py +++ b/app/web/pages.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, status from fastapi.responses import HTMLResponse, RedirectResponse, Response from sqlalchemy.ext.asyncio import AsyncSession @@ -36,7 +38,7 @@ def _set_auth_cookie(response: Response, access_token: str) -> None: httponly=True, secure=settings.cookie_secure, samesite="lax", - max_age=settings.access_token_expire_minutes * 60, + max_age=settings.session_timeout_hours * 3600, path="/", ) @@ -79,7 +81,9 @@ async def login_submit( }, status_code=status.HTTP_401_UNAUTHORIZED, ) - access, _ = await issue_token_pair(db, user) + access, _ = await issue_token_pair( + db, user, timedelta(hours=settings.session_timeout_hours) + ) response = RedirectResponse( url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER ) @@ -110,7 +114,9 @@ async def login_ldap_submit( }, status_code=status.HTTP_401_UNAUTHORIZED, ) - access, _ = await issue_token_pair(db, user) + access, _ = await issue_token_pair( + db, user, timedelta(hours=settings.session_timeout_hours) + ) response = RedirectResponse( url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER )