confirm actions

This commit is contained in:
2026-03-15 18:59:37 -05:00
parent 59754c7657
commit df041254da
28 changed files with 999 additions and 63 deletions

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "AuthSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"lastSeenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ipAddress" TEXT,
"userAgent" TEXT,
"revokedAt" DATETIME,
"revokedById" TEXT,
"revokedReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "AuthSession_revokedById_fkey" FOREIGN KEY ("revokedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "AuthSession_userId_createdAt_idx" ON "AuthSession"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "AuthSession_expiresAt_idx" ON "AuthSession"("expiresAt");
-- CreateIndex
CREATE INDEX "AuthSession_revokedAt_idx" ON "AuthSession"("revokedAt");

View File

@@ -18,6 +18,8 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userRoles UserRole[]
authSessions AuthSession[] @relation("AuthSessionUser")
revokedAuthSessions AuthSession[] @relation("AuthSessionRevokedBy")
contactEntries CrmContactEntry[]
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
@@ -72,6 +74,26 @@ model RolePermission {
@@id([roleId, permissionId])
}
model AuthSession {
id String @id @default(cuid())
userId String
expiresAt DateTime
lastSeenAt DateTime @default(now())
ipAddress String?
userAgent String?
revokedAt DateTime?
revokedById String?
revokedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation("AuthSessionUser", fields: [userId], references: [id], onDelete: Cascade)
revokedBy User? @relation("AuthSessionRevokedBy", fields: [revokedById], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([expiresAt])
@@index([revokedAt])
}
model CompanyProfile {
id String @id @default(cuid())
companyName String

View File

@@ -9,6 +9,7 @@ import pinoHttp from "pino-http";
import { env } from "./config/env.js";
import { paths } from "./config/paths.js";
import { verifyToken } from "./lib/auth.js";
import { getActiveAuthSession, touchAuthSession } from "./lib/auth-sessions.js";
import { getCurrentUserById } from "./lib/current-user.js";
import { fail, ok } from "./lib/http.js";
import { recordSupportLog } from "./lib/support-log.js";
@@ -44,10 +45,25 @@ export function createApp() {
try {
const token = authHeader.slice("Bearer ".length);
const payload = verifyToken(token);
const session = await getActiveAuthSession(payload.sid, payload.sub);
if (!session) {
request.authUser = undefined;
request.authSessionId = undefined;
return next();
}
const authUser = await getCurrentUserById(payload.sub);
request.authUser = authUser ?? undefined;
if (!authUser) {
request.authUser = undefined;
request.authSessionId = undefined;
return next();
}
request.authUser = authUser;
request.authSessionId = session.id;
void touchAuthSession(session.id).catch(() => undefined);
} catch {
request.authUser = undefined;
request.authSessionId = undefined;
}
next();

View File

@@ -0,0 +1,71 @@
import { prisma } from "./prisma.js";
const SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
export interface AuthSessionContext {
id: string;
userId: string;
expiresAt: Date;
}
export function getSessionExpiryDate(now = new Date()) {
return new Date(now.getTime() + SESSION_DURATION_MS);
}
export async function createAuthSession(input: { userId: string; ipAddress?: string | null; userAgent?: string | null }) {
return prisma.authSession.create({
data: {
userId: input.userId,
expiresAt: getSessionExpiryDate(),
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
},
});
}
export async function getActiveAuthSession(sessionId: string, userId: string): Promise<AuthSessionContext | null> {
const session = await prisma.authSession.findFirst({
where: {
id: sessionId,
userId,
revokedAt: null,
expiresAt: {
gt: new Date(),
},
},
select: {
id: true,
userId: true,
expiresAt: true,
},
});
if (!session) {
return null;
}
return session;
}
export async function touchAuthSession(sessionId: string) {
await prisma.authSession.update({
where: { id: sessionId },
data: {
lastSeenAt: new Date(),
},
});
}
export async function revokeAuthSession(sessionId: string, input: { revokedById?: string | null; reason: string }) {
return prisma.authSession.updateMany({
where: {
id: sessionId,
revokedAt: null,
},
data: {
revokedAt: new Date(),
revokedById: input.revokedById ?? null,
revokedReason: input.reason,
},
});
}

View File

@@ -5,14 +5,16 @@ import { env } from "../config/env.js";
interface AuthTokenPayload {
sub: string;
sid: string;
email: string;
permissions: string[];
}
export function signToken(user: AuthUser) {
export function signToken(user: AuthUser, sessionId: string) {
return jwt.sign(
{
sub: user.id,
sid: sessionId,
email: user.email,
permissions: user.permissions,
} satisfies AuthTokenPayload,
@@ -24,4 +26,3 @@ export function signToken(user: AuthUser) {
export function verifyToken(token: string) {
return jwt.verify(token, env.JWT_SECRET) as AuthTokenPayload;
}

View File

@@ -26,6 +26,10 @@ export async function getCurrentUserById(userId: string): Promise<AuthUser | nul
return null;
}
if (!user.isActive) {
return null;
}
const permissionKeys = new Set<PermissionKey>();
const roleNames = user.userRoles.map(({ role }) => {
for (const rolePermission of role.rolePermissions) {
@@ -44,4 +48,3 @@ export async function getCurrentUserById(userId: string): Promise<AuthUser | nul
permissions: [...permissionKeys],
};
}

View File

@@ -6,6 +6,7 @@ import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createAdminRole,
listAdminAuthSessions,
createAdminUser,
getBackupGuidance,
getAdminDiagnostics,
@@ -14,6 +15,7 @@ import {
listAdminPermissions,
listAdminRoles,
listAdminUsers,
revokeAdminAuthSession,
updateAdminRole,
updateAdminUser,
} from "./service.js";
@@ -100,6 +102,24 @@ adminRouter.get("/users", requirePermissions([permissions.adminManage]), async (
return ok(response, await listAdminUsers());
});
adminRouter.get("/sessions", requirePermissions([permissions.adminManage]), async (request, response) => {
return ok(response, await listAdminAuthSessions(request.authSessionId));
});
adminRouter.post("/sessions/:sessionId/revoke", requirePermissions([permissions.adminManage]), async (request, response) => {
const sessionId = getRouteParam(request.params.sessionId);
if (!sessionId) {
return fail(response, 400, "INVALID_INPUT", "Session id is invalid.");
}
const result = await revokeAdminAuthSession(sessionId, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, { success: true as const });
});
adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = userSchema.safeParse(request.body);
if (!parsed.success) {

View File

@@ -1,5 +1,6 @@
import type {
AdminDiagnosticsDto,
AdminAuthSessionDto,
BackupGuidanceDto,
AdminPermissionOptionDto,
AdminRoleDto,
@@ -124,6 +125,50 @@ function mapUser(record: {
};
}
function mapAuthSession(
record: {
id: string;
userId: string;
expiresAt: Date;
lastSeenAt: Date;
revokedAt: Date | null;
revokedReason: string | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: Date;
user: {
email: string;
firstName: string;
lastName: string;
};
revokedBy: {
firstName: string;
lastName: string;
} | null;
},
currentSessionId?: string
): AdminAuthSessionDto {
const now = Date.now();
const status = record.revokedAt ? "REVOKED" : record.expiresAt.getTime() <= now ? "EXPIRED" : "ACTIVE";
return {
id: record.id,
userId: record.userId,
userEmail: record.user.email,
userName: `${record.user.firstName} ${record.user.lastName}`.trim(),
status,
isCurrent: record.id === currentSessionId,
createdAt: record.createdAt.toISOString(),
lastSeenAt: record.lastSeenAt.toISOString(),
expiresAt: record.expiresAt.toISOString(),
revokedAt: record.revokedAt?.toISOString() ?? null,
revokedReason: record.revokedReason,
revokedByName: record.revokedBy ? `${record.revokedBy.firstName} ${record.revokedBy.lastName}`.trim() : null,
ipAddress: record.ipAddress,
userAgent: record.userAgent,
};
}
async function validatePermissionKeys(permissionKeys: string[]) {
const uniquePermissionKeys = [...new Set(permissionKeys)];
const permissions = await prisma.permission.findMany({
@@ -338,6 +383,76 @@ export async function listAdminUsers(): Promise<AdminUserDto[]> {
return users.map(mapUser);
}
export async function listAdminAuthSessions(currentSessionId?: string | null): Promise<AdminAuthSessionDto[]> {
const sessions = await prisma.authSession.findMany({
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
revokedBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revokedAt: "asc" }, { lastSeenAt: "desc" }, { createdAt: "desc" }],
take: 200,
});
return sessions.map((session) => mapAuthSession(session, currentSessionId ?? undefined));
}
export async function revokeAdminAuthSession(sessionId: string, actorId?: string | null) {
const existingSession = await prisma.authSession.findUnique({
where: { id: sessionId },
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
});
if (!existingSession) {
return { ok: false as const, reason: "Session was not found." };
}
if (existingSession.revokedAt) {
return { ok: false as const, reason: "Session is already revoked." };
}
await prisma.authSession.update({
where: { id: sessionId },
data: {
revokedAt: new Date(),
revokedById: actorId ?? null,
revokedReason: "Revoked by administrator.",
},
});
await logAuditEvent({
actorId,
entityType: "auth-session",
entityId: existingSession.id,
action: "revoked",
summary: `Revoked session for ${existingSession.user.email}.`,
metadata: {
userId: existingSession.userId,
userEmail: existingSession.user.email,
},
});
return { ok: true as const };
}
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
if (!payload.password || payload.password.trim().length < 8) {
return { ok: false as const, reason: "A password with at least 8 characters is required for new users." };
@@ -485,6 +600,7 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
companyProfile,
userCount,
activeUserCount,
activeSessionCount,
roleCount,
permissionCount,
customerCount,
@@ -504,6 +620,14 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }),
prisma.user.count(),
prisma.user.count({ where: { isActive: true } }),
prisma.authSession.count({
where: {
revokedAt: null,
expiresAt: {
gt: new Date(),
},
},
}),
prisma.role.count(),
prisma.permission.count(),
prisma.customer.count(),
@@ -542,6 +666,7 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
companyProfilePresent: Boolean(companyProfile),
userCount,
activeUserCount,
activeSessionCount,
roleCount,
permissionCount,
customerCount,

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requireAuth } from "../../lib/rbac.js";
import { login } from "./service.js";
import { login, logout } from "./service.js";
const loginSchema = z.object({
email: z.string().email(),
@@ -18,7 +18,10 @@ authRouter.post("/login", async (request, response) => {
return fail(response, 400, "INVALID_INPUT", "Please provide a valid email and password.");
}
const result = await login(parsed.data);
const result = await login(parsed.data, {
ipAddress: request.ip,
userAgent: request.header("user-agent"),
});
if (!result) {
return fail(response, 401, "INVALID_CREDENTIALS", "Email or password is incorrect.");
}
@@ -28,3 +31,11 @@ authRouter.post("/login", async (request, response) => {
authRouter.get("/me", requireAuth, async (request, response) => ok(response, request.authUser));
authRouter.post("/logout", requireAuth, async (request, response) => {
if (!request.authSessionId || !request.authUser) {
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
}
await logout(request.authSessionId, request.authUser.id);
return ok(response, { success: true as const });
});

View File

@@ -1,11 +1,18 @@
import type { LoginRequest, LoginResponse } from "@mrp/shared";
import { signToken } from "../../lib/auth.js";
import { createAuthSession, revokeAuthSession } from "../../lib/auth-sessions.js";
import { getCurrentUserById } from "../../lib/current-user.js";
import { verifyPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js";
export async function login(payload: LoginRequest): Promise<LoginResponse | null> {
export async function login(
payload: LoginRequest,
context?: {
ipAddress?: string | null;
userAgent?: string | null;
}
): Promise<LoginResponse | null> {
const user = await prisma.user.findUnique({
where: { email: payload.email.toLowerCase() },
});
@@ -23,9 +30,21 @@ export async function login(payload: LoginRequest): Promise<LoginResponse | null
return null;
}
const session = await createAuthSession({
userId: user.id,
ipAddress: context?.ipAddress ?? null,
userAgent: context?.userAgent ?? null,
});
return {
token: signToken(authUser),
token: signToken(authUser, session.id),
user: authUser,
};
}
export async function logout(sessionId: string, actorId?: string | null) {
await revokeAuthSession(sessionId, {
revokedById: actorId ?? null,
reason: actorId ? "User signed out." : "Session signed out.",
});
}

View File

@@ -4,9 +4,9 @@ declare global {
namespace Express {
interface Request {
authUser?: AuthUser;
authSessionId?: string;
}
}
}
export {};