299 lines
8.4 KiB
TypeScript
299 lines
8.4 KiB
TypeScript
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
|
|
import type { Application, Request, Response } from "express";
|
|
import { Router } from "express";
|
|
import { rateLimit } from "express-rate-limit";
|
|
import type { z } from "zod/v4";
|
|
|
|
import type { SessionResponseDTO } from "../../../common";
|
|
import {
|
|
BootstrapAdminRequestSchema,
|
|
ChangePasswordRequestSchema,
|
|
CreateUserRequestSchema,
|
|
LoginRequestSchema,
|
|
ResetPasswordRequestSchema,
|
|
UpdateUserRequestSchema,
|
|
UserParamsRequestSchema,
|
|
} from "../../../common";
|
|
import type { IdentityUseCases } from "../../application/identity.use-cases";
|
|
import { buildUserResponse } from "../../application/user-response.builder";
|
|
import type { IdentityError } from "../../domain/identity-error";
|
|
|
|
import type { AuthenticatedSession } from "./session-types";
|
|
|
|
function sendError(response: Response, code: string, status: number): void {
|
|
response
|
|
.status(status)
|
|
.json({ code, requestId: response.getHeader("x-request-id")?.toString() ?? randomUUID() });
|
|
}
|
|
|
|
function businessError(response: Response, error: IdentityError): void {
|
|
const statuses = {
|
|
INVALID_REQUEST: 400,
|
|
INVALID_CREDENTIALS: 401,
|
|
UNAUTHENTICATED: 401,
|
|
FORBIDDEN: 403,
|
|
SETUP_CLOSED: 409,
|
|
EMAIL_EXISTS: 409,
|
|
USER_NOT_FOUND: 404,
|
|
ADMIN_PROTECTED: 409,
|
|
};
|
|
|
|
sendError(response, error.code, statuses[error.code]);
|
|
}
|
|
|
|
function getContext(request: Request): AuthenticatedSession | undefined {
|
|
const { userId, authVersion } = request.session;
|
|
|
|
return userId && authVersion !== undefined ? { userId, authVersion } : undefined;
|
|
}
|
|
|
|
function saveSession(request: Request): Promise<void> {
|
|
return new Promise((resolve, reject) =>
|
|
request.session.save((error) => (error ? reject(error) : resolve()))
|
|
);
|
|
}
|
|
|
|
async function signIn(request: Request, userId: string, authVersion: number): Promise<void> {
|
|
await new Promise<void>((resolve, reject) =>
|
|
request.session.regenerate((error) => (error ? reject(error) : resolve()))
|
|
);
|
|
request.session.userId = userId;
|
|
request.session.authVersion = authVersion;
|
|
request.session.csrfToken = randomBytes(32).toString("hex");
|
|
await saveSession(request);
|
|
}
|
|
|
|
export function registerIdentityRoutes(
|
|
app: Application,
|
|
useCases: IdentityUseCases,
|
|
origin: string
|
|
): void {
|
|
const router = Router();
|
|
|
|
router.get("/auth/session", (request, response, next) => {
|
|
const handle = async () => {
|
|
request.session.csrfToken ??= randomBytes(32).toString("hex");
|
|
const user = await useCases.session(getContext(request));
|
|
|
|
if (!user) {
|
|
request.session.userId = undefined;
|
|
request.session.authVersion = undefined;
|
|
}
|
|
const snapshot: SessionResponseDTO = {
|
|
user: user ? buildUserResponse(user) : null,
|
|
setupRequired: await useCases.setupRequired(),
|
|
csrfToken: request.session.csrfToken,
|
|
};
|
|
|
|
await saveSession(request);
|
|
response.json(snapshot);
|
|
};
|
|
|
|
handle().catch(next);
|
|
});
|
|
router.use((request, response, next) => {
|
|
if (request.method === "GET") {
|
|
next();
|
|
|
|
return;
|
|
}
|
|
const token = request.get("x-csrf-token");
|
|
const expected = request.session.csrfToken;
|
|
|
|
if (
|
|
request.get("origin") !== origin ||
|
|
!token ||
|
|
!expected ||
|
|
!/^[a-f0-9]{64}$/.test(token) ||
|
|
!timingSafeEqual(Buffer.from(token), Buffer.from(expected))
|
|
) {
|
|
sendError(response, "FORBIDDEN", 403);
|
|
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
const authLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
limit: 10,
|
|
standardHeaders: "draft-8",
|
|
legacyHeaders: false,
|
|
skipSuccessfulRequests: true,
|
|
handler: (_request, response) => sendError(response, "TOO_MANY_REQUESTS", 429),
|
|
});
|
|
|
|
router.use(["/auth/login", "/auth/bootstrap"], authLimiter);
|
|
|
|
const registerBody = <T>(
|
|
path: string,
|
|
schema: z.ZodType<T>,
|
|
execute: (dto: T, request: Request, response: Response) => Promise<void>
|
|
) => {
|
|
router.post(path, (request, response, next) => {
|
|
const parsed = schema.safeParse(request.body);
|
|
|
|
if (!parsed.success) {
|
|
sendError(response, "INVALID_REQUEST", 400);
|
|
|
|
return;
|
|
}
|
|
execute(parsed.data, request, response).catch(next);
|
|
});
|
|
};
|
|
|
|
registerBody("/auth/bootstrap", BootstrapAdminRequestSchema, async (dto, request, response) => {
|
|
const result = await useCases.bootstrap(dto);
|
|
|
|
if (!result.isSuccess) {
|
|
businessError(response, result.error);
|
|
|
|
return;
|
|
}
|
|
await signIn(request, result.data.id.toPrimitive(), result.data.authVersion);
|
|
response.status(201).json({ user: buildUserResponse(result.data) });
|
|
});
|
|
registerBody("/auth/login", LoginRequestSchema, async (dto, request, response) => {
|
|
const result = await useCases.login(dto);
|
|
|
|
if (!result.isSuccess) {
|
|
businessError(response, result.error);
|
|
|
|
return;
|
|
}
|
|
await signIn(request, result.data.id.toPrimitive(), result.data.authVersion);
|
|
response.json({ user: buildUserResponse(result.data) });
|
|
});
|
|
registerBody(
|
|
"/auth/logout",
|
|
BootstrapAdminRequestSchema.pick({}),
|
|
async (_dto, request, response) => {
|
|
await new Promise<void>((resolve, reject) =>
|
|
request.session.destroy((error) => (error ? reject(error) : resolve()))
|
|
);
|
|
response.clearCookie("metis.sid", { httpOnly: true, sameSite: "strict", path: "/" });
|
|
response.json({ success: true });
|
|
}
|
|
);
|
|
registerBody(
|
|
"/auth/change-password",
|
|
ChangePasswordRequestSchema,
|
|
async (dto, request, response) => {
|
|
const context = getContext(request);
|
|
|
|
if (!context) {
|
|
sendError(response, "UNAUTHENTICATED", 401);
|
|
|
|
return;
|
|
}
|
|
const result = await useCases.changePassword(dto, context);
|
|
|
|
if (!result.isSuccess) {
|
|
businessError(response, result.error);
|
|
|
|
return;
|
|
}
|
|
await signIn(request, result.data.id.toPrimitive(), result.data.authVersion);
|
|
response.json({ user: buildUserResponse(result.data) });
|
|
}
|
|
);
|
|
router.use("/users", (request, response, next) => {
|
|
const handle = async () => {
|
|
const user = await useCases.session(getContext(request));
|
|
|
|
if (!user) {
|
|
sendError(response, "UNAUTHENTICATED", 401);
|
|
|
|
return;
|
|
}
|
|
if (!user.isInitialAdmin) {
|
|
sendError(response, "FORBIDDEN", 403);
|
|
|
|
return;
|
|
}
|
|
next();
|
|
};
|
|
|
|
handle().catch(next);
|
|
});
|
|
router.get("/users", (request, response, next) => {
|
|
const context = getContext(request);
|
|
|
|
if (!context) {
|
|
sendError(response, "UNAUTHENTICATED", 401);
|
|
|
|
return;
|
|
}
|
|
useCases
|
|
.list(context)
|
|
.then((result) => {
|
|
if (!result.isSuccess) {
|
|
businessError(response, result.error);
|
|
|
|
return;
|
|
}
|
|
response.json({ users: result.data.map(buildUserResponse) });
|
|
})
|
|
.catch(next);
|
|
});
|
|
registerBody("/users", CreateUserRequestSchema, async (dto, request, response) => {
|
|
const context = getContext(request);
|
|
|
|
if (!context) {
|
|
sendError(response, "UNAUTHENTICATED", 401);
|
|
|
|
return;
|
|
}
|
|
const result = await useCases.create(dto, context);
|
|
|
|
if (!result.isSuccess) {
|
|
businessError(response, result.error);
|
|
|
|
return;
|
|
}
|
|
response.status(201).json({ user: buildUserResponse(result.data) });
|
|
});
|
|
registerBody("/users/:id/update", UpdateUserRequestSchema, async (dto, request, response) => {
|
|
const context = getContext(request);
|
|
const params = UserParamsRequestSchema.safeParse(request.params);
|
|
|
|
if (!(context && params.success)) {
|
|
sendError(response, "INVALID_REQUEST", 400);
|
|
|
|
return;
|
|
}
|
|
const result = await useCases.update(params.data.id, dto, context);
|
|
|
|
if (!result.isSuccess) {
|
|
businessError(response, result.error);
|
|
|
|
return;
|
|
}
|
|
response.json({ user: buildUserResponse(result.data) });
|
|
});
|
|
|
|
registerBody(
|
|
"/users/:id/reset-password",
|
|
ResetPasswordRequestSchema,
|
|
async (dto, request, response) => {
|
|
const context = getContext(request);
|
|
const params = UserParamsRequestSchema.safeParse(request.params);
|
|
|
|
if (!(context && params.success)) {
|
|
sendError(response, "INVALID_REQUEST", 400);
|
|
|
|
return;
|
|
}
|
|
const result = await useCases.resetPassword(params.data.id, dto, context);
|
|
|
|
if (!result.isSuccess) {
|
|
businessError(response, result.error);
|
|
|
|
return;
|
|
}
|
|
response.json({ user: buildUserResponse(result.data) });
|
|
}
|
|
);
|
|
app.use("/api", router);
|
|
}
|