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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:34:43 +02:00
1 parent bed6588699
commit 9269043e91
14 files changed
+1379 -1

No files matched your search

@@ -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 });
});
@@ -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 });
},
);
@@ -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);
});
});
+105
View File
@@ -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<string, string | null>();
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 });
});