822c0b7c0b
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
56 lines
2.2 KiB
TypeScript
56 lines
2.2 KiB
TypeScript
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
|
|
/* Password reset tokens.
|
|
*
|
|
* The raw token is shown to exactly one person, once, in one email, and is never stored: the
|
|
* database keeps only its SHA-256. That matters because this table lands in every pg_dump, and a
|
|
* plaintext token in a leaked backup is a working key to an account until it expires.
|
|
*
|
|
* SHA-256 rather than bcrypt is the right call here, unusually: the token is 32 bytes of CSPRNG
|
|
* output, so there is no dictionary to attack and no need to be slow — and a reset lookup happens
|
|
* before the user is authenticated, where a deliberately slow hash is a denial-of-service lever. */
|
|
|
|
/** One hour. Long enough to walk back to a desk, short enough that a forwarded email goes stale. */
|
|
export const RESET_TTL_MS = 60 * 60 * 1000;
|
|
|
|
export function newResetToken() {
|
|
const token = randomBytes(32).toString("base64url");
|
|
return { token, tokenHash: hashResetToken(token) };
|
|
}
|
|
|
|
export function hashResetToken(token: string) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
/** Constant-time compare, so a mismatched token can't be found a character at a time. */
|
|
export function tokenMatches(a: string, b: string) {
|
|
const ab = Buffer.from(a, "utf8");
|
|
const bb = Buffer.from(b, "utf8");
|
|
if (ab.length !== bb.length) return false;
|
|
return timingSafeEqual(ab, bb);
|
|
}
|
|
|
|
/** The link a person clicks. Absolute, because it is going into an email client. */
|
|
export function resetUrl(token: string) {
|
|
const base = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
|
|
return `${base}/reset?token=${encodeURIComponent(token)}`;
|
|
}
|
|
|
|
export function resetEmail(firstName: string, url: string) {
|
|
const subject = "Reset your ThreadCount password";
|
|
const text = [
|
|
`Hi ${firstName || "there"},`,
|
|
"",
|
|
"Someone asked to reset the password on your ThreadCount account. If that was you, open this link:",
|
|
"",
|
|
url,
|
|
"",
|
|
"The link works once and expires in an hour.",
|
|
"",
|
|
"If it wasn't you, you can ignore this — your password hasn't changed, and nobody can get in without this link.",
|
|
"",
|
|
"— ThreadCount",
|
|
].join("\n");
|
|
return { subject, text };
|
|
}
|