feat(auth): add single-use email password recovery and session revocation
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user