feat(auth): add server-tracked sessions with view/revoke
JWTs were previously fully stateless -- the token alone was the credential until its own expiry, with no way to list or kill a session server-side. Add a UserSession table alongside every JWT issued (AppSessionIssuer), embed its id as a "sid" claim, and check that claim against the DB on every "local" scheme request (Program.cs OnTokenValidated) so a session can actually be revoked before its JWT naturally expires. New /api/auth/sessions endpoints (list, revoke one, revoke-others) plus a Sessions card on the profile page. Fails closed on a missing "sid" claim: every JWT issued going forward has one, so a token without it is either pre-deploy (forces one re-login for already-signed-in users at deploy time, same additive-forward cost the 2FA/trusted-device work on this branch already paid) or forged.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { clearAuthClientState } from "../auth";
|
||||
|
||||
type Session = {
|
||||
id: string;
|
||||
deviceLabel: string | null;
|
||||
createdAtUtc: string;
|
||||
lastSeenAtUtc: string;
|
||||
expiresAtUtc: string;
|
||||
isCurrentSession: boolean;
|
||||
};
|
||||
|
||||
function apiErrorMessage(e: any, t: (k: any) => string) {
|
||||
return getApiErrorMessage(e, t("twoFactorGenericError"));
|
||||
}
|
||||
|
||||
export default function SessionsSettingsCard() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRevokeOthers, setConfirmRevokeOthers] = useState(false);
|
||||
|
||||
const loadSessions = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.get<Session[]>("/auth/sessions")
|
||||
.then((r) => setSessions(r.data))
|
||||
.catch((e) => setError(apiErrorMessage(e, t)))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadSessions(); }, []);
|
||||
|
||||
async function revokeSession(id: string, isCurrentSession: boolean) {
|
||||
try {
|
||||
await api.delete(`/auth/sessions/${id}`);
|
||||
if (isCurrentSession) {
|
||||
// Same pattern as AuthStatusCard's sign-out: clearing local auth state emits
|
||||
// "auth-changed", which App.tsx's listener picks up to refetch /auth/me (now 401,
|
||||
// since the server already deleted the session cookie) and redirect to /login.
|
||||
clearAuthClientState();
|
||||
return;
|
||||
}
|
||||
toast(t("sessionsRevoked"), "success");
|
||||
loadSessions();
|
||||
} catch (e: any) {
|
||||
setError(apiErrorMessage(e, t));
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeOthers() {
|
||||
try {
|
||||
await api.post("/auth/sessions/revoke-others");
|
||||
toast(t("sessionsRevokedOthers"), "success");
|
||||
setConfirmRevokeOthers(false);
|
||||
loadSessions();
|
||||
} catch (e: any) {
|
||||
setError(apiErrorMessage(e, t));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("sessionsSectionTitle")}
|
||||
</Typography>
|
||||
|
||||
{error ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||
{!loading && sessions.length === 0 && !error ? (
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("sessionsEmpty")}</Typography>
|
||||
) : null}
|
||||
|
||||
{sessions.length > 0 ? (
|
||||
<List dense disablePadding>
|
||||
{sessions.map((s) => (
|
||||
<ListItem key={s.id} divider>
|
||||
<ListItemText
|
||||
primary={
|
||||
<>
|
||||
{s.deviceLabel || t("sessionsUnknownDevice")}
|
||||
{s.isCurrentSession ? (
|
||||
<Typography component="span" variant="caption" sx={{ ml: 1, color: "primary.main", fontWeight: 700 }}>
|
||||
{t("sessionsCurrentDevice")}
|
||||
</Typography>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
secondary={t("sessionsMeta", {
|
||||
lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(),
|
||||
expires: new Date(s.expiresAtUtc).toLocaleDateString(),
|
||||
})}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton edge="end" aria-label={t("sessionsRevoke")} onClick={() => revokeSession(s.id, s.isCurrentSession)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : null}
|
||||
|
||||
{sessions.length > 1 ? (
|
||||
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => setConfirmRevokeOthers(true)}>
|
||||
{t("sessionsRevokeOthers")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Dialog open={confirmRevokeOthers} onClose={() => setConfirmRevokeOthers(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t("sessionsRevokeOthersConfirmTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>{t("sessionsRevokeOthersConfirmBody")}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button type="button" onClick={() => setConfirmRevokeOthers(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" color="warning" onClick={revokeOthers}>
|
||||
{t("sessionsRevokeOthers")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -356,6 +356,17 @@ export const translations = {
|
||||
twoFactorTrustedDevicesRevokedAll: "All trusted devices have been signed out.",
|
||||
twoFactorRevokeAllConfirmTitle: "Sign out all trusted devices?",
|
||||
twoFactorRevokeAllConfirmBody: "You'll be asked for a 2FA code the next time you sign in on any device, including this one.",
|
||||
sessionsSectionTitle: "Sessions",
|
||||
sessionsEmpty: "No active sessions.",
|
||||
sessionsUnknownDevice: "Unknown device",
|
||||
sessionsCurrentDevice: "This device",
|
||||
sessionsMeta: "Last active {lastSeen} · Expires {expires}",
|
||||
sessionsRevoke: "Sign out",
|
||||
sessionsRevoked: "Session signed out.",
|
||||
sessionsRevokeOthers: "Sign out all other devices",
|
||||
sessionsRevokedOthers: "All other sessions have been signed out.",
|
||||
sessionsRevokeOthersConfirmTitle: "Sign out all other devices?",
|
||||
sessionsRevokeOthersConfirmBody: "Every other session for your account will be signed out immediately. This device stays signed in.",
|
||||
cropDialogTitle: "Crop profile image",
|
||||
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
|
||||
cropDialogZoom: "Zoom",
|
||||
@@ -1393,6 +1404,17 @@ export const translations = {
|
||||
twoFactorTrustedDevicesRevokedAll: "Alle betrodde enheter er logget ut.",
|
||||
twoFactorRevokeAllConfirmTitle: "Logg ut alle betrodde enheter?",
|
||||
twoFactorRevokeAllConfirmBody: "Du vil bli bedt om en 2FA-kode neste gang du logger inn på en enhet, inkludert denne.",
|
||||
sessionsSectionTitle: "Økter",
|
||||
sessionsEmpty: "Ingen aktive økter.",
|
||||
sessionsUnknownDevice: "Ukjent enhet",
|
||||
sessionsCurrentDevice: "Denne enheten",
|
||||
sessionsMeta: "Sist aktiv {lastSeen} · Utløper {expires}",
|
||||
sessionsRevoke: "Logg ut",
|
||||
sessionsRevoked: "Økten er logget ut.",
|
||||
sessionsRevokeOthers: "Logg ut alle andre enheter",
|
||||
sessionsRevokedOthers: "Alle andre økter er logget ut.",
|
||||
sessionsRevokeOthersConfirmTitle: "Logg ut alle andre enheter?",
|
||||
sessionsRevokeOthersConfirmBody: "Alle andre økter for kontoen din blir umiddelbart logget ut. Denne enheten forblir innlogget.",
|
||||
cropDialogTitle: "Beskjær profilbilde",
|
||||
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
|
||||
cropDialogZoom: "Zoom",
|
||||
|
||||
@@ -12,6 +12,7 @@ import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import AuthStatusCard from "../components/AuthStatusCard";
|
||||
import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
|
||||
import SessionsSettingsCard from "../components/SessionsSettingsCard";
|
||||
import EmailProviderConnections from "../components/EmailProviderConnections";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import { useToast } from "../toast";
|
||||
@@ -1351,6 +1352,7 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
|
||||
{isLocal ? <TwoFactorSettingsCard /> : null}
|
||||
{isLocal ? <SessionsSettingsCard /> : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user