Files
probably-rendering-one-more…/apps/dashboard/src/lib/account.ts
T

93 lines
6.7 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import nodemailer from 'nodemailer';
import { db, transaction } from './db';
import { ACCOUNT_COOKIE, SESSION_TTL, newToken, tokenDigest, hashPassword, checkPassword, validPassword } from './account-crypto';
export async function rateLimit(key: string, limit: number) {
const result = await db().query(`INSERT INTO account_limits(key,attempts,window_start) VALUES ($1,1,now())
ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN 1 ELSE account_limits.attempts+1 END,
window_start=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN now() ELSE account_limits.window_start END RETURNING attempts`,[tokenDigest(key)]);
return result.rows[0].attempts <= limit;
}
async function sendAccountMail(email: string, token: string, kind: 'verify' | 'reset') {
const origin = process.env.ACCOUNT_PUBLIC_ORIGIN;
if (!origin || !process.env.SMTP_PASSWORD || !process.env.SMTP_USERNAME) throw Error('SMTP unavailable');
const url = new URL('/account',origin); url.searchParams.set(kind,token);
const smtp = nodemailer.createTransport({host:process.env.SMTP_HOST || 'smtpdm.aliyun.com',port:Number(process.env.SMTP_PORT || 465),secure:true,
auth:{user:process.env.SMTP_USERNAME,pass:process.env.SMTP_PASSWORD},connectionTimeout:10000,socketTimeout:15000});
await smtp.sendMail({from:process.env.SMTP_FROM || process.env.SMTP_USERNAME,to:email,subject:kind==='reset'?'重设你的啡跃账户密码':'验证你的知识站邮箱',text:kind==='reset'?`请在 30 分钟内打开链接,由你设置新的密码:\n${url.href}\n如果你没有请求重设密码,请忽略;你的密码不会改变。`:`请在 30 分钟内打开链接并点击确认验证邮箱:\n${url.href}\n如果你没有注册,请忽略。`});
}
export async function sendVerification(email: string,token: string) { await sendAccountMail(email,token,'verify'); }
export async function requestPasswordReset(email: string) {
const result=await db().query('SELECT id FROM account_users WHERE email=$1 AND verified_at IS NOT NULL',[email]);
if(!result.rowCount)return;
const token=newToken(),digest=tokenDigest(token);
await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'reset',now()+interval '30 minutes')",[digest,result.rows[0].id]);
try { await sendAccountMail(email,token,'reset'); } catch { await db().query('DELETE FROM account_tokens WHERE digest=$1',[digest]);throw Error('SMTP unavailable'); }
}
export async function resetPassword(token: string,password: string) {
if(!/^[a-f0-9]{64}$/.test(token)||!validPassword(password))return false;
const hash=await hashPassword(password),digest=tokenDigest(token);
return transaction(async client=>{
const lookup=await client.query("SELECT user_id FROM account_tokens WHERE digest=$1 AND kind='reset' AND expires_at>now()",[digest]);
if(!lookup.rowCount)return false;
const id=lookup.rows[0].user_id;
// Serialize all reset links for one account, then recheck after acquiring the lock.
await client.query('SELECT id FROM account_users WHERE id=$1 FOR UPDATE',[id]);
const consumed=await client.query("DELETE FROM account_tokens WHERE digest=$1 AND kind='reset' AND expires_at>now() RETURNING user_id",[digest]);
if(!consumed.rowCount)return false;
await client.query('UPDATE account_users SET password_hash=$1 WHERE id=$2',[hash,id]);
await client.query('DELETE FROM account_tokens WHERE user_id=$1',[id]);
return true;
});
}
export async function registerAccount(email: string,password: string) {
const hash = await hashPassword(password);
const result = await db().query('INSERT INTO account_users(id,email,password_hash) VALUES ($1,$2,$3) ON CONFLICT(email) DO NOTHING RETURNING id',[randomUUID(),email,hash]);
if (result.rowCount) await issueVerification(result.rows[0].id,email);
}
async function issueVerification(id: string,email: string) {
const token = newToken();
// Keep a previous unexpired link usable when a resend fails.
await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'verify',now()+interval '30 minutes')",[tokenDigest(token),id]);
try { await sendVerification(email,token); } catch { await db().query('DELETE FROM account_tokens WHERE digest=$1',[tokenDigest(token)]); throw Error('SMTP unavailable'); }
}
export async function resendVerification(email: string,password: string) {
const result = await db().query('SELECT * FROM account_users WHERE email=$1',[email]);
const user = result.rows[0];
if (user && !user.verified_at && await checkPassword(password,user.password_hash)) await issueVerification(user.id,email);
}
export async function verifyAccount(token: string) {
return transaction(async client => {
const result = await client.query("DELETE FROM account_tokens WHERE digest=$1 AND kind='verify' AND expires_at>now() RETURNING user_id",[tokenDigest(token)]);
if (!result.rowCount) return false;
await client.query('UPDATE account_users SET verified_at=COALESCE(verified_at,now()) WHERE id=$1',[result.rows[0].user_id]);
await client.query("DELETE FROM account_tokens WHERE user_id=$1 AND kind='verify'",[result.rows[0].user_id]);
return true;
});
}
export async function loginAccount(email: string,password: string) {
return transaction(async client=>{
const result = await client.query('SELECT * FROM account_users WHERE email=$1 FOR UPDATE',[email]);
const user = result.rows[0];
// A fixed dummy hash makes unknown accounts perform the same expensive KDF.
const hash = user?.password_hash || 'scrypt:00000000000000000000000000000000:'+'0'.repeat(128);
if (!await checkPassword(password,hash) || !user?.verified_at) return null;
const token = newToken();
await client.query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'session',now()+$3*interval '1 second')",[tokenDigest(token),user.id,SESSION_TTL]);
return token;
});
}
export function requestToken(request: Request) {
const value = request.headers.get('cookie')?.split(';').map(s=>s.trim()).find(s=>s.startsWith(ACCOUNT_COOKIE+'='))?.slice(ACCOUNT_COOKIE.length+1);
return value && /^[a-f0-9]{64}$/.test(value) ? value : null;
}
export async function currentAccount(request: Request) {
const token = requestToken(request); if (!token) return null;
const result = await db().query(`SELECT u.id,u.email,u.verified_at FROM account_users u JOIN account_tokens t ON t.user_id=u.id WHERE t.digest=$1 AND t.kind='session' AND t.expires_at>now() AND u.verified_at IS NOT NULL`,[tokenDigest(token)]);
return result.rows[0] || null;
}
export async function logoutAccount(request: Request) {
const token = requestToken(request);
if (token) await db().query("DELETE FROM account_tokens WHERE digest=$1 AND kind='session'",[tokenDigest(token)]);
}