ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 5470a36 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user