93 lines
6.7 KiB
TypeScript
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)]);
|
|
}
|