feat(auth): implement Resend email quota and rate limit protection
Track Resend API usage via response headers to prevent quota exhaustion Block auth requests with 503 when email delivery limits are reached
This commit is contained in:
@@ -11,6 +11,10 @@ VITE_API_BASE_URL=http://localhost:20016
|
|||||||
VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
||||||
|
RESEND_DAILY_QUOTA_LIMIT=100
|
||||||
|
RESEND_MONTHLY_QUOTA_LIMIT=3000
|
||||||
|
RESEND_QUOTA_RESERVE=5
|
||||||
|
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
||||||
AI_MODERATION_API_KEY=
|
AI_MODERATION_API_KEY=
|
||||||
|
|
||||||
# Cloudflared tunnel deployment example:
|
# Cloudflared tunnel deployment example:
|
||||||
|
|||||||
@@ -102,6 +102,8 @@
|
|||||||
- `EMAIL_FROM`
|
- `EMAIL_FROM`
|
||||||
- `APP_ORIGIN` 或 `FRONTEND_ORIGIN`
|
- `APP_ORIGIN` 或 `FRONTEND_ORIGIN`
|
||||||
- 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。
|
- 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。
|
||||||
|
- 后端从 Resend 邮件发送响应 headers 读取日/月发送额度和 rate limit 状态,并维护短期内存 snapshot;当 Resend 已报告额度接近用尽、额度耗尽或 API 限流时,认证邮件发送会暂时停止并返回本地化用户提示。
|
||||||
|
- Resend 额度保护不使用本项目自增发送计数;默认按 Free 计划 `100/day`、`3000/month` 和 5 封保留量判断,可通过 `RESEND_DAILY_QUOTA_LIMIT`、`RESEND_MONTHLY_QUOTA_LIMIT`、`RESEND_QUOTA_RESERVE`、`RESEND_QUOTA_SNAPSHOT_TTL_MINUTES` 调整。
|
||||||
- 验证邮件包含一次性验证链接。
|
- 验证邮件包含一次性验证链接。
|
||||||
- 验证 token 只保存 hash,并带过期时间和使用状态。
|
- 验证 token 只保存 hash,并带过期时间和使用状态。
|
||||||
- 只有邮箱已验证的用户可以登录。
|
- 只有邮箱已验证的用户可以登录。
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const defaultLocale = 'en';
|
|||||||
const referralCodeLength = 8;
|
const referralCodeLength = 8;
|
||||||
const referralAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
const referralAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
const referralCodePattern = /^[A-Z0-9]{8,16}$/;
|
const referralCodePattern = /^[A-Z0-9]{8,16}$/;
|
||||||
|
const resendDailyQuotaLimit = positiveIntegerEnv('RESEND_DAILY_QUOTA_LIMIT', 100);
|
||||||
|
const resendMonthlyQuotaLimit = positiveIntegerEnv('RESEND_MONTHLY_QUOTA_LIMIT', 3000);
|
||||||
|
const resendQuotaReserve = nonNegativeIntegerEnv('RESEND_QUOTA_RESERVE', 5);
|
||||||
|
const resendQuotaSnapshotTtlMs = positiveIntegerEnv('RESEND_QUOTA_SNAPSHOT_TTL_MINUTES', 10) * 60 * 1000;
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
|
|
||||||
@@ -65,6 +69,7 @@ type AuthMessageKey =
|
|||||||
| 'emailKicker'
|
| 'emailKicker'
|
||||||
| 'emailLinkFallback'
|
| 'emailLinkFallback'
|
||||||
| 'emailFooter'
|
| 'emailFooter'
|
||||||
|
| 'emailDeliveryUnavailable'
|
||||||
| 'verificationActionLabel'
|
| 'verificationActionLabel'
|
||||||
| 'passwordResetSubject'
|
| 'passwordResetSubject'
|
||||||
| 'passwordResetHtml'
|
| 'passwordResetHtml'
|
||||||
@@ -162,6 +167,30 @@ const criticalPermissionKeys = [
|
|||||||
'admin.permissions.delete'
|
'admin.permissions.delete'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type ResendBlockReason = 'quota' | 'rateLimit';
|
||||||
|
|
||||||
|
type ResendQuotaSnapshot = {
|
||||||
|
dailyUsed?: number;
|
||||||
|
monthlyUsed?: number;
|
||||||
|
rateLimitRemaining?: number;
|
||||||
|
rateLimitResetAt?: number;
|
||||||
|
blockedUntil?: number;
|
||||||
|
blockedReason?: ResendBlockReason;
|
||||||
|
updatedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resendQuotaSnapshot: ResendQuotaSnapshot = {};
|
||||||
|
|
||||||
|
function positiveIntegerEnv(key: string, fallback: number): number {
|
||||||
|
const value = Number(process.env[key]);
|
||||||
|
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeIntegerEnv(key: string, fallback: number): number {
|
||||||
|
const value = Number(process.env[key]);
|
||||||
|
return Number.isInteger(value) && value >= 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function statusError(message: string, statusCode: number): StatusError {
|
function statusError(message: string, statusCode: number): StatusError {
|
||||||
const error = new Error(message) as StatusError;
|
const error = new Error(message) as StatusError;
|
||||||
error.statusCode = statusCode;
|
error.statusCode = statusCode;
|
||||||
@@ -193,6 +222,7 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record<string,
|
|||||||
emailKicker: 'email.auth.kicker',
|
emailKicker: 'email.auth.kicker',
|
||||||
emailLinkFallback: 'email.auth.linkFallback',
|
emailLinkFallback: 'email.auth.linkFallback',
|
||||||
emailFooter: 'email.auth.footer',
|
emailFooter: 'email.auth.footer',
|
||||||
|
emailDeliveryUnavailable: 'server.auth.emailDeliveryUnavailable',
|
||||||
verificationActionLabel: 'email.auth.verificationActionLabel',
|
verificationActionLabel: 'email.auth.verificationActionLabel',
|
||||||
passwordResetSubject: 'email.auth.passwordResetSubject',
|
passwordResetSubject: 'email.auth.passwordResetSubject',
|
||||||
passwordResetHtml: 'email.auth.passwordResetHtml',
|
passwordResetHtml: 'email.auth.passwordResetHtml',
|
||||||
@@ -708,6 +738,158 @@ function buildPasswordResetUrl(token: string): string {
|
|||||||
return buildTokenUrl('/reset-password', token);
|
return buildTokenUrl('/reset-password', token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quotaThreshold(limit: number): number {
|
||||||
|
const reserve = Math.min(resendQuotaReserve, Math.max(0, limit - 1));
|
||||||
|
return limit - reserve;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeaderInteger(headers: Headers, name: string): number | undefined {
|
||||||
|
const value = headers.get(name);
|
||||||
|
const match = value?.match(/\d+/);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number(match[0]);
|
||||||
|
return Number.isSafeInteger(parsedValue) && parsedValue >= 0 ? parsedValue : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRetryAfterMs(headers: Headers, now = Date.now()): number | undefined {
|
||||||
|
const value = headers.get('retry-after');
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Number(value);
|
||||||
|
if (Number.isFinite(seconds) && seconds > 0) {
|
||||||
|
return seconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAt = Date.parse(value);
|
||||||
|
return Number.isFinite(retryAt) && retryAt > now ? retryAt - now : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateResendQuotaSnapshot(headers: Headers): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const dailyUsed = parseHeaderInteger(headers, 'x-resend-daily-quota');
|
||||||
|
const monthlyUsed = parseHeaderInteger(headers, 'x-resend-monthly-quota');
|
||||||
|
const rateLimitRemaining = parseHeaderInteger(headers, 'ratelimit-remaining');
|
||||||
|
const rateLimitReset = parseHeaderInteger(headers, 'ratelimit-reset');
|
||||||
|
|
||||||
|
if (dailyUsed !== undefined) {
|
||||||
|
resendQuotaSnapshot.dailyUsed = dailyUsed;
|
||||||
|
} else {
|
||||||
|
delete resendQuotaSnapshot.dailyUsed;
|
||||||
|
}
|
||||||
|
if (monthlyUsed !== undefined) {
|
||||||
|
resendQuotaSnapshot.monthlyUsed = monthlyUsed;
|
||||||
|
} else {
|
||||||
|
delete resendQuotaSnapshot.monthlyUsed;
|
||||||
|
}
|
||||||
|
if (rateLimitRemaining !== undefined) {
|
||||||
|
resendQuotaSnapshot.rateLimitRemaining = rateLimitRemaining;
|
||||||
|
} else {
|
||||||
|
delete resendQuotaSnapshot.rateLimitRemaining;
|
||||||
|
}
|
||||||
|
if (rateLimitReset !== undefined) {
|
||||||
|
resendQuotaSnapshot.rateLimitResetAt = now + rateLimitReset * 1000;
|
||||||
|
} else {
|
||||||
|
delete resendQuotaSnapshot.rateLimitResetAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
resendQuotaSnapshot.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockResendEmail(reason: ResendBlockReason, headers: Headers): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const retryAfterMs = parseRetryAfterMs(headers, now);
|
||||||
|
resendQuotaSnapshot.blockedReason = reason;
|
||||||
|
resendQuotaSnapshot.blockedUntil = now + (retryAfterMs ?? resendQuotaSnapshotTtlMs);
|
||||||
|
resendQuotaSnapshot.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentResendBlockReason(now = Date.now()): ResendBlockReason | null {
|
||||||
|
if (resendQuotaSnapshot.blockedUntil && resendQuotaSnapshot.blockedUntil > now) {
|
||||||
|
return resendQuotaSnapshot.blockedReason ?? 'quota';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resendQuotaSnapshot.updatedAt || now - resendQuotaSnapshot.updatedAt > resendQuotaSnapshotTtlMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resendQuotaSnapshot.dailyUsed !== undefined &&
|
||||||
|
resendQuotaSnapshot.dailyUsed >= quotaThreshold(resendDailyQuotaLimit)
|
||||||
|
) {
|
||||||
|
return 'quota';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resendQuotaSnapshot.monthlyUsed !== undefined &&
|
||||||
|
resendQuotaSnapshot.monthlyUsed >= quotaThreshold(resendMonthlyQuotaLimit)
|
||||||
|
) {
|
||||||
|
return 'quota';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resendQuotaSnapshot.rateLimitRemaining !== undefined &&
|
||||||
|
resendQuotaSnapshot.rateLimitRemaining <= 0 &&
|
||||||
|
resendQuotaSnapshot.rateLimitResetAt !== undefined &&
|
||||||
|
resendQuotaSnapshot.rateLimitResetAt > now
|
||||||
|
) {
|
||||||
|
return 'rateLimit';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertResendEmailAvailable(locale: string): Promise<void> {
|
||||||
|
if (currentResendBlockReason()) {
|
||||||
|
throw statusError(await authMessage(locale, 'emailDeliveryUnavailable'), 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resendFailureReason(status: number, responseText: string): ResendBlockReason | null {
|
||||||
|
if (status !== 429) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /daily_quota_exceeded|monthly_quota_exceeded/i.test(responseText) ? 'quota' : 'rateLimit';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendResendEmail(
|
||||||
|
locale: string,
|
||||||
|
payload: { apiKey: string; from: string; to: string; subject: string; html: string; text: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await assertResendEmailAvailable(locale);
|
||||||
|
|
||||||
|
const response = await fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${payload.apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: payload.from,
|
||||||
|
to: [payload.to],
|
||||||
|
subject: payload.subject,
|
||||||
|
html: payload.html,
|
||||||
|
text: payload.text
|
||||||
|
})
|
||||||
|
});
|
||||||
|
updateResendQuotaSnapshot(response.headers);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseText = await response.text();
|
||||||
|
const blockReason = resendFailureReason(response.status, responseText);
|
||||||
|
if (blockReason) {
|
||||||
|
blockResendEmail(blockReason, response.headers);
|
||||||
|
throw statusError(await authMessage(locale, 'emailDeliveryUnavailable'), 503);
|
||||||
|
}
|
||||||
|
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
@@ -834,25 +1016,7 @@ async function sendVerificationEmail(email: string, token: string, locale: strin
|
|||||||
linkFallback,
|
linkFallback,
|
||||||
footer
|
footer
|
||||||
});
|
});
|
||||||
const response = await fetch('https://api.resend.com/emails', {
|
await sendResendEmail(locale, { apiKey, from, to: email, subject, html, text });
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from,
|
|
||||||
to: [email],
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
text
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const responseText = await response.text();
|
|
||||||
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPasswordResetEmail(email: string, token: string, locale: string): Promise<void> {
|
async function sendPasswordResetEmail(email: string, token: string, locale: string): Promise<void> {
|
||||||
@@ -876,25 +1040,7 @@ async function sendPasswordResetEmail(email: string, token: string, locale: stri
|
|||||||
linkFallback,
|
linkFallback,
|
||||||
footer
|
footer
|
||||||
});
|
});
|
||||||
const response = await fetch('https://api.resend.com/emails', {
|
await sendResendEmail(locale, { apiKey, from, to: email, subject, html, text });
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from,
|
|
||||||
to: [email],
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
text
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const responseText = await response.text();
|
|
||||||
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
@@ -902,6 +1048,7 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
|||||||
const displayName = await cleanDisplayName(payload.displayName, locale);
|
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||||
const password = await cleanPassword(payload.password, locale);
|
const password = await cleanPassword(payload.password, locale);
|
||||||
const referralCode = await cleanReferralCode(payload.referralCode, locale);
|
const referralCode = await cleanReferralCode(payload.referralCode, locale);
|
||||||
|
await assertResendEmailAvailable(locale);
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const verificationToken = createPlainToken();
|
const verificationToken = createPlainToken();
|
||||||
const verificationTokenHash = hashToken(verificationToken);
|
const verificationTokenHash = hashToken(verificationToken);
|
||||||
@@ -1015,6 +1162,7 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
|||||||
|
|
||||||
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
|
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const email = await cleanEmail(payload.email, locale);
|
const email = await cleanEmail(payload.email, locale);
|
||||||
|
await assertResendEmailAvailable(locale);
|
||||||
const user = await queryOne<UserRow>(
|
const user = await queryOne<UserRow>(
|
||||||
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
||||||
[email]
|
[email]
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ app.setErrorHandler(async (error, _request, reply) => {
|
|||||||
return reply.code(429).send({ message: await serverMessage(locale, 'rateLimited') });
|
return reply.code(429).send({ message: await serverMessage(locale, 'rateLimited') });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pgError.statusCode === 503) {
|
||||||
|
return reply.code(503).send({ message: await localizedStatusMessage(locale, pgError.message) });
|
||||||
|
}
|
||||||
|
|
||||||
if (pgError.statusCode && pgError.statusCode < 500) {
|
if (pgError.statusCode && pgError.statusCode < 500) {
|
||||||
return reply.code(pgError.statusCode).send({ message: await localizedStatusMessage(locale, pgError.message) });
|
return reply.code(pgError.statusCode).send({ message: await localizedStatusMessage(locale, pgError.message) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -998,7 +998,8 @@ export const systemWordingMessages = {
|
|||||||
verifyEmailFirst: 'Please complete email verification first',
|
verifyEmailFirst: 'Please complete email verification first',
|
||||||
invalidResetToken: 'The password reset link is invalid or expired',
|
invalidResetToken: 'The password reset link is invalid or expired',
|
||||||
currentPasswordInvalid: 'Current password is incorrect',
|
currentPasswordInvalid: 'Current password is incorrect',
|
||||||
invalidReferralCode: 'Referral code is invalid'
|
invalidReferralCode: 'Referral code is invalid',
|
||||||
|
emailDeliveryUnavailable: 'Email delivery is temporarily unavailable. Please try again later.'
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
nameRequired: 'Name is required',
|
nameRequired: 'Name is required',
|
||||||
@@ -2072,7 +2073,8 @@ export const systemWordingMessages = {
|
|||||||
verifyEmailFirst: '请先完成邮箱验证',
|
verifyEmailFirst: '请先完成邮箱验证',
|
||||||
invalidResetToken: '密码重置链接无效或已过期',
|
invalidResetToken: '密码重置链接无效或已过期',
|
||||||
currentPasswordInvalid: '当前密码不正确',
|
currentPasswordInvalid: '当前密码不正确',
|
||||||
invalidReferralCode: '邀请码无效'
|
invalidReferralCode: '邀请码无效',
|
||||||
|
emailDeliveryUnavailable: '邮件发送暂时不可用,请稍后再试。'
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
nameRequired: '请输入名称',
|
nameRequired: '请输入名称',
|
||||||
|
|||||||
Reference in New Issue
Block a user