confirm actions
This commit is contained in:
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
71
server/src/lib/auth-sessions.ts
Normal file
71
server/src/lib/auth-sessions.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
|
||||
2
server/src/types/express.d.ts
vendored
2
server/src/types/express.d.ts
vendored
@@ -4,9 +4,9 @@ declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
authUser?: AuthUser;
|
||||
authSessionId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user