diff --git a/apps/waterfall-wiki/db/017_password_reset.sql b/apps/waterfall-wiki/db/017_password_reset.sql new file mode 100644 index 0000000..f58ae7e --- /dev/null +++ b/apps/waterfall-wiki/db/017_password_reset.sql @@ -0,0 +1,5 @@ +BEGIN; +ALTER TABLE account_tokens DROP CONSTRAINT account_tokens_kind_check; +ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_kind_check CHECK (kind IN ('verify','session','reset')); +INSERT INTO schema_migrations(version) VALUES ('017_password_reset'); +COMMIT; diff --git a/apps/waterfall-wiki/src/lib/account-handler.ts b/apps/waterfall-wiki/src/lib/account-handler.ts index 1c9c8b9..5894384 100644 --- a/apps/waterfall-wiki/src/lib/account-handler.ts +++ b/apps/waterfall-wiki/src/lib/account-handler.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { normalizeEmail,validPassword, trustedOrigin,accountCookie } from './account-crypto'; -import { rateLimit,registerAccount,resendVerification,verifyAccount,loginAccount,currentAccount,logoutAccount } from './account'; +import { requestPasswordReset,resetPassword,rateLimit,registerAccount,resendVerification,verifyAccount,loginAccount,currentAccount,logoutAccount } from './account'; export const runtime = 'nodejs'; const headers = {'Cache-Control':'private, no-store','Referrer-Policy':'no-referrer'}; const json = (body: object,status=200) => NextResponse.json(body,{status,headers}); @@ -12,7 +12,7 @@ export async function GET(request: Request,context: Context) { export async function POST(request: Request,context: Context) { if (!trustedOrigin(request)) return json({error:'请求来源不受信任'},403); const {action} = await context.params; - if (!['register','resend','verify','login','logout'].includes(action)) return json({error:'不存在'},404); + if (!['register','resend','verify','login','logout','forgot','reset'].includes(action)) return json({error:'不存在'},404); try { // Revocation remains available even when login or mail limits are exhausted. if (action === 'logout') { await logoutAccount(request); const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie('',true));return r; } @@ -27,6 +27,19 @@ export async function POST(request: Request,context: Context) { if(typeof body.token!=='string'||! /^[a-f0-9]{64}$/.test(body.token))return json({error:'验证链接无效或已过期'},400); return await verifyAccount(body.token) ? json({ok:true,message:'邮箱已验证,请登录'}) : json({error:'验证链接无效或已过期'},400); } + if(action==='reset') { + if(typeof body.token!=='string'||!/^[a-f0-9]{64}$/.test(body.token)||!validPassword(body.password))return json({error:'重设链接无效,或密码不符合要求'},400); + if(!await rateLimit('reset:'+body.token,5))return json({error:'请求过于频繁,请稍后再试'},429); + if(!await resetPassword(body.token,body.password))return json({error:'重设链接无效或已过期,请重新申请'},400); + const r=json({ok:true,message:'密码已更新,请使用新密码登录'});r.headers.set('Set-Cookie',accountCookie('',true));return r; + } + if(action==='forgot') { + const email=normalizeEmail(body.email); + if(!email)return json({error:'请输入有效邮箱'},400); + if(!await rateLimit('forgot:'+email,3))return json({error:'请求过于频繁,请 15 分钟后再试'},429); + await requestPasswordReset(email); + return json({ok:true,message:'若该邮箱对应已验证账户,重设链接将发送至邮箱,有效期 30 分钟。'}); + } const email=normalizeEmail(body.email); if(!email||!validPassword(body.password))return json({error:'请输入有效邮箱及 12–256 字节密码'},400); if(!await rateLimit(action+':'+email,action==='login'?10:3))return json({error:'请求过于频繁,请 15 分钟后再试'},429); diff --git a/apps/waterfall-wiki/src/lib/account.ts b/apps/waterfall-wiki/src/lib/account.ts index e7046f2..64ca807 100644 --- a/apps/waterfall-wiki/src/lib/account.ts +++ b/apps/waterfall-wiki/src/lib/account.ts @@ -1,20 +1,44 @@ import { randomUUID } from 'node:crypto'; import nodemailer from 'nodemailer'; import { db, transaction } from './db'; -import { ACCOUNT_COOKIE, SESSION_TTL, newToken, tokenDigest, hashPassword, checkPassword } from './account-crypto'; +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; } -export async function sendVerification(email: string, token: string) { +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('verify',token); + 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:'验证你的知识站邮箱',text:`请在 30 分钟内打开链接并点击确认验证邮箱:\n${url.href}\n如果你没有注册,请忽略。`}); + 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); @@ -42,14 +66,16 @@ export async function verifyAccount(token: string) { }); } export async function loginAccount(email: string,password: string) { - const result = await db().query('SELECT * FROM account_users WHERE email=$1',[email]); + 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 db().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]); + 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); diff --git a/apps/waterfall-wiki/tests/account-integration.ts b/apps/waterfall-wiki/tests/account-integration.ts index 7d0c3c4..85609b1 100644 --- a/apps/waterfall-wiki/tests/account-integration.ts +++ b/apps/waterfall-wiki/tests/account-integration.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { db } from '../src/lib/db'; import { hashPassword,newToken,tokenDigest } from '../src/lib/account-crypto'; -import { loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account'; +import { resetPassword,loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account'; export async function verifyEmailAccounts() { const id=randomUUID(), email=id+'@example.com', password='a secure test password'; const request=(token:string)=>new Request('https://wiki.example',{headers:{cookie:'wiki_account='+token}}); @@ -17,6 +17,20 @@ export async function verifyEmailAccounts() { await db().query("UPDATE account_tokens SET expires_at=now()-interval '1 second' WHERE digest=$1",[tokenDigest(token)]); assert.equal(await currentAccount(request(token)),null,'expired sessions rejected'); const fresh=await loginAccount(email,password);assert.ok(fresh);await logoutAccount(request(fresh));assert.equal(await currentAccount(request(fresh)),null,'logout revokes token'); + const oldSession=await loginAccount(email,password);assert.ok(oldSession); + const resets=[newToken(),newToken()],replacement='a new secure test password'; + for(const value of resets)await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(value),id]); + const resetResults=await Promise.all(resets.map(value=>resetPassword(value,replacement))); + assert.deepEqual(resetResults.sort(),[false,true],'Only one concurrent reset may succeed for one account'); + assert.equal(await currentAccount(request(oldSession)),null,'Reset revokes existing sessions'); + assert.equal(await loginAccount(email,password),null,'Original password is no longer accepted'); + assert.ok(await loginAccount(email,replacement)); + assert.equal(await resetPassword(resets[0],password),false,'Reset links are single-use, including sibling links'); + const expired=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()-interval '1 minute')",[tokenDigest(expired),id]); + assert.equal(await resetPassword(expired,password),false); + const wrongKind=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'verify',now()+interval '30 minutes')",[tokenDigest(wrongKind),id]); + assert.equal(await resetPassword(wrongKind,password),false,'Email verification cannot reset a password'); + assert.equal(await resetPassword(newToken(),'short'),false); const key=randomUUID();assert.deepEqual(await Promise.all(Array.from({length:5},()=>rateLimit(key,3))).then(a=>a.sort()),[false,false,true,true,true]); await db().query('DELETE FROM account_limits WHERE key=$1',[tokenDigest(key)]); } finally {await db().query('DELETE FROM account_users WHERE id=$1',[id]);}