128 lines
3.2 KiB
TypeScript
128 lines
3.2 KiB
TypeScript
// app/services/csrf-token.ts
|
|
// Copyright (C) 2026 DTP Technologies, LLC
|
|
// All Rights Reserved
|
|
|
|
import { Request, Response, NextFunction, RequestHandler } from "express";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import dayjs from "dayjs";
|
|
|
|
import CsrfToken, { ICsrfToken } from "../models/csrf-token.js";
|
|
|
|
import { DtpService } from "../lib/service.js";
|
|
|
|
export interface CsrfTokenOptions {
|
|
name: string;
|
|
expiresMinutes: number;
|
|
allowReuse: boolean;
|
|
}
|
|
|
|
export class CsrfTokenService extends DtpService {
|
|
get name(): string {
|
|
return "CsrfTokenService";
|
|
}
|
|
get slug(): string {
|
|
return "csrfToken";
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.log.info("service started");
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.log.info("service stopped");
|
|
}
|
|
|
|
middleware(options: CsrfTokenOptions): RequestHandler {
|
|
return async (
|
|
req: Request,
|
|
_res: Response,
|
|
next: NextFunction,
|
|
): Promise<void> => {
|
|
const requestToken = req.body[`csrf-token-${options.name}`];
|
|
if (!requestToken) {
|
|
this.log.error("missing CSRF token", { name: options.name });
|
|
const error = new Error("Must include valid CSRF token");
|
|
error.statusCode = 401;
|
|
return next(error);
|
|
}
|
|
|
|
const token = await CsrfToken.findOne({ token: requestToken });
|
|
if (!token) {
|
|
const error = new Error("CSRF request token is invalid");
|
|
error.statusCode = 401;
|
|
return next(error);
|
|
}
|
|
if (token.ip !== req.ip) {
|
|
const error = new Error("CSRF request token client mismatch");
|
|
error.statusCode = 401;
|
|
return next(error);
|
|
}
|
|
if (token.claimedAt && !options.allowReuse) {
|
|
const error = new Error(
|
|
"Your request can't be accepted. Please refresh the page and try again.",
|
|
);
|
|
error.statusCode = 401;
|
|
return next(error);
|
|
}
|
|
|
|
if (token.user) {
|
|
if (!req.user) {
|
|
const error = new Error("Must be logged in");
|
|
error.statusCode = 401;
|
|
return next(error);
|
|
}
|
|
if (!token.user._id.equals(req.user._id)) {
|
|
const error = new Error("CSRF request token user mismatch");
|
|
error.statusCode = 401;
|
|
return next(error);
|
|
}
|
|
}
|
|
|
|
await CsrfToken.updateOne(
|
|
{ _id: token._id },
|
|
{ $set: { claimed: new Date() } },
|
|
);
|
|
|
|
return next();
|
|
};
|
|
}
|
|
|
|
async create(req: Request, options: CsrfTokenOptions): Promise<ICsrfToken> {
|
|
const NOW = new Date();
|
|
|
|
options = Object.assign(
|
|
{
|
|
expiresMinutes: 30,
|
|
},
|
|
options,
|
|
);
|
|
|
|
if (options.expiresMinutes > 120) {
|
|
const error = new Error("CSRF tokens have a max lifespan of 120 minutes");
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const token = new CsrfToken();
|
|
token.name = `csrf-token-${options.name}`;
|
|
token.createdAt = NOW;
|
|
token.expiresAt = dayjs(NOW).add(options.expiresMinutes, "minute").toDate();
|
|
if (req.user) {
|
|
token.user = req.user._id;
|
|
}
|
|
if (req.ip) {
|
|
token.ip = req.ip;
|
|
}
|
|
token.token = uuidv4();
|
|
await token.save();
|
|
|
|
return token.toObject();
|
|
}
|
|
}
|
|
|
|
export default new CsrfTokenService();
|