feat(auth): idle timeout and maximum session age, session-only cookies
Sessions used to last as long as a 7-day refresh token kept being renewed, and the auth cookies outlived a browser restart. Settings -> Security now sets an idle timeout (default 15 min, 5 min to 3 h) and a maximum session length (default 10 h, 1 to 12 h); both are clamped when read. The refresh token lives exactly the idle timeout and is renewed on activity. The access token lives half of it, at most 30 min. Every token carries sat (sign-in time), copied on refresh: nothing outlives sat + max, and /auth/refresh refuses past it. That holds for password, MFA, passkey, SAML and OIDC sign-ins alike. Refresh tokens from before sat are refused, so everyone signs in once after the upgrade. Auth cookies are session cookies and go when the browser closes. In the SPA, SessionGuard renews on load and on keyboard/mouse activity and takes the page to /login when the idle timeout or maximum age passes, so an abandoned screen does not stay readable. Activity is shared across tabs.
This commit is contained in:
@@ -420,6 +420,36 @@ and `/auth/passkeys/*` in one file. Specific routes (e.g. `/auth/login`, `/auth/
|
||||
`/auth/users/{id}/reset-password`, `/auth/users/{id}/reset-mfa`) keep
|
||||
priority — Next.js prefers more specific segments over catch-alls.
|
||||
|
||||
## Session lifetime
|
||||
|
||||
`app/auth/session_policy.py`, Settings → Security:
|
||||
|
||||
| Setting | Default | Range | Effect |
|
||||
|---|---|---|---|
|
||||
| `session_idle_timeout_minutes` | 15 | 5–180 | No activity for this long → signed out |
|
||||
| `session_max_hours` | 10 | 1–12 | Hard end after sign-in, however active |
|
||||
|
||||
Values are clamped when read, so a hand-edited row cannot exceed the range.
|
||||
|
||||
- **Idle**: the refresh token lives exactly the idle timeout; each refresh
|
||||
issues a new one. The access token lives half of it (max 30 min). The SPA
|
||||
(`components/auth/SessionGuard.tsx`) renews on load and on keyboard/mouse
|
||||
activity (at most once a minute) and takes the page to `/login?reason=idle` when the
|
||||
timeout passes, so an abandoned screen does not stay readable. Activity is
|
||||
shared across tabs (localStorage), so a background tab does not sign out a
|
||||
user working in another.
|
||||
- **Maximum age**: tokens carry `sat` (sign-in time), copied on refresh. No
|
||||
token outlives `sat + max`, `/auth/refresh` refuses past it, the SPA
|
||||
redirects to `/login?reason=expired`. Refresh tokens from before `sat`
|
||||
are refused, so everyone signs in once after the upgrade.
|
||||
- **Cookies** are session cookies (no `Max-Age`/`Expires`): closing the
|
||||
browser drops them. Closing one tab does not — cookies belong to the
|
||||
browser, not the tab; that case is covered by the idle timeout. A browser
|
||||
that restores session cookies ("continue where you left off") gets nothing
|
||||
past the token `exp`.
|
||||
- Job-status polls (scan running) renew the server session through the 401
|
||||
path; the on-screen idle logout still applies.
|
||||
|
||||
## Passkeys (passwordless sign-in)
|
||||
|
||||
**Sign in with a passkey** on the login page replaces password *and* TOTP:
|
||||
|
||||
@@ -135,19 +135,21 @@ def create_access_token(
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: Dict[str, Any]) -> str:
|
||||
def create_refresh_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Erstellt JWT Refresh Token (längere Gültigkeit)
|
||||
|
||||
Args:
|
||||
data: Payload-Daten
|
||||
expires_delta: Custom Expiry-Zeit (default: 7 Tage). Sessions setzen
|
||||
hier den Idle-Timeout (app/auth/session_policy.py).
|
||||
|
||||
Returns:
|
||||
JWT Refresh Token
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
expire = now + (expires_delta or timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS))
|
||||
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Session lifetime policy: idle timeout and absolute maximum session age.
|
||||
|
||||
Settings (KV in `settings` table, Settings → Security in the UI):
|
||||
- `session_idle_timeout_minutes` default 15, clamped 5–180
|
||||
- `session_max_hours` default 10, clamped 1–12
|
||||
|
||||
Idle: the refresh token lives exactly the idle timeout, and every refresh
|
||||
(the SPA renews on user activity) issues a new one. A session nobody uses
|
||||
for that long can no longer be renewed. The access token lives half of it,
|
||||
at most 30 min, so it cannot outlast an idle session by much.
|
||||
|
||||
Absolute: every token carries `sat` (session auth time, epoch seconds),
|
||||
copied unchanged on refresh. No token outlives sat + max age, and refresh
|
||||
refuses past it — the user signs in again, however active they are.
|
||||
|
||||
Values are clamped when READ, so a hand-edited setting can never stretch a
|
||||
session past the limits. Changes apply to running sessions at their next
|
||||
refresh.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_handler import (
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
)
|
||||
from app.models.setting import Setting
|
||||
from app.models.user import User
|
||||
|
||||
KEY_IDLE_MINUTES = "session_idle_timeout_minutes"
|
||||
KEY_MAX_HOURS = "session_max_hours"
|
||||
|
||||
IDLE_DEFAULT, IDLE_MIN, IDLE_MAX = 15, 5, 180
|
||||
MAX_AGE_DEFAULT, MAX_AGE_MIN, MAX_AGE_MAX = 10, 1, 12
|
||||
|
||||
|
||||
class SessionExpired(Exception):
|
||||
"""The session reached its absolute maximum age."""
|
||||
|
||||
|
||||
def _int_setting(db: Session, key: str, default: int, lo: int, hi: int) -> int:
|
||||
row = db.query(Setting).filter(Setting.key == key).first()
|
||||
try:
|
||||
value = int((row.value or "").strip()) if row else default
|
||||
except ValueError:
|
||||
value = default
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
def idle_timeout_minutes(db: Session) -> int:
|
||||
return _int_setting(db, KEY_IDLE_MINUTES, IDLE_DEFAULT, IDLE_MIN, IDLE_MAX)
|
||||
|
||||
|
||||
def max_session_hours(db: Session) -> int:
|
||||
return _int_setting(db, KEY_MAX_HOURS, MAX_AGE_DEFAULT, MAX_AGE_MIN, MAX_AGE_MAX)
|
||||
|
||||
|
||||
def session_end(db: Session, started_at: float) -> int:
|
||||
"""Epoch second at which the session started at `started_at` ends for good."""
|
||||
return int(started_at) + max_session_hours(db) * 3600
|
||||
|
||||
|
||||
def issue_tokens(db: Session, user: User, started_at: Optional[float] = None) -> tuple[str, str]:
|
||||
"""(access, refresh) for a new session, or a renewal of the session that
|
||||
started at `started_at`. Raises SessionExpired past the maximum age."""
|
||||
now = time.time()
|
||||
sat = int(started_at) if started_at else int(now)
|
||||
remaining = session_end(db, sat) - now
|
||||
if remaining <= 0:
|
||||
raise SessionExpired()
|
||||
idle = idle_timeout_minutes(db) * 60
|
||||
data = {"sub": user.username, "role": user.role.value, "sat": sat}
|
||||
access = create_access_token(data, expires_delta=timedelta(
|
||||
seconds=min(JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, idle // 2, remaining)))
|
||||
refresh = create_refresh_token(data, expires_delta=timedelta(seconds=min(idle, remaining)))
|
||||
return access, refresh
|
||||
+47
-37
@@ -21,11 +21,8 @@ from app.auth.jwt_handler import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_password_strength,
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS,
|
||||
TOKEN_TYPE_MFA_CHALLENGE,
|
||||
TOKEN_TYPE_MFA_SETUP,
|
||||
)
|
||||
@@ -34,7 +31,7 @@ from app.services.audit_trail import record_change
|
||||
from app.auth.orchestrator import AuthOrchestrator
|
||||
from app.auth.strategies.base import AuthError
|
||||
from app.auth import totp as totp_helper
|
||||
from app.auth import mfa_policy
|
||||
from app.auth import mfa_policy, session_policy
|
||||
from app.auth.passkey import enabled as passkeys_enabled, suggested as passkey_suggested
|
||||
from app.auth.token_revocation import revoke as revoke_token
|
||||
from app.models.user import AuthProvider
|
||||
@@ -206,24 +203,27 @@ def check_account_lockout(user: User, db: Session) -> bool:
|
||||
|
||||
|
||||
def _set_auth_cookies(response: JSONResponse, access_token: str, refresh_token: str) -> None:
|
||||
response.set_cookie(
|
||||
"access_token",
|
||||
access_token,
|
||||
httponly=True,
|
||||
secure=COOKIE_SECURE,
|
||||
samesite=COOKIE_SAMESITE,
|
||||
max_age=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
"refresh_token",
|
||||
refresh_token,
|
||||
httponly=True,
|
||||
secure=COOKIE_SECURE,
|
||||
samesite=COOKIE_SAMESITE,
|
||||
max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
|
||||
path="/",
|
||||
)
|
||||
# Session cookies (no max_age/expires): the browser drops them when it
|
||||
# closes. Lifetime is enforced server-side by the token `exp`
|
||||
# (app/auth/session_policy.py), so a browser that restores its session
|
||||
# cookies on restart still gets nothing past the idle timeout.
|
||||
for name, value in (("access_token", access_token), ("refresh_token", refresh_token)):
|
||||
response.set_cookie(
|
||||
name,
|
||||
value,
|
||||
httponly=True,
|
||||
secure=COOKIE_SECURE,
|
||||
samesite=COOKIE_SAMESITE,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _presented_access_token(request: Request) -> Optional[str]:
|
||||
"""Access token from the Authorization header, else the cookie."""
|
||||
bearer = request.headers.get("authorization")
|
||||
if bearer and bearer.lower().startswith("bearer "):
|
||||
return bearer.split(" ", 1)[1].strip() or None
|
||||
return request.cookies.get("access_token")
|
||||
|
||||
|
||||
# ============================================
|
||||
@@ -232,9 +232,7 @@ def _set_auth_cookies(response: JSONResponse, access_token: str, refresh_token:
|
||||
|
||||
def _issue_session_response(user: User, db: Session) -> JSONResponse:
|
||||
"""Generate JWTs and JSON response payload for a successfully authenticated user."""
|
||||
token_data = {"sub": user.username, "role": user.role.value}
|
||||
access_token = create_access_token(token_data)
|
||||
refresh_token = create_refresh_token(token_data)
|
||||
access_token, refresh_token = session_policy.issue_tokens(db, user)
|
||||
response = JSONResponse(content={
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
@@ -666,12 +664,7 @@ def logout(
|
||||
tokens are rejected by the get_current_user / refresh dependencies.
|
||||
"""
|
||||
# Revoke access token JTI
|
||||
bearer = request.headers.get("authorization")
|
||||
access_token = None
|
||||
if bearer and bearer.lower().startswith("bearer "):
|
||||
access_token = bearer.split(" ", 1)[1].strip()
|
||||
if not access_token:
|
||||
access_token = request.cookies.get("access_token")
|
||||
access_token = _presented_access_token(request)
|
||||
if access_token:
|
||||
payload = decode_token(access_token)
|
||||
if payload and payload.get("jti"):
|
||||
@@ -753,10 +746,20 @@ def refresh_token(
|
||||
detail="User not found or deactivated"
|
||||
)
|
||||
|
||||
# Generate new access token
|
||||
token_data = {"sub": user.username, "role": user.role.value}
|
||||
access_token = create_access_token(token_data)
|
||||
new_refresh_token = create_refresh_token(token_data)
|
||||
# Same session, new idle window; `sat` is carried so the absolute
|
||||
# maximum age keeps counting from the original sign-in. A refresh token
|
||||
# from before `sat` existed cannot say when its session began (rotation
|
||||
# reset its iat), so it ends here: one re-login after the upgrade.
|
||||
try:
|
||||
if not payload.get("sat"):
|
||||
raise session_policy.SessionExpired()
|
||||
access_token, new_refresh_token = session_policy.issue_tokens(
|
||||
db, user, started_at=payload["sat"])
|
||||
except session_policy.SessionExpired:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session expired — please sign in again",
|
||||
)
|
||||
|
||||
response = JSONResponse(content={
|
||||
"access_token": access_token,
|
||||
@@ -842,10 +845,13 @@ def setup_admin(
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_current_user_info(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
def get_current_user_info(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns information about current user
|
||||
Returns information about current user, plus the session limits the SPA
|
||||
enforces on screen (idle logout, hard end at `expires_at`, epoch seconds).
|
||||
"""
|
||||
claims = decode_token(_presented_access_token(request) or "") or {}
|
||||
started_at = claims.get("sat") or claims.get("iat") or 0
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
@@ -854,6 +860,10 @@ def get_current_user_info(current_user: User = Depends(get_current_user), db: Se
|
||||
"auth_provider": current_user.auth_provider.value if current_user.auth_provider else "local",
|
||||
"mfa_enabled": bool(current_user.totp_enabled),
|
||||
"passkey_suggested": passkey_suggested(db, current_user),
|
||||
"session": {
|
||||
"idle_timeout_minutes": session_policy.idle_timeout_minutes(db),
|
||||
"expires_at": session_policy.session_end(db, started_at),
|
||||
},
|
||||
"is_active": current_user.is_active,
|
||||
"is_verified": current_user.is_verified,
|
||||
"created_at": current_user.created_at,
|
||||
|
||||
@@ -28,12 +28,10 @@ from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_handler import (
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
TOKEN_TYPE_MFA_SETUP,
|
||||
)
|
||||
from app.auth import session_policy
|
||||
from app.auth.orchestrator import AuthOrchestrator
|
||||
from app.auth.strategies.base import AuthError
|
||||
from app.auth.strategies.oidc_strategy import OidcStrategy
|
||||
@@ -321,19 +319,17 @@ async def oidc_callback(
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
token_payload = {"sub": user.username, "role": user.role.value}
|
||||
access_jwt = create_access_token(token_payload)
|
||||
refresh_jwt = create_refresh_token(token_payload)
|
||||
access_jwt, refresh_jwt = session_policy.issue_tokens(db, user)
|
||||
|
||||
redirect_to = os.getenv("DASHBOARD_URL", "/").rstrip("/") + "/"
|
||||
response = RedirectResponse(redirect_to, status_code=302)
|
||||
response.set_cookie(
|
||||
"access_token", access_jwt, httponly=True, secure=COOKIE_SECURE,
|
||||
samesite=COOKIE_SAMESITE, max_age=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, path="/",
|
||||
samesite=COOKIE_SAMESITE, path="/", # session cookie, see auth._set_auth_cookies
|
||||
)
|
||||
response.set_cookie(
|
||||
"refresh_token", refresh_jwt, httponly=True, secure=COOKIE_SECURE,
|
||||
samesite=COOKIE_SAMESITE, max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400, path="/",
|
||||
samesite=COOKIE_SAMESITE, path="/", # session cookie, see auth._set_auth_cookies
|
||||
)
|
||||
# Drop the OIDC state cookie now that we're done
|
||||
response.delete_cookie(OIDC_STATE_COOKIE, path="/")
|
||||
|
||||
@@ -23,12 +23,10 @@ from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_handler import (
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
TOKEN_TYPE_MFA_SETUP,
|
||||
)
|
||||
from app.auth import session_policy
|
||||
from app.auth.orchestrator import AuthOrchestrator
|
||||
from app.auth.strategies.base import AuthError
|
||||
from app.auth.strategies.saml_strategy import SamlStrategy
|
||||
@@ -208,9 +206,7 @@ async def saml_acs(request: Request, db: Session = Depends(get_db)):
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
token_payload = {"sub": user.username, "role": user.role.value}
|
||||
access_jwt = create_access_token(token_payload)
|
||||
refresh_jwt = create_refresh_token(token_payload)
|
||||
access_jwt, refresh_jwt = session_policy.issue_tokens(db, user)
|
||||
|
||||
relay_state = form_dict.get("RelayState") or "/"
|
||||
# Defend against open redirect: only allow same-origin paths.
|
||||
@@ -220,11 +216,11 @@ async def saml_acs(request: Request, db: Session = Depends(get_db)):
|
||||
response = RedirectResponse(relay_state, status_code=302)
|
||||
response.set_cookie(
|
||||
"access_token", access_jwt, httponly=True, secure=COOKIE_SECURE,
|
||||
samesite=COOKIE_SAMESITE, max_age=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, path="/",
|
||||
samesite=COOKIE_SAMESITE, path="/", # session cookie, see auth._set_auth_cookies
|
||||
)
|
||||
response.set_cookie(
|
||||
"refresh_token", refresh_jwt, httponly=True, secure=COOKIE_SECURE,
|
||||
samesite=COOKIE_SAMESITE, max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400, path="/",
|
||||
samesite=COOKIE_SAMESITE, path="/", # session cookie, see auth._set_auth_cookies
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export default function LoginPage() {
|
||||
|
||||
// Fetch enabled providers so we render the right buttons.
|
||||
useEffect(() => {
|
||||
// Sent here by SessionGuard when the session ended on its own.
|
||||
const reason = new URLSearchParams(window.location.search).get('reason');
|
||||
if (reason === 'idle') setError('Signed out after inactivity.');
|
||||
else if (reason === 'expired') setError('Your session reached its maximum length. Please sign in again.');
|
||||
api.get('/auth/providers')
|
||||
.then(res => {
|
||||
setProviders(res.data?.providers || []);
|
||||
|
||||
@@ -1814,8 +1814,10 @@ export default function SettingsPage() {
|
||||
const [mfaEnforcedRoles, setMfaEnforcedRoles] = useState<string[]>(['admin', 'editor']);
|
||||
const [mfaEnforceSso, setMfaEnforceSso] = useState(false);
|
||||
const [mfaSavingStatus, setMfaSavingStatus] = useState({ message: '', type: '' });
|
||||
// Default mirrors app/auth/mfa_policy.py.
|
||||
// Defaults mirror app/auth/mfa_policy.py and app/auth/session_policy.py.
|
||||
const [totpAlgorithm, setTotpAlgorithm] = useState('SHA256');
|
||||
const [sessionIdleMinutes, setSessionIdleMinutes] = useState('15');
|
||||
const [sessionMaxHours, setSessionMaxHours] = useState('10');
|
||||
const [smtpConfigured, setSmtpConfigured] = useState(false);
|
||||
const [isSmtpModalOpen, setIsSmtpModalOpen] = useState(false);
|
||||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
||||
@@ -2180,7 +2182,7 @@ export default function SettingsPage() {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [dig, sched, hour, delay, cap, lockPerm, lifecycle, mail, totpAlg] = await Promise.all([
|
||||
const [dig, sched, hour, delay, cap, lockPerm, lifecycle, mail, totpAlg, idle, maxAge] = await Promise.all([
|
||||
api.get('/api/v1/settings/email_template_new_vuln_digest').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/notification_schedule').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/notification_nightly_hour').catch(() => ({ data: { value: null } })),
|
||||
@@ -2190,6 +2192,8 @@ export default function SettingsPage() {
|
||||
api.get('/api/v1/settings/notification_lifecycle_mode').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/mail_enabled').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/totp_algorithm').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/session_idle_timeout_minutes').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/session_max_hours').catch(() => ({ data: { value: null } })),
|
||||
]);
|
||||
if (dig.data?.value) { try { const p = JSON.parse(dig.data.value); setDigestTemplate({ subject: p.subject || '', body: p.body || '' }); } catch { } }
|
||||
if (sched.data?.value) setNotificationSchedule(String(sched.data.value).toLowerCase() === 'nightly' ? 'nightly' : 'per_sync');
|
||||
@@ -2200,6 +2204,8 @@ export default function SettingsPage() {
|
||||
if (lifecycle.data?.value) setNotificationLifecycleMode(String(lifecycle.data.value).toLowerCase() === 'include' ? 'include' : 'exclude');
|
||||
if (mail.data?.value != null) setMailEnabled(String(mail.data.value).toLowerCase() !== 'false');
|
||||
if (totpAlg.data?.value) setTotpAlgorithm(String(totpAlg.data.value).toUpperCase());
|
||||
if (idle.data?.value) setSessionIdleMinutes(String(idle.data.value));
|
||||
if (maxAge.data?.value) setSessionMaxHours(String(maxAge.data.value));
|
||||
} catch { /* non-admin 403s → keep defaults */ }
|
||||
})();
|
||||
}, []);
|
||||
@@ -2756,7 +2762,7 @@ export default function SettingsPage() {
|
||||
{active === 'security' && userRole === 'admin' && (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 font-mono mb-4 border-b border-gray-100 pb-2 flex items-center gap-x-3">
|
||||
Security — MFA Enforcement
|
||||
Security — MFA & Sessions
|
||||
{mfaSavingStatus.message && (
|
||||
<span className={`rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset ${
|
||||
mfaSavingStatus.type === 'success'
|
||||
@@ -2851,6 +2857,42 @@ export default function SettingsPage() {
|
||||
<option value="SHA512">SHA-512</option>
|
||||
</select>
|
||||
</li>
|
||||
|
||||
<li className="flex items-center justify-between gap-x-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold leading-6 text-gray-900 font-mono">Idle timeout</p>
|
||||
<div className="mt-1 text-xs leading-5 text-gray-500">
|
||||
Sign a session out after this long without keyboard or mouse activity. Activity extends it, up to the maximum session length.
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={sessionIdleMinutes}
|
||||
onChange={(e) => { setSessionIdleMinutes(e.target.value); handleSaveSecuritySetting('session_idle_timeout_minutes', e.target.value); }}
|
||||
className="rounded-md border-0 py-1.5 pl-3 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-truevuln-blue sm:text-sm font-mono"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 45, 60, 90, 120, 180].map((m) => (
|
||||
<option key={m} value={String(m)}>{m < 60 ? `${m} min` : `${m / 60} h`}</option>
|
||||
))}
|
||||
</select>
|
||||
</li>
|
||||
|
||||
<li className="flex items-center justify-between gap-x-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold leading-6 text-gray-900 font-mono">Maximum session length</p>
|
||||
<div className="mt-1 text-xs leading-5 text-gray-500">
|
||||
Hard limit from sign-in, however active the user is; then they sign in again. Applies to running sessions at their next renewal. Auth cookies are session cookies and end when the browser closes.
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={sessionMaxHours}
|
||||
onChange={(e) => { setSessionMaxHours(e.target.value); handleSaveSecuritySetting('session_max_hours', e.target.value); }}
|
||||
className="rounded-md border-0 py-1.5 pl-3 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-truevuln-blue sm:text-sm font-mono"
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
|
||||
<option key={h} value={String(h)}>{h} h</option>
|
||||
))}
|
||||
</select>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { renewSession } from '../../lib/api';
|
||||
|
||||
// Last user activity across all tabs, so a tab left in the background does
|
||||
// not sign out a user who is working in another one.
|
||||
const LAST_ACTIVITY_KEY = 'tv_last_activity';
|
||||
const RENEW_EVERY_MS = 60_000;
|
||||
const ACTIVITY_EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'] as const;
|
||||
|
||||
/**
|
||||
* On-screen half of the session policy (app/auth/session_policy.py).
|
||||
*
|
||||
* The server already refuses a session past its idle timeout or maximum age,
|
||||
* but only on the next request — a dashboard left open on an abandoned desk
|
||||
* would stay readable. This takes the page down to /login at the same limits.
|
||||
* User activity renews the session (at most once a minute), so reading a page
|
||||
* without any request still counts as activity on the server too.
|
||||
*/
|
||||
export default function SessionGuard({ idleMinutes, expiresAt }: { idleMinutes: number; expiresAt: number }) {
|
||||
useEffect(() => {
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
let lastLocal = Date.now();
|
||||
let lastRenew = 0;
|
||||
let ended = false;
|
||||
|
||||
const store = (t: number) => { try { localStorage.setItem(LAST_ACTIVITY_KEY, String(t)); } catch { /* private mode */ } };
|
||||
const shared = () => { try { return Number(localStorage.getItem(LAST_ACTIVITY_KEY)) || 0; } catch { return 0; } };
|
||||
|
||||
const end = (reason: 'idle' | 'expired') => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
// Best effort: revoke whatever is still valid. Plain fetch, not the
|
||||
// api client — its 401 handling would race this redirect.
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/auth/logout`, { method: 'POST', credentials: 'include' })
|
||||
.catch(() => null)
|
||||
.finally(() => { window.location.href = `/login?reason=${reason}`; });
|
||||
};
|
||||
|
||||
// A failed renewal is not the end: another tab may have just rotated
|
||||
// the one-use refresh token, or the network blipped. A session that
|
||||
// really is gone shows on the next request (api.ts → /login).
|
||||
const renew = () => { lastRenew = Date.now(); renewSession().catch(() => null); };
|
||||
|
||||
const onActivity = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastLocal < 1000) return; // pointermove fires constantly
|
||||
lastLocal = now;
|
||||
store(now);
|
||||
if (now - lastRenew > RENEW_EVERY_MS) renew();
|
||||
};
|
||||
|
||||
// A (re)load counts as activity — renew so the server agrees.
|
||||
store(lastLocal);
|
||||
renew();
|
||||
ACTIVITY_EVENTS.forEach((e) => window.addEventListener(e, onActivity, { passive: true }));
|
||||
const tick = setInterval(() => {
|
||||
const now = Date.now();
|
||||
if (now >= expiresAt * 1000) end('expired');
|
||||
else if (now - Math.max(lastLocal, shared()) >= idleMs) end('idle');
|
||||
}, 10_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(tick);
|
||||
ACTIVITY_EVENTS.forEach((e) => window.removeEventListener(e, onActivity));
|
||||
};
|
||||
}, [idleMinutes, expiresAt]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
import Drawer from './Drawer';
|
||||
import PasskeyHint from '../../auth/PasskeyHint';
|
||||
import SessionGuard from '../../auth/SessionGuard';
|
||||
import api from '../../../lib/api';
|
||||
import { UserInfo } from '../../../types';
|
||||
|
||||
@@ -61,6 +62,7 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{user?.session && <SessionGuard idleMinutes={user.session.idle_timeout_minutes} expiresAt={user.session.expires_at} />}
|
||||
<Drawer sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} user={user} />
|
||||
|
||||
<div className="lg:pl-72">
|
||||
|
||||
+14
-7
@@ -6,10 +6,10 @@ const api = axios.create({
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Response interceptor: on a 401, first try to silently renew the 30-min
|
||||
// access token from the (still valid, 7-day) refresh cookie and retry the
|
||||
// Response interceptor: on a 401, first try to silently renew the short-lived
|
||||
// access token from the (idle-timeout-long) refresh cookie and retry the
|
||||
// original request — so an actively-used session is NOT bounced to /login every
|
||||
// 30 minutes. Only when the refresh itself fails (refresh token expired/revoked
|
||||
// few minutes. Only when the refresh itself fails (refresh token expired/revoked
|
||||
// = genuinely inactive) do we redirect. Applies to all roles.
|
||||
//
|
||||
// Single-flight: the backend ROTATES the refresh token (revokes it on use), so
|
||||
@@ -48,6 +48,16 @@ api.interceptors.request.use((config) => {
|
||||
});
|
||||
|
||||
let refreshInFlight: Promise<unknown> | null = null;
|
||||
|
||||
// Renew the session now (SessionGuard calls this on user activity, so reading
|
||||
// a page without fetching still counts against the server's idle timeout).
|
||||
// Shares the single-flight below: the refresh token is one-use.
|
||||
export function renewSession(): Promise<unknown> {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = api.post('/auth/refresh').finally(() => { refreshInFlight = null; });
|
||||
}
|
||||
return refreshInFlight;
|
||||
}
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
if ((response.config?.url || '').includes('/auth/me')) {
|
||||
@@ -68,13 +78,10 @@ api.interceptors.response.use(
|
||||
if (error.response?.status === 401 && !isAuthEndpoint && !original._retried) {
|
||||
original._retried = true;
|
||||
try {
|
||||
refreshInFlight = refreshInFlight || api.post('/auth/refresh');
|
||||
await refreshInFlight; // cookie sent via withCredentials
|
||||
await renewSession(); // cookie sent via withCredentials
|
||||
return api(original); // replay the original request
|
||||
} catch {
|
||||
// fall through to the redirect below
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
}
|
||||
}
|
||||
if (error.response?.status === 401) {
|
||||
|
||||
@@ -181,6 +181,8 @@ export interface UserInfo {
|
||||
email: string;
|
||||
role: 'admin' | 'editor' | 'readonly';
|
||||
passkey_suggested?: boolean; // show the "add a passkey" hint
|
||||
// Limits SessionGuard enforces on screen; expires_at is epoch seconds.
|
||||
session?: { idle_timeout_minutes: number; expires_at: number };
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Session lifetime: idle timeout (refresh token lives exactly that long and is
|
||||
renewed on activity) and absolute maximum session age (`sat` claim, carried
|
||||
across refreshes, hard stop). Auth cookies are session-only.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "0" * 64)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
import app.models # noqa: F401,E402
|
||||
from app.auth import session_policy # noqa: E402
|
||||
from app.auth.jwt_handler import create_refresh_token, decode_token, hash_password # noqa: E402
|
||||
from app.database import get_db # noqa: E402
|
||||
from app.models.base import Base # noqa: E402
|
||||
from app.models.setting import Setting # noqa: E402
|
||||
from app.models.user import User, UserRole # noqa: E402
|
||||
from app.rate_limiter import limiter # noqa: E402
|
||||
from app.routers import auth # noqa: E402
|
||||
|
||||
PASSWORD = "Str0ng!Passw0rd"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
db.add(User(username="alice", email="a@example.com", role=UserRole.EDITOR,
|
||||
is_active=True, password_hash=hash_password(PASSWORD)))
|
||||
db.commit()
|
||||
app = FastAPI()
|
||||
app.state.limiter = limiter
|
||||
limiter.reset()
|
||||
app.include_router(auth.router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
return TestClient(app), db
|
||||
|
||||
|
||||
def _set(db, key, value):
|
||||
db.add(Setting(key=key, value=value))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _login(client):
|
||||
res = client.post("/auth/login", json={"username": "alice", "password": PASSWORD})
|
||||
assert res.status_code == 200, res.text
|
||||
return res
|
||||
|
||||
|
||||
def _lifetime(token):
|
||||
p = decode_token(token)
|
||||
return p["exp"] - p["iat"]
|
||||
|
||||
|
||||
def test_defaults_idle_15_minutes_and_max_10_hours(env):
|
||||
client, db = env
|
||||
body = _login(client).json()
|
||||
assert _lifetime(body["refresh_token"]) == pytest.approx(15 * 60, abs=2)
|
||||
assert _lifetime(body["access_token"]) <= 15 * 60 / 2 + 1
|
||||
sat = decode_token(body["access_token"])["sat"]
|
||||
assert sat == pytest.approx(time.time(), abs=5)
|
||||
me = client.get("/auth/me", headers={"Authorization": f"Bearer {body['access_token']}"}).json()
|
||||
assert me["session"]["idle_timeout_minutes"] == 15
|
||||
assert me["session"]["expires_at"] == sat + 10 * 3600
|
||||
|
||||
|
||||
def test_cookies_are_session_only(env):
|
||||
client, _ = env
|
||||
cookies = _login(client).headers.get_list("set-cookie")
|
||||
assert len(cookies) == 2
|
||||
for c in cookies:
|
||||
assert "max-age" not in c.lower() and "expires" not in c.lower(), c
|
||||
|
||||
|
||||
def test_settings_are_clamped(env):
|
||||
_, db = env
|
||||
_set(db, "session_idle_timeout_minutes", "999")
|
||||
_set(db, "session_max_hours", "48")
|
||||
assert session_policy.idle_timeout_minutes(db) == 180
|
||||
assert session_policy.max_session_hours(db) == 12
|
||||
db.query(Setting).filter_by(key="session_idle_timeout_minutes").one().value = "1"
|
||||
db.query(Setting).filter_by(key="session_max_hours").one().value = "nonsense"
|
||||
db.commit()
|
||||
assert session_policy.idle_timeout_minutes(db) == 5
|
||||
assert session_policy.max_session_hours(db) == 10
|
||||
|
||||
|
||||
def test_access_token_is_capped_at_30_minutes(env):
|
||||
client, db = env
|
||||
_set(db, "session_idle_timeout_minutes", "180")
|
||||
body = _login(client).json()
|
||||
assert _lifetime(body["refresh_token"]) == pytest.approx(180 * 60, abs=2)
|
||||
assert _lifetime(body["access_token"]) <= 30 * 60 + 1
|
||||
|
||||
|
||||
def test_refresh_slides_the_idle_window_but_keeps_the_session_start(env):
|
||||
client, _ = env
|
||||
first = _login(client).json()
|
||||
sat = decode_token(first["refresh_token"])["sat"]
|
||||
res = client.post("/auth/refresh", json={"refresh_token": first["refresh_token"]})
|
||||
assert res.status_code == 200, res.text
|
||||
renewed = res.json()["refresh_token"]
|
||||
assert decode_token(renewed)["sat"] == sat
|
||||
assert _lifetime(renewed) == pytest.approx(15 * 60, abs=2)
|
||||
|
||||
|
||||
def test_refresh_past_max_age_is_refused(env):
|
||||
client, _ = env
|
||||
started = int(time.time()) - 10 * 3600 - 1
|
||||
stale = create_refresh_token({"sub": "alice", "role": "editor", "sat": started})
|
||||
res = client.post("/auth/refresh", json={"refresh_token": stale})
|
||||
assert res.status_code == 401
|
||||
assert "expired" in res.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_no_token_outlives_the_max_age(env):
|
||||
client, _ = env
|
||||
started = int(time.time()) - 10 * 3600 + 60 # one minute left
|
||||
token = create_refresh_token({"sub": "alice", "role": "editor", "sat": started})
|
||||
body = client.post("/auth/refresh", json={"refresh_token": token}).json()
|
||||
for t in (body["access_token"], body["refresh_token"]):
|
||||
assert decode_token(t)["exp"] <= started + 10 * 3600
|
||||
|
||||
|
||||
def test_legacy_refresh_token_without_sat_is_refused(env):
|
||||
"""Rotation reset its iat, so it cannot prove when the session began."""
|
||||
client, _ = env
|
||||
legacy = create_refresh_token({"sub": "alice", "role": "editor"})
|
||||
assert client.post("/auth/refresh", json={"refresh_token": legacy}).status_code == 401
|
||||
Reference in New Issue
Block a user