From 6800a1b1c4f1e1e928f8a42747bb2eec953c7505 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 1 Nov 2026 23:43:26 +0800 Subject: [PATCH 1/2] feat(auth): add single-use email password recovery and session revocation --- apps/waterfall-wiki/db/017_password_reset.sql | 5 +++ .../waterfall-wiki/src/lib/account-handler.ts | 17 ++++++++- apps/waterfall-wiki/src/lib/account.ts | 38 ++++++++++++++++--- .../tests/account-integration.ts | 16 +++++++- 4 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 apps/waterfall-wiki/db/017_password_reset.sql 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]);} From b31a4835ee9e88a82bb7c2bf00927dd6b8195613 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 2 Nov 2026 00:02:32 +0800 Subject: [PATCH 2/2] test(auth): verify recovery endpoints over HTTP with real storage --- apps/waterfall-wiki/tests/account-integration.ts | 16 +++++++++++++++- apps/waterfall-wiki/tests/integration.test.ts | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/waterfall-wiki/tests/account-integration.ts b/apps/waterfall-wiki/tests/account-integration.ts index 85609b1..ea8c971 100644 --- a/apps/waterfall-wiki/tests/account-integration.ts +++ b/apps/waterfall-wiki/tests/account-integration.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { db } from '../src/lib/db'; import { hashPassword,newToken,tokenDigest } from '../src/lib/account-crypto'; import { resetPassword,loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account'; -export async function verifyEmailAccounts() { +export async function verifyEmailAccounts(base: string) { 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}}); try { @@ -31,6 +31,20 @@ export async function verifyEmailAccounts() { 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 post=(action:string,body:object,origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base)=>fetch(base+'/api/account/'+action,{method:'POST',headers:{'Content-Type':'application/json',Origin:origin},body:JSON.stringify(body)}); + assert.equal((await post('forgot',{email},'https://untrusted.invalid')).status,403,'Cross-origin recovery is rejected'); + const unknown=await post('forgot',{email:randomUUID()+'@example.com'}); + assert.equal(unknown.status,200,'Unknown email receives the same non-enumerating response without sending mail'); + assert.match((await unknown.json()).message,/若该邮箱/); + const httpToken=newToken(),httpPassword='http replacement secure password'; + const priorHttpSession=await loginAccount(email,replacement);assert.ok(priorHttpSession); + await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(httpToken),id]); + const changed=await post('reset',{token:httpToken,password:httpPassword}); + assert.equal(changed.status,200); + assert.match(changed.headers.get('set-cookie')||'',/Max-Age=0/); + assert.equal(await currentAccount(request(priorHttpSession)),null); + assert.ok(await loginAccount(email,httpPassword),'HTTP recovery changes the stored password'); + assert.equal((await post('reset',{token:httpToken,password:replacement})).status,400,'HTTP recovery rejects a consumed token'); 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]);} diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index 478cd5c..34fac0c 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -42,7 +42,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready); for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route); if(!external)await verifyBrandUI(base); - await verifyEmailAccounts(); + await verifyEmailAccounts(base); await verifyCanvasWorkspace(base); if(process.env.VERIFY_CANVAS_UI==='1')await verifyCanvasAgentUI(base); await verifyGuidancePublishing(base);