name: bknd-password-reset description: 在Bknd应用程序中实现密码重置或更改功能时使用。涵盖服务器端密码更改、构建基于电子邮件的忘记密码流程和安全考虑。
密码重置流程
在Bknd应用程序中实现密码重置和更改功能。
先决条件
- 启用认证的Bknd项目(
bknd-setup-auth) - 配置密码策略
- 对于基于电子邮件的重置:电子邮件发送能力(外部服务)
重要上下文
Bknd提供:
- 服务器端
changePassword()方法,用于管理员/系统密码更改 - 没有内置的忘记密码流程 - 您必须实现令牌生成、电子邮件发送和验证
何时使用UI模式
- 通过管理面板发起的密码重置
UI步骤: 管理面板 > 数据 > 用户 > 选择用户 > 编辑
注意:通过UI直接编辑密码会设置原始值 - 使用服务器端方法进行适当的哈希处理。
何时使用代码模式
- 实现基于电子邮件的忘记密码流程
- 为登录用户添加更改密码功能
- 管理员工具的密码重置
服务器端密码更改
使用changePassword()方法
import { serve } from "bknd/adapter/node";
import { defineConfig } from "bknd";
export default serve(
defineConfig({ /* config */ }),
{
async seed(ctx) {
// 通过用户ID更改密码
await ctx.app.module.auth.changePassword(1, "newSecurePassword123");
// 或先查找用户
const { data: user } = await ctx.em.repo("users").findOne({
email: "user@example.com",
});
if (user) {
await ctx.app.module.auth.changePassword(user.id, "newPassword456");
}
},
}
);
方法签名:
changePassword(userId: number | string, newPassword: string): Promise<boolean>
约束:
- 用户必须存在
- 用户必须使用密码策略(非OAuth)
- 密码使用配置的哈希方法自动哈希处理
构建忘记密码流程
由于Bknd没有内置的忘记密码功能,实现自定义流程:
步骤1:创建重置令牌实体
import { em, entity, text, date, number } from "bknd";
const schema = em({
password_resets: entity("password_resets", {
email: text().required(),
token: text().required().unique(),
expires_at: date().required(),
used: number().default(0), // 0 = 未使用, 1 = 已使用
}),
});
步骤2:请求重置端点
import { randomBytes } from "crypto";
async function requestPasswordReset(email: string, ctx: any) {
const api = ctx.api;
// 检查用户是否存在(不要在响应中泄露)
const { data: user } = await api.data.readOneBy("users", { email });
if (!user) {
return { success: true, message: "如果电子邮件存在,重置链接已发送" };
}
// 生成安全令牌
const token = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1小时
// 存储重置令牌
await api.data.createOne("password_resets", {
email,
token,
expires_at: expiresAt.toISOString(),
used: 0,
});
// 发送电子邮件(使用您的电子邮件服务:SendGrid、Resend等)
const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
// await emailService.send({ to: email, subject: "密码重置", ... });
return { success: true, message: "如果电子邮件存在,重置链接已发送" };
}
步骤3:验证和重置密码
async function resetPassword(token: string, newPassword: string, ctx: any) {
const api = ctx.api;
const auth = ctx.app.module.auth;
// 查找有效令牌
const { data: resetRecord } = await api.data.readOneBy("password_resets", {
token,
used: 0,
});
if (!resetRecord) {
throw new Error("无效或过期的重置令牌");
}
// 检查过期时间
if (new Date(resetRecord.expires_at) < new Date()) {
throw new Error("重置令牌已过期");
}
// 查找用户
const { data: user } = await api.data.readOneBy("users", {
email: resetRecord.email,
});
if (!user) {
throw new Error("用户未找到");
}
// 更改密码(适当哈希处理)
await auth.changePassword(user.id, newPassword);
// 标记令牌为已使用
await api.data.updateOne("password_resets", resetRecord.id, { used: 1 });
return { success: true };
}
步骤4:React前端
请求重置表单:
function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "sent">("idle");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setStatus("loading");
await fetch("/api/password-reset/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
setStatus("sent");
}
if (status === "sent") {
return <p>检查您的电子邮件获取重置说明。</p>;
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="电子邮件"
required
/>
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "发送中..." : "发送重置链接"}
</button>
</form>
);
}
重置密码表单:
function ResetPasswordForm() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
if (!token) return <p>无效的重置链接。</p>;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirmPassword) {
setError("密码不匹配");
return;
}
const res = await fetch("/api/password-reset/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
if (res.ok) {
navigate("/login");
} else {
const data = await res.json();
setError(data.message || "重置失败");
}
}
return (
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="新密码"
minLength={8}
required
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="确认密码"
required
/>
<button type="submit">重置密码</button>
</form>
);
}
认证密码更改
对于登录用户更改自己的密码:
async function changePassword(currentPassword: string, newPassword: string) {
const api = new Api({ host: "http://localhost:7654", storage: localStorage });
// 通过重新认证验证当前密码
const { data: userData } = await api.auth.me();
if (!userData?.user) throw new Error("未认证");
const { ok } = await api.auth.login("password", {
email: userData.user.email,
password: currentPassword,
});
if (!ok) throw new Error("当前密码不正确");
// 调用自定义密码更改端点
const res = await fetch("/api/password-change", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newPassword }),
});
if (!res.ok) throw new Error("更改密码失败");
}
安全考虑
令牌安全
// 好:密码学安全令牌
import { randomBytes } from "crypto";
const token = randomBytes(32).toString("hex"); // 64字符
// 坏:可预测令牌
const token = Math.random().toString(36); // 不安全!
速率限制
const resetAttempts = new Map<string, { count: number; lastAttempt: Date }>();
function checkRateLimit(email: string): boolean {
const record = resetAttempts.get(email);
const now = new Date();
if (!record || record.lastAttempt < new Date(now.getTime() - 3600000)) {
resetAttempts.set(email, { count: 1, lastAttempt: now });
return true;
}
if (record.count >= 3) return false; // 每小时最多3次
record.count++;
return true;
}
常见陷阱
未哈希密码
// 错误 - 绕过哈希处理
await api.data.updateOne("users", userId, {
strategy_value: "plainPassword123",
});
// 正确 - 使用配置的哈希处理
await ctx.app.module.auth.changePassword(userId, "plainPassword123");
电子邮件枚举
// 错误 - 泄露电子邮件存在
if (!user) return { error: "电子邮件未找到" };
// 正确 - 不泄露
return { success: true, message: "如果电子邮件存在,重置链接已发送" };
OAuth用户密码重置
const { data: user } = await api.data.readOneBy("users", { email });
if (user?.strategy !== "password") {
return { error: "此账户使用社交登录" };
}
验证
# 1. 请求重置
curl -X POST http://localhost:7654/api/password-reset/request \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
# 2. 重置密码(使用来自电子邮件/数据库的令牌)
curl -X POST http://localhost:7654/api/password-reset/confirm \
-H "Content-Type: application/json" \
-d '{"token": "<token>", "password": "newPassword123"}'
# 3. 使用新密码登录
curl -X POST http://localhost:7654/api/auth/password/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "newPassword123"}'
应该做和不应该做
应该做:
- 使用
changePassword()方法进行适当的哈希处理 - 生成密码学安全令牌
- 设置短过期时间(最多1小时)
- 立即标记令牌为已使用
- 返回一致的响应(不要泄露电子邮件存在)
- 速率限制重置请求
不应该做:
- 存储/传输明文密码
- 使用可预测令牌(Math.random)
- 允许无限重置尝试
- 保持令牌永久有效
- 允许OAuth用户的密码重置
相关技能
- bknd-setup-auth - 配置认证系统
- bknd-login-flow - 登录/注销功能
- bknd-registration - 用户注册设置
- bknd-session-handling - 管理用户会话
- bknd-custom-endpoint - 创建自定义API端点