feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
2ff08e5127
commit
ed7db6e048
76 files changed
+4834
-1376
No files matched your search
+60
-24
@@ -1,20 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { env } from "@/env";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Stateless email verification, AtomCMS-faithful but DB-table-free.
|
||||
// Stateless email verification with a time-limited HMAC token.
|
||||
//
|
||||
// Instead of persisting a row (password_resets style), the token is a keyed
|
||||
// digest of the email address: sha256(email + APP_KEY). Because APP_KEY is a
|
||||
// server-only secret, an attacker who only knows the email cannot forge a
|
||||
// matching token, and /verify can recompute + compare it without any storage.
|
||||
// The token is therefore deterministic per (email, secret) pair and stays valid
|
||||
// until the account's mail_verified flips to '1' (after which /verify no-ops).
|
||||
// Token format: `{issuedAtUnix}.{hmacHex}` where
|
||||
// hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`)
|
||||
// Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests
|
||||
// (bare 64-char hex) are rejected.
|
||||
|
||||
/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
|
||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Secret mixed into the HMAC. Requires at least one of APP_KEY or AUTH_SECRET. */
|
||||
function verifySecret(): string {
|
||||
const secret = env.APP_KEY || env.AUTH_SECRET;
|
||||
if (!secret)
|
||||
@@ -24,26 +25,43 @@ function verifySecret(): string {
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** Compute the verification token for an email (lowercased + trimmed). */
|
||||
export async function verificationToken(email: string): Promise<string> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
return createHash("sha256")
|
||||
.update(`${normalised}|${verifySecret()}`)
|
||||
function sign(email: string, issuedAt: number): string {
|
||||
return createHmac("sha256", verifySecret())
|
||||
.update(`${email}|${issuedAt}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** Compute a fresh verification token for an email (lowercased + trimmed). */
|
||||
export async function verificationToken(email: string): Promise<string> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
return `${issuedAt}.${sign(normalised, issuedAt)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time check that `token` matches the expected digest for `email`.
|
||||
* Returns false on any length/format mismatch rather than throwing.
|
||||
* Constant-time check that `token` matches a non-expired HMAC for `email`.
|
||||
* Returns false on format/expiry/signature mismatch rather than throwing.
|
||||
*/
|
||||
export async function isValidVerificationToken(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
|
||||
const expected = await verificationToken(email);
|
||||
if (!email || !token) return false;
|
||||
const normalised = email.trim().toLowerCase();
|
||||
|
||||
const match = /^(\d+)\.([a-f0-9]{64})$/i.exec(token.trim());
|
||||
if (!match) return false; // also rejects legacy forever-valid digests
|
||||
|
||||
const issuedAt = Number(match[1]);
|
||||
const sig = match[2]?.toLowerCase() ?? "";
|
||||
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return false;
|
||||
|
||||
const ageMs = Date.now() - issuedAt * 1000;
|
||||
if (ageMs < 0 || ageMs > TOKEN_TTL_MS) return false;
|
||||
|
||||
const expected = sign(normalised, issuedAt);
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(token.toLowerCase(), "utf8");
|
||||
const b = Buffer.from(sig, "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
@@ -63,22 +81,40 @@ export async function sendVerification(email: string): Promise<boolean> {
|
||||
const hotelName =
|
||||
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
||||
|
||||
let subject = `Verify your email · ${hotelName}`;
|
||||
let heading = "Verify your email";
|
||||
let body = `Welcome to ${hotelName}! Confirm this email address to finish setting up your account.`;
|
||||
let button = "Verify email";
|
||||
let fallback =
|
||||
"If the button doesn't work, paste this link into your browser:";
|
||||
|
||||
try {
|
||||
const t = await getTranslations("emails.verify");
|
||||
subject = t("subject", { hotel: hotelName });
|
||||
heading = t("heading");
|
||||
body = t("body", { hotel: hotelName });
|
||||
button = t("button");
|
||||
fallback = t("fallback");
|
||||
} catch {
|
||||
/* messages missing — keep English defaults */
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
|
||||
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
|
||||
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
|
||||
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
|
||||
<p>${escapeHtml(body)}</p>
|
||||
<p style="margin:1.25rem 0">
|
||||
<a href="${link}"
|
||||
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
|
||||
Verify email
|
||||
${escapeHtml(button)}
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
|
||||
<p style="color:#64748b;font-size:0.875rem">${escapeHtml(fallback)}</p>
|
||||
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
||||
return sendMail(normalised, subject, html);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
|
||||
Reference in new issue
Block a user