feat(auth): add server-backed demo sessions
The browser treated sessionStorage as the source of truth for the logged-in user and never verified or invalidated the server-side session cookie: no GET /api/v1/demo/session or POST /api/v1/demo/logout endpoint existed, and a central 401 handler was defined but never wired up. Add both endpoints; the session-check response is marked Cache-Control: no-store to avoid the browser serving a stale "authenticated" response right after logout. AuthProvider now verifies against the server on every mount (sessionStorage only caches presentation state to avoid a login-screen flash), subscribes to a central 401 listener on the API client, and RequireAuth shows a loading state during verification instead of flashing protected content or the wrong role.
This commit is contained in:
@@ -10,6 +10,10 @@ POSTGRES_PASSWORD=mobilityops
|
|||||||
APP_SECRET=replace-in-production
|
APP_SECRET=replace-in-production
|
||||||
DEMO_TODAY=2026-08-01
|
DEMO_TODAY=2026-08-01
|
||||||
TZ=Europe/Brussels
|
TZ=Europe/Brussels
|
||||||
|
# Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the
|
||||||
|
# current Unraid review environment); set true only once MobilityOps is served over HTTPS,
|
||||||
|
# otherwise browsers will silently drop the cookie and no one can log in.
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
|
||||||
# n8n
|
# n8n
|
||||||
N8N_BASE_URL=http://n8n:5678
|
N8N_BASE_URL=http://n8n:5678
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.security import SessionPayload, create_session_token
|
from app.core.security import SessionPayload, create_session_token, read_session_token
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas import CurrentUser, DemoLoginRequest
|
from app.schemas import CurrentUser, DemoLoginRequest
|
||||||
from app.seed_loader import reset_and_seed
|
from app.seed_loader import reset_and_seed
|
||||||
@@ -41,6 +42,7 @@ def demo_login(
|
|||||||
token,
|
token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
|
secure=settings.session_cookie_secure,
|
||||||
max_age=settings.session_ttl_seconds,
|
max_age=settings.session_ttl_seconds,
|
||||||
)
|
)
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
@@ -56,6 +58,34 @@ def demo_login(
|
|||||||
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=body.role)
|
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=body.role)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", response_model=CurrentUser)
|
||||||
|
def get_session(
|
||||||
|
response: Response, user: CurrentUser = Depends(get_current_user)
|
||||||
|
) -> CurrentUser:
|
||||||
|
# Never let the browser (or an intermediary) cache an authentication check — a stale
|
||||||
|
# cached 200 here would keep showing a logged-out browser as authenticated.
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
payload = read_session_token(token) if token else None
|
||||||
|
if payload is not None:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=uuid.UUID(payload.user_id),
|
||||||
|
actor_label=payload.display_name,
|
||||||
|
action="demo_logout",
|
||||||
|
entity_type="user",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
response.delete_cookie(settings.session_cookie_name)
|
||||||
|
return {"status": "logged_out"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reset")
|
@router.post("/reset")
|
||||||
def demo_reset(
|
def demo_reset(
|
||||||
response: Response,
|
response: Response,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
|
|||||||
app_secret: str = "replace-in-production"
|
app_secret: str = "replace-in-production"
|
||||||
session_cookie_name: str = "mobilityops_session"
|
session_cookie_name: str = "mobilityops_session"
|
||||||
session_ttl_seconds: int = 60 * 60 * 8
|
session_ttl_seconds: int = 60 * 60 * 8
|
||||||
|
session_cookie_secure: bool = False
|
||||||
seed_dir: str = "/app/seed"
|
seed_dir: str = "/app/seed"
|
||||||
knowledge_dir: str = "/app/knowledge/procedures"
|
knowledge_dir: str = "/app/knowledge/procedures"
|
||||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||||
|
|||||||
@@ -18,3 +18,32 @@ def test_operations_manager_can_reset_demo(ops_client):
|
|||||||
response = ops_client.post("/api/v1/demo/reset")
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["counts"]["vehicles"] == 50
|
assert response.json()["counts"]["vehicles"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_endpoint_requires_authentication(client):
|
||||||
|
response = client.get("/api/v1/demo/session")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_endpoint_confirms_logged_in_user(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/demo/session")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["role"] == "operations_manager"
|
||||||
|
assert body["public_ref"] == "USR-OPS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_invalidates_session(ops_client):
|
||||||
|
confirmed = ops_client.get("/api/v1/demo/session")
|
||||||
|
assert confirmed.status_code == 200
|
||||||
|
|
||||||
|
logout = ops_client.post("/api/v1/demo/logout")
|
||||||
|
assert logout.status_code == 200
|
||||||
|
|
||||||
|
after = ops_client.get("/api/v1/demo/session")
|
||||||
|
assert after.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_without_a_session_is_safe(client):
|
||||||
|
response = client.post("/api/v1/demo/logout")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
type UnauthorizedListener = () => void;
|
||||||
|
const unauthorizedListeners = new Set<UnauthorizedListener>();
|
||||||
|
|
||||||
|
export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
||||||
|
unauthorizedListeners.add(listener);
|
||||||
|
return () => unauthorizedListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -31,6 +39,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
body = undefined;
|
body = undefined;
|
||||||
}
|
}
|
||||||
const error = body?.error;
|
const error = body?.error;
|
||||||
|
if (response.status === 401) {
|
||||||
|
unauthorizedListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
response.status,
|
response.status,
|
||||||
error?.code ?? String(response.status),
|
error?.code ?? String(response.status),
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { LoadingState } from "./PageChrome";
|
||||||
|
|
||||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
const { user } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<LoadingState label="Verifying session…" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,93 @@
|
|||||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api, onUnauthorized } from "../api/client";
|
||||||
import type { CurrentUser, Role } from "../api/types";
|
import type { CurrentUser, Role } from "../api/types";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: CurrentUser | null;
|
user: CurrentUser | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loginAs: (role: Role) => Promise<void>;
|
loginAs: (role: Role) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthState | undefined>(undefined);
|
const AuthContext = createContext<AuthState | undefined>(undefined);
|
||||||
|
|
||||||
const STORAGE_KEY = "mobilityops.demo-user";
|
const STORAGE_KEY = "mobilityops.demo-user";
|
||||||
|
|
||||||
|
function readCachedUser(): CurrentUser | null {
|
||||||
|
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored ? (JSON.parse(stored) as CurrentUser) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheUser(user: CurrentUser | null) {
|
||||||
|
if (user) {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<CurrentUser | null>(() => {
|
// The cached value only avoids a login-screen flash while the server round trip below is
|
||||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
// in flight; it is never trusted on its own. `loading` stays true until the server has
|
||||||
return stored ? (JSON.parse(stored) as CurrentUser) : null;
|
// confirmed (or rejected) the session cookie.
|
||||||
});
|
const [user, setUser] = useState<CurrentUser | null>(() => readCachedUser());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api
|
||||||
|
.get<CurrentUser>("/api/v1/demo/session")
|
||||||
|
.then((confirmed) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setUser(confirmed);
|
||||||
|
cacheUser(confirmed);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setUser(null);
|
||||||
|
cacheUser(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onUnauthorized(() => {
|
||||||
|
setUser(null);
|
||||||
|
cacheUser(null);
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const loginAs = useCallback(async (role: Role) => {
|
const loginAs = useCallback(async (role: Role) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const loggedIn = await api.post<CurrentUser>("/api/v1/demo/login", { role });
|
const loggedIn = await api.post<CurrentUser>("/api/v1/demo/login", { role });
|
||||||
setUser(loggedIn);
|
setUser(loggedIn);
|
||||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(loggedIn));
|
cacheUser(loggedIn);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(async () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
cacheUser(null);
|
||||||
|
try {
|
||||||
|
await api.post("/api/v1/demo/logout");
|
||||||
|
} catch {
|
||||||
|
// Best effort: the cookie is cleared server-side when it works, and the client has
|
||||||
|
// already dropped its own state either way.
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>
|
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>{children}</AuthContext.Provider>
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +96,3 @@ export function useAuth(): AuthState {
|
|||||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSessionExpired(error: unknown): boolean {
|
|
||||||
return error instanceof ApiError && error.status === 401;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user