From 9269043e91b483b3bbac799fa12269a940fdf98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:34:43 +0200 Subject: [PATCH] feat(security): list active sessions, sign out everywhere, and show security activity Add the user-facing account-security surface in Settings: - Active sessions (closes #64): GET /api/auth/me/sessions lists each browser login with a coarse device label, masked IP, resolved location, the sliding last-active stamp, and the current-session marker. DELETE the collection signs out everywhere else (keeps the current session, revokes other sessions and native refresh tokens, leaves API tokens alone); DELETE one revokes a single session, scoped to the owner so a foreign id 404s. - A sliding last-active stamp on the session, written throttled and fire-and-forget off the request-resolution path. - Security activity: GET /api/auth/me/security-activity, the shared read over the audit log filtered to sign-ins, second-factor changes, password changes, session revocations, exports, and deletions, with masked IPs. OpenAPI regenerated for the new endpoints. --- docs/api/openapi.yaml | 246 ++++++++++++++++++ .../api/auth/me/security-activity/route.ts | 70 +++++ src/app/api/auth/me/sessions/[id]/route.ts | 41 +++ .../auth/me/sessions/__tests__/route.test.ts | 142 ++++++++++ src/app/api/auth/me/sessions/route.ts | 105 ++++++++ .../settings/account-section/index.tsx | 8 + .../settings/security-activity-card.tsx | 125 +++++++++ .../settings/security-sessions-card.tsx | 183 +++++++++++++ .../auth/__tests__/security-activity.test.ts | 64 +++++ src/lib/auth/security-activity.ts | 41 +++ src/lib/auth/session.ts | 65 +++++ src/lib/openapi/routes/auth.ts | 144 +++++++++- src/lib/query-keys/auth.ts | 15 ++ .../sessions-and-known-devices.test.ts | 131 ++++++++++ 14 files changed, 1379 insertions(+), 1 deletion(-) create mode 100644 src/app/api/auth/me/security-activity/route.ts create mode 100644 src/app/api/auth/me/sessions/[id]/route.ts create mode 100644 src/app/api/auth/me/sessions/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/sessions/route.ts create mode 100644 src/components/settings/security-activity-card.tsx create mode 100644 src/components/settings/security-sessions-card.tsx create mode 100644 src/lib/auth/__tests__/security-activity.test.ts create mode 100644 src/lib/auth/security-activity.ts create mode 100644 tests/integration/sessions-and-known-devices.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4055a7446..648c082e6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1554,6 +1554,81 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/sessions: + get: + tags: + - Auth + summary: List active web sessions + description: "v1.23 — the user-facing active-session list (issue #64). One row per browser login with a coarse device + label, masked IP, resolved location, sliding last-active time, and the current-session marker. Distinct from + /api/auth/me/devices (notification devices)." + responses: + "200": + description: Active sessions for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/SessionListEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Auth + summary: Sign out everywhere else + description: v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current + session. API tokens are not touched (manage those under /settings/api-tokens). + responses: + "200": + description: Other sessions revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/SignOutEverywhereEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/sessions/{id}: + delete: + tags: + - Auth + summary: Revoke a single web session + description: v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never + another user's row). + responses: + "200": + description: Session revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/SessionRevokeEnvelope" + "401": *a1 + "404": + description: Session not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/auth/me/security-activity: + get: + tags: + - Auth + summary: List recent account-security activity + description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with + timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the + AuditLog store; no event detail bodies are surfaced." + responses: + "200": + description: Recent security events for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/SecurityActivityEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/sync/state: get: tags: @@ -11011,6 +11086,177 @@ components: - data - error additionalProperties: false + SessionListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SessionListResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SessionListResponse: + type: object + properties: + sessions: + type: array + items: + type: object + properties: + id: + type: string + device: + type: string + description: Coarse device label derived from the User-Agent. + ipMasked: + anyOf: + - type: string + - type: "null" + description: IP with the host portion masked — never the full address. + location: + anyOf: + - type: string + - type: "null" + description: Resolved coarse location, when available. + lastActiveAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + isCurrent: + type: boolean + description: True for the session making this request. + required: + - id + - device + - ipMasked + - location + - lastActiveAt + - createdAt + - isCurrent + additionalProperties: false + required: + - sessions + additionalProperties: false + SignOutEverywhereEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SignOutEverywhereResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SignOutEverywhereResponse: + type: object + properties: + sessionsRevoked: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Number of OTHER sessions removed (the current one is kept). + required: + - sessionsRevoked + additionalProperties: false + SessionRevokeEnvelope: + type: object + properties: + data: + type: object + properties: + revoked: + type: boolean + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SecurityActivityEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SecurityActivityResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SecurityActivityResponse: + type: object + properties: + events: + type: array + items: + type: object + properties: + action: + type: string + description: Audit action name, e.g. auth.login.password. + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + location: + anyOf: + - type: string + - type: "null" + ipMasked: + anyOf: + - type: string + - type: "null" + description: Host-masked IP. + carrier: + anyOf: + - type: string + - type: "null" + required: + - action + - createdAt + - location + - ipMasked + - carrier + additionalProperties: false + required: + - events + additionalProperties: false SyncStateEnvelope: type: object properties: diff --git a/src/app/api/auth/me/security-activity/route.ts b/src/app/api/auth/me/security-activity/route.ts new file mode 100644 index 000000000..b7966da2e --- /dev/null +++ b/src/app/api/auth/me/security-activity/route.ts @@ -0,0 +1,70 @@ +/** + * GET /api/auth/me/security-activity + * + * v1.23 — the SHARED user-facing security-activity feed. Returns the caller's + * recent account-security audit events (logins, MFA, password change, token / + * session revocations, exports, deletions) with timestamp + resolved location + * + MASKED IP. The IP is never returned in full and no event detail body is + * surfaced — only the action name, time, and coarse geo. Consumed by the + * account-security surface and the privacy dashboard. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { securityActivityWhere } from "@/lib/auth/security-activity"; +import { maskIp } from "@/lib/auth/device-fingerprint"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +export interface SecurityActivityDTO { + action: string; + createdAt: string; + location: string | null; + ipMasked: string | null; + carrier: string | null; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const limitParam = Number( + request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT, + ); + const limit = + Number.isFinite(limitParam) && limitParam > 0 + ? Math.min(Math.floor(limitParam), MAX_LIMIT) + : DEFAULT_LIMIT; + + const rows = await prisma.auditLog.findMany({ + where: securityActivityWhere(user.id), + orderBy: { createdAt: "desc" }, + take: limit, + select: { + action: true, + createdAt: true, + location: true, + ipAddress: true, + carrier: true, + }, + }); + + const events: SecurityActivityDTO[] = rows.map((r) => ({ + action: r.action, + createdAt: r.createdAt.toISOString(), + location: r.location ?? null, + ipMasked: maskIp(r.ipAddress), + carrier: r.carrier ?? null, + })); + + annotate({ + action: { name: "auth.security_activity.list" }, + meta: { event_count: events.length }, + }); + + return apiSuccess({ events }); +}); diff --git a/src/app/api/auth/me/sessions/[id]/route.ts b/src/app/api/auth/me/sessions/[id]/route.ts new file mode 100644 index 000000000..4a98aed9d --- /dev/null +++ b/src/app/api/auth/me/sessions/[id]/route.ts @@ -0,0 +1,41 @@ +/** + * DELETE /api/auth/me/sessions/[id] — revoke a single active web session. + * + * v1.23 — the deletion is scoped to the authenticated user (the Prisma `where` + * narrows on both id AND userId), so a caller can never revoke another user's + * session by guessing an id. A 404 is returned for a non-existent or + * not-owned id so the surface cannot probe foreign session ids. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { destroySessionById } from "@/lib/auth/session"; + +export const dynamic = "force-dynamic"; + +export const DELETE = apiHandler( + async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user, session } = await requireAuth(); + const { id } = await ctx.params; + + const removed = await destroySessionById(user.id, id); + if (!removed) { + return apiError("Session not found", 404); + } + + await auditLog("auth.session.revoke", { + userId: user.id, + ipAddress: getClientIp(request), + details: { self: id === session.id }, + }); + + annotate({ + action: { name: "auth.session.revoke" }, + meta: { self: id === session.id }, + }); + + return apiSuccess({ revoked: true }); + }, +); diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts new file mode 100644 index 000000000..57ad51f46 --- /dev/null +++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { session: { findMany: vi.fn() } }, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), + destroyOtherSessions: vi.fn(), + destroySessionById: vi.fn(), +})); + +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(async () => "Berlin, DE"), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, DELETE } from "../route"; +import { DELETE as DELETE_ONE } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { + getSession, + destroyOtherSessions, + destroySessionById, +} from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-current", expiresAt: new Date(Date.now() + 3.6e6) }, + user: { id: "user-1", username: "u", role: "USER" as const }, +}; + +function del(): NextRequest { + return new NextRequest("http://localhost/api/auth/me/sessions", { + method: "DELETE", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/auth/me/sessions", () => { + it("401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET(); + expect(res.status).toBe(401); + }); + + it("lists sessions with masked IP, location, and the current marker", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.session.findMany).mockResolvedValue([ + { + id: "sess-current", + ipAddress: "203.0.113.7", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0", + lastActiveAt: new Date(), + createdAt: new Date(), + }, + { + id: "sess-other", + ipAddress: "198.51.100.4", + userAgent: + "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36", + lastActiveAt: null, + createdAt: new Date(), + }, + ] as never); + + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + const list = body.data.sessions; + expect(list).toHaveLength(2); + expect(list[0]).toMatchObject({ + id: "sess-current", + device: "Firefox on macOS", + ipMasked: "203.0.x.x", + location: "Berlin, DE", + isCurrent: true, + }); + expect(list[1].isCurrent).toBe(false); + // The full IP never appears in the response body. + expect(JSON.stringify(body)).not.toContain("203.0.113.7"); + }); +}); + +describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { + it("revokes other sessions and reports the count", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroyOtherSessions).mockResolvedValue({ sessionsRevoked: 3 }); + + const res = await DELETE(del()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.sessionsRevoked).toBe(3); + expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", "sess-current"); + }); +}); + +describe("DELETE /api/auth/me/sessions/[id]", () => { + function ctx(id: string) { + return { params: Promise.resolve({ id }) }; + } + function delReq(id: string): NextRequest { + return new NextRequest(`http://localhost/api/auth/me/sessions/${id}`, { + method: "DELETE", + }); + } + + it("revokes a single owned session", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroySessionById).mockResolvedValue(true); + const res = await DELETE_ONE(delReq("sess-other"), ctx("sess-other")); + expect(res.status).toBe(200); + expect(destroySessionById).toHaveBeenCalledWith("user-1", "sess-other"); + }); + + it("404 for a non-existent or not-owned id", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroySessionById).mockResolvedValue(false); + const res = await DELETE_ONE(delReq("nope"), ctx("nope")); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts new file mode 100644 index 000000000..bb3fcd05e --- /dev/null +++ b/src/app/api/auth/me/sessions/route.ts @@ -0,0 +1,105 @@ +/** + * GET /api/auth/me/sessions — list the user's active web sessions. + * DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER + * session (and native refresh tokens), keeping + * the caller's current session. Closes #64. + * + * v1.23 — the user-facing session/device-management surface. Distinct from + * `/api/auth/me/devices`, which lists APNs / Web-Push notification devices; + * this lists the authenticated `Session` rows (one per browser login). The list + * carries the masked IP + resolved coarse location + a coarse device label + + * the sliding `lastActiveAt` — never the full IP, never a credential. The + * caller's own row is flagged `isCurrent` so the UI can mark "this device". + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { getClientIp } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { destroyOtherSessions } from "@/lib/auth/session"; +import { lookupIpLocation } from "@/lib/geo"; +import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint"; + +export const dynamic = "force-dynamic"; + +export interface SessionDTO { + id: string; + device: string; + ipMasked: string | null; + location: string | null; + lastActiveAt: string | null; + createdAt: string; + isCurrent: boolean; +} + +export const GET = apiHandler(async () => { + const { user, session } = await requireAuth(); + + const sessions = await prisma.session.findMany({ + where: { userId: user.id, expiresAt: { gt: new Date() } }, + orderBy: { lastActiveAt: { sort: "desc", nulls: "last" } }, + select: { + id: true, + ipAddress: true, + userAgent: true, + lastActiveAt: true, + createdAt: true, + }, + }); + + // Resolve location once per distinct IP (the resolver caches internally, but + // de-duping keeps the await count to the number of unique networks). + const uniqueIps = Array.from( + new Set( + sessions.map((s) => s.ipAddress).filter((ip): ip is string => !!ip), + ), + ); + const locationByIp = new Map(); + await Promise.all( + uniqueIps.map(async (ip) => { + try { + locationByIp.set(ip, await lookupIpLocation(ip)); + } catch { + locationByIp.set(ip, null); + } + }), + ); + + const result: SessionDTO[] = sessions.map((s) => ({ + id: s.id, + device: coarseDeviceLabel(s.userAgent), + ipMasked: maskIp(s.ipAddress), + location: s.ipAddress ? (locationByIp.get(s.ipAddress) ?? null) : null, + lastActiveAt: s.lastActiveAt ? s.lastActiveAt.toISOString() : null, + createdAt: s.createdAt.toISOString(), + isCurrent: s.id === session.id, + })); + + annotate({ + action: { name: "auth.session.list" }, + meta: { session_count: result.length }, + }); + + return apiSuccess({ sessions: result }); +}); + +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user, session } = await requireAuth(); + + const { sessionsRevoked } = await destroyOtherSessions(user.id, session.id); + + await auditLog("auth.session.revoke_others", { + userId: user.id, + ipAddress: getClientIp(request), + details: { sessionsRevoked }, + }); + + annotate({ + action: { name: "auth.session.revoke_others" }, + meta: { sessions_revoked: sessionsRevoked }, + }); + + return apiSuccess({ sessionsRevoked }); +}); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 4fc5df757..7ba648646 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -39,6 +39,8 @@ import { } from "./account-section-utils"; import { PasskeyListSection } from "./passkey-list-section"; import { AvatarSection } from "./avatar-section"; +import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; +import { SecurityActivityCard } from "@/components/settings/security-activity-card"; export { resolveInitialTimezone } from "./account-section-utils"; @@ -589,6 +591,12 @@ export function AccountSection() { + {/* v1.23 — active-session management (issue #64) + the security-activity + feed. Both sit in the account/security block under the credential + controls above. */} + + + {/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings → Erweitert. It is a maintenance / reset action, not a profile or security control, so it sits beside Research Mode + the danger zone. */} diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx new file mode 100644 index 000000000..7f7426984 --- /dev/null +++ b/src/components/settings/security-activity-card.tsx @@ -0,0 +1,125 @@ +"use client"; + +/** + * v1.23 — Settings → account → "Security activity". + * + * A read-only feed of the user's recent account-security events (sign-ins, MFA + * changes, password changes, session revocations, exports, deletions) from the + * shared `GET /api/auth/me/security-activity` endpoint. Each row shows a + * readable action label, the resolved location, the masked IP, and the time. + */ +import { useQuery } from "@tanstack/react-query"; +import { Loader2, ShieldCheck } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; + +interface ActivityRow { + action: string; + createdAt: string; + location: string | null; + ipMasked: string | null; + carrier: string | null; +} + +/** Map an audit action onto a stable, translatable label key. */ +function actionLabelKey(action: string): string { + if (action === "auth.login.new_device") + return "settings.security.action.newDevice"; + if (action === "auth.login.failed") + return "settings.security.action.loginFailed"; + if (action.startsWith("auth.login") || action === "auth.register") + return "settings.security.action.login"; + if (action.startsWith("auth.password")) + return "settings.security.action.passwordChange"; + if (action.startsWith("auth.mfa")) return "settings.security.action.mfa"; + if (action.startsWith("auth.session")) + return "settings.security.action.sessionRevoke"; + if (action.startsWith("auth.token") || action.startsWith("auth.bearer")) + return "settings.security.action.token"; + if (action === "user.account.delete") + return "settings.security.action.accountDelete"; + if (action === "user.data.clear") return "settings.security.action.dataClear"; + if (action.includes("export")) return "settings.security.action.export"; + return "settings.security.action.other"; +} + +export function SecurityActivityCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.securityActivity(), + queryFn: () => + apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"), + enabled: isAuthenticated, + }); + + const events = data?.events ?? []; + + return ( + + +
+

+ {t("settings.security.activityDescription")} +

+ + {isLoading && ( + + )} + + {isError && ( +

+ {t("settings.security.activityLoadError")} +

+ )} + + {!isLoading && !isError && events.length === 0 && ( +

+ {t("settings.security.activityEmpty")} +

+ )} + + {events.length > 0 && ( +
    + {events.map((e, idx) => ( +
  • +
    +

    + {t(actionLabelKey(e.action))} +

    +

    + {[ + e.location ?? t("settings.security.unknownLocation"), + e.ipMasked, + ] + .filter(Boolean) + .join(" · ")} +

    +
    +

    + {new Date(e.createdAt).toLocaleString()} +

    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx new file mode 100644 index 000000000..77f98cabb --- /dev/null +++ b/src/components/settings/security-sessions-card.tsx @@ -0,0 +1,183 @@ +"use client"; + +/** + * v1.23 — Settings → account → "Active sessions" (issue #64). + * + * Lists the user's active web sessions (device label, masked IP, resolved + * location, last-active time, current-device marker) and offers a per-session + * "sign out" plus a "sign out everywhere else" that revokes every other + * session (and native device logins) while keeping the current one. All reads + * + writes route through the centralised query-key factory. + */ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2, MonitorSmartphone } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet, apiDelete } from "@/lib/api/api-fetch"; + +interface SessionRow { + id: string; + device: string; + ipMasked: string | null; + location: string | null; + lastActiveAt: string | null; + createdAt: string; + isCurrent: boolean; +} + +export function SecuritySessionsCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [status, setStatus] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.sessions(), + queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"), + enabled: isAuthenticated, + }); + + const revokeOne = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/auth/me/sessions/${id}`), + onSuccess: () => { + setStatus(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.securityActivity(), + }); + }, + onError: () => setStatus(t("settings.security.revokeError")), + }); + + const revokeOthers = useMutation({ + mutationFn: () => + apiDelete<{ sessionsRevoked: number }>("/api/auth/me/sessions"), + onSuccess: (res) => { + setStatus( + t("settings.security.signOutEverywhereDone", { + count: res?.sessionsRevoked ?? 0, + }), + ); + void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.securityActivity(), + }); + }, + onError: () => setStatus(t("settings.security.revokeError")), + }); + + const sessions = data?.sessions ?? []; + const hasOthers = sessions.some((s) => !s.isCurrent); + + return ( + + +
+

+ {t("settings.security.sessionsDescription")} +

+ + {isLoading && ( + + )} + + {isError && ( +

+ {t("settings.security.sessionsLoadError")} +

+ )} + + {!isLoading && !isError && sessions.length === 0 && ( +

+ {t("settings.security.sessionsEmpty")} +

+ )} + + {sessions.length > 0 && ( +
    + {sessions.map((s) => ( +
  • +
    +

    + {s.device} + {s.isCurrent && ( + + {t("settings.security.currentSession")} + + )} +

    +

    + {[ + s.location ?? t("settings.security.unknownLocation"), + s.ipMasked, + ] + .filter(Boolean) + .join(" · ")} +

    + {s.lastActiveAt && ( +

    + {t("settings.security.lastActive", { + time: new Date(s.lastActiveAt).toLocaleString(), + })} +

    + )} +
    + {!s.isCurrent && ( + + )} +
  • + ))} +
+ )} + + {hasOthers && ( +
+ +
+ )} + + {status && ( +

+ {status} +

+ )} +
+
+ ); +} diff --git a/src/lib/auth/__tests__/security-activity.test.ts b/src/lib/auth/__tests__/security-activity.test.ts new file mode 100644 index 000000000..7575e15f0 --- /dev/null +++ b/src/lib/auth/__tests__/security-activity.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { + matchesSecurityActivity, + securityActivityWhere, +} from "../security-activity"; + +describe("matchesSecurityActivity", () => { + it("includes every auth.* event", () => { + for (const a of [ + "auth.login.password", + "auth.login.failed", + "auth.login.new_device", + "auth.mfa.verify", + "auth.password.change", + "auth.session.revoke", + "auth.session.revoke_others", + "auth.token.autoissue.native", + "auth.bearer.success", + "auth.register", + ]) { + expect(matchesSecurityActivity(a)).toBe(true); + } + }); + + it("includes export actions", () => { + for (const a of [ + "export.download", + "health-record.export", + "user.export.full-backup", + "user.export.measurements", + ]) { + expect(matchesSecurityActivity(a)).toBe(true); + } + }); + + it("includes the erasure actions", () => { + expect(matchesSecurityActivity("user.account.delete")).toBe(true); + expect(matchesSecurityActivity("user.data.clear")).toBe(true); + }); + + it("excludes unrelated audit actions", () => { + for (const a of [ + "measurement.create", + "medication.intake.bulk", + "coach.budget.exceeded", + "withings.sync.ok", + "consent.ai.web", + ]) { + expect(matchesSecurityActivity(a)).toBe(false); + } + }); +}); + +describe("securityActivityWhere", () => { + it("scopes by userId and mirrors the predicate clauses", () => { + const where = securityActivityWhere("user-1"); + expect(where.userId).toBe("user-1"); + expect(where.OR).toEqual([ + { action: { startsWith: "auth." } }, + { action: { contains: "export" } }, + { action: { in: ["user.account.delete", "user.data.clear"] } }, + ]); + }); +}); diff --git a/src/lib/auth/security-activity.ts b/src/lib/auth/security-activity.ts new file mode 100644 index 000000000..f687a30f3 --- /dev/null +++ b/src/lib/auth/security-activity.ts @@ -0,0 +1,41 @@ +/** + * v1.23 — security-activity feed. The SHARED read both the account-security + * surface and the data-sovereignty privacy dashboard consume: a user-scoped, + * read-only window onto the `AuditLog` rows that matter for account security — + * logins, MFA events, password changes, token/session revocations, exports, + * and deletions. No new storage: it reuses the existing + * `@@index([userId, action, createdAt(sort: Desc)])`. + */ + +/** + * Whether an audit action belongs in the user-facing security-activity feed. + * Pure so the contract can be unit-tested without a DB: + * - every `auth.*` event (login, mfa, password, token, bearer, session, + * register), + * - every export action (`export.download`, `health-record.export`, + * `user.export.*` — all contain "export"), + * - the two erasure actions (`user.account.delete`, `user.data.clear`). + */ +export function matchesSecurityActivity(action: string): boolean { + return ( + action.startsWith("auth.") || + action.includes("export") || + action === "user.account.delete" || + action === "user.data.clear" + ); +} + +/** + * Prisma `where` mirror of `matchesSecurityActivity`, scoped to one user. Kept + * alongside the predicate so the two never drift. + */ +export function securityActivityWhere(userId: string) { + return { + userId, + OR: [ + { action: { startsWith: "auth." } }, + { action: { contains: "export" } }, + { action: { in: ["user.account.delete", "user.data.clear"] } }, + ], + }; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 7a2c73e77..83295b4b8 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -8,6 +8,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; const SESSION_COOKIE = "healthlog_session"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +// v1.23 — throttle window for the user-facing active-session list's "last +// seen" stamp. A write only happens when the prior stamp is older than this, +// so the high-churn `getSession` path costs at most one extra UPDATE per +// session per window rather than one per request. +const LAST_ACTIVE_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes + /** * v1.4.22 C4 — flag cookie mirroring the user's `onboardingCompletedAt` * status. Set to `"pending"` while the field is null and cleared once @@ -147,6 +153,22 @@ export async function getSession(): Promise<{ }); } + // v1.23 — sliding "last seen" stamp for the active-session list. Throttled + // (only when the prior stamp is stale) and fire-and-forget so it never adds + // latency or a failure mode to the request-resolution path. + const lastActive = session.lastActiveAt; + if ( + !lastActive || + lastActive.getTime() < Date.now() - LAST_ACTIVE_THROTTLE_MS + ) { + void prisma.session + .update({ + where: { id: session.id }, + data: { lastActiveAt: new Date() }, + }) + .catch(() => {}); + } + return { session: { id: session.id, expiresAt: session.expiresAt }, user: session.user, @@ -191,3 +213,46 @@ export async function destroyAllSessions(userId: string): Promise { }), ]); } + +/** + * v1.23 — "sign out everywhere" for the user-facing active-session surface + * (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's + * CURRENT cookie session alive so clicking the button doesn't log the user out + * of the device they pressed it on, and it does NOT revoke `ApiToken`s — those + * are long-lived programmatic credentials the user manages separately under + * /settings/api-tokens, not "sessions" in the device-list sense. It DOES revoke + * every native-client `RefreshToken` (each is a device login) so a signed-in + * phone/tablet is dropped too, matching the "everywhere" promise. + * + * Returns the number of OTHER web sessions removed so the surface can confirm. + */ +export async function destroyOtherSessions( + userId: string, + currentSessionId: string, +): Promise<{ sessionsRevoked: number }> { + const [deleted] = await prisma.$transaction([ + prisma.session.deleteMany({ + where: { userId, id: { not: currentSessionId } }, + }), + prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + ]); + return { sessionsRevoked: deleted.count }; +} + +/** + * v1.23 — revoke a single web session by id, scoped to the owning user so a + * caller can never delete another user's session row. Returns whether a row + * was actually removed (false → not found or not owned). + */ +export async function destroySessionById( + userId: string, + sessionId: string, +): Promise { + const result = await prisma.session.deleteMany({ + where: { id: sessionId, userId }, + }); + return result.count > 0; +} diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 28736b8af..959913b13 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -13,7 +13,7 @@ import { totpConfirmSchema, mfaDisableSchema, } from "@/lib/validations/mfa"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, stdResponses, errorEnvelope } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -129,6 +129,61 @@ const mfaToggleResponse = z .object({ enabled: z.boolean() }) .meta({ id: "MfaToggleResponse" }); +// ── v1.23 active sessions + security activity shapes ────────────────── + +const sessionListResponse = z + .object({ + sessions: z.array( + z.object({ + id: z.string(), + device: z + .string() + .describe("Coarse device label derived from the User-Agent."), + ipMasked: z + .string() + .nullable() + .describe( + "IP with the host portion masked — never the full address.", + ), + location: z + .string() + .nullable() + .describe("Resolved coarse location, when available."), + lastActiveAt: z.iso.datetime({ offset: true }).nullable(), + createdAt: z.iso.datetime({ offset: true }), + isCurrent: z + .boolean() + .describe("True for the session making this request."), + }), + ), + }) + .meta({ id: "SessionListResponse" }); + +const signOutEverywhereResponse = z + .object({ + sessionsRevoked: z + .number() + .int() + .describe("Number of OTHER sessions removed (the current one is kept)."), + }) + .meta({ id: "SignOutEverywhereResponse" }); + +const securityActivityResponse = z + .object({ + events: z.array( + z.object({ + action: z + .string() + .describe("Audit action name, e.g. auth.login.password."), + createdAt: z.iso.datetime({ offset: true }), + location: z.string().nullable(), + ipMasked: z.string().nullable().describe("Host-masked IP."), + carrier: z.string().nullable(), + }), + ), + }) + .meta({ id: "SecurityActivityResponse" }); + export const authPaths: NonNullable = { "/api/auth/login": { post: { @@ -320,4 +375,91 @@ export const authPaths: NonNullable = { }, }, }, + "/api/auth/me/sessions": { + get: { + tags: ["Auth"], + summary: "List active web sessions", + description: + "v1.23 — the user-facing active-session list (issue #64). One row per browser login with a coarse device label, masked IP, resolved location, sliding last-active time, and the current-session marker. Distinct from /api/auth/me/devices (notification devices).", + responses: { + "200": { + description: "Active sessions for the caller.", + content: { + "application/json": { + schema: dataEnvelope(sessionListResponse, "SessionListEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Auth"], + summary: "Sign out everywhere else", + description: + "v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current session. API tokens are not touched (manage those under /settings/api-tokens).", + responses: { + "200": { + description: "Other sessions revoked.", + content: { + "application/json": { + schema: dataEnvelope( + signOutEverywhereResponse, + "SignOutEverywhereEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/sessions/{id}": { + delete: { + tags: ["Auth"], + summary: "Revoke a single web session", + description: + "v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row).", + responses: { + "200": { + description: "Session revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.boolean() }), + "SessionRevokeEnvelope", + ), + }, + }, + }, + "404": { + description: "Session not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/security-activity": { + get: { + tags: ["Auth"], + summary: "List recent account-security activity", + description: + "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.", + responses: { + "200": { + description: "Recent security events for the caller.", + content: { + "application/json": { + schema: dataEnvelope( + securityActivityResponse, + "SecurityActivityEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/query-keys/auth.ts b/src/lib/query-keys/auth.ts index d02beb3fe..1e5836a0b 100644 --- a/src/lib/query-keys/auth.ts +++ b/src/lib/query-keys/auth.ts @@ -33,6 +33,21 @@ export const authKeys = { passkeys: () => ["passkeys"] as const, + /** + * v1.23 — the user-facing active-session list behind + * `GET /api/auth/me/sessions`. Revoking a single session or signing out + * everywhere invalidates this key so the surface re-reads the live set. + */ + sessions: () => ["auth", "sessions"] as const, + + /** + * v1.23 — the shared security-activity feed behind + * `GET /api/auth/me/security-activity` (logins, MFA, password change, + * session revoke, exports, deletions). Consumed by the account-security + * surface and the privacy dashboard. + */ + securityActivity: () => ["auth", "security-activity"] as const, + userAiProvider: () => ["user", "ai-provider"] as const, userProfile: () => ["user", "profile"] as const, /** diff --git a/tests/integration/sessions-and-known-devices.test.ts b/tests/integration/sessions-and-known-devices.test.ts new file mode 100644 index 000000000..2ea8d6743 --- /dev/null +++ b/tests/integration/sessions-and-known-devices.test.ts @@ -0,0 +1,131 @@ +/** + * Integration test — v1.23 active-session management + known-device ledger + * against a real Postgres (migration 0203 applied by the container harness). + * + * Covers the DB-layer contracts the unit mocks can't pin: + * - destroyOtherSessions keeps the current session, deletes the rest, revokes + * native refresh tokens, and leaves API tokens untouched. + * - destroySessionById is scoped to the owning user (no cross-user delete). + * - the (userId, deviceHash) unique index enforces the login-alert dedupe. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { destroyOtherSessions, destroySessionById } from "@/lib/auth/session"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function makeUser(username: string) { + return getPrismaClient().user.create({ + data: { username, email: `${username}@example.test` }, + }); +} + +describe("destroyOtherSessions", () => { + it("keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => { + const prisma = getPrismaClient(); + const user = await makeUser("sess-owner"); + + const current = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + const refresh = await prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: "rt-hash-1", + expiresAt: new Date(Date.now() + 1e6), + }, + }); + const apiToken = await prisma.apiToken.create({ + data: { + userId: user.id, + name: "automation", + tokenHash: "at-hash-1", + permissions: ["*"], + }, + }); + + const result = await destroyOtherSessions(user.id, current.id); + expect(result.sessionsRevoked).toBe(2); + + const remaining = await prisma.session.findMany({ + where: { userId: user.id }, + }); + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe(current.id); + + const rt = await prisma.refreshToken.findUnique({ + where: { id: refresh.id }, + }); + expect(rt?.revokedAt).not.toBeNull(); + + const at = await prisma.apiToken.findUnique({ where: { id: apiToken.id } }); + expect(at?.revoked).toBe(false); + }); +}); + +describe("destroySessionById", () => { + it("revokes an owned session and refuses a cross-user id", async () => { + const prisma = getPrismaClient(); + const owner = await makeUser("owner"); + const other = await makeUser("intruder"); + + const ownerSession = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + const otherSession = await prisma.session.create({ + data: { userId: other.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + + // Cross-user attempt: owner tries to delete the intruder's session. + expect(await destroySessionById(owner.id, otherSession.id)).toBe(false); + expect( + await prisma.session.findUnique({ where: { id: otherSession.id } }), + ).not.toBeNull(); + + // Owned delete succeeds. + expect(await destroySessionById(owner.id, ownerSession.id)).toBe(true); + expect( + await prisma.session.findUnique({ where: { id: ownerSession.id } }), + ).toBeNull(); + }); +}); + +describe("UserKnownDevice unique index", () => { + it("dedupes on (userId, deviceHash) — the same fingerprint cannot insert twice", async () => { + const prisma = getPrismaClient(); + const user = await makeUser("device-owner"); + + await prisma.userKnownDevice.create({ + data: { + userId: user.id, + deviceHash: "hash-abc", + label: "Firefox on macOS", + }, + }); + + await expect( + prisma.userKnownDevice.create({ + data: { userId: user.id, deviceHash: "hash-abc" }, + }), + ).rejects.toThrow(); + + // A different hash for the same user inserts fine. + await prisma.userKnownDevice.create({ + data: { userId: user.id, deviceHash: "hash-def" }, + }); + + const rows = await prisma.userKnownDevice.findMany({ + where: { userId: user.id }, + }); + expect(rows).toHaveLength(2); + }); +});