bknd-password-reset

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Password Reset Flow

密码重置流程

Implement password reset and change functionality in Bknd applications.
在Bknd应用中实现密码重置和修改功能。

Prerequisites

前提条件

  • Bknd project with auth enabled (
    bknd-setup-auth
    )
  • Password strategy configured
  • For email-based reset: email sending capability (external service)
  • 已启用身份验证的Bknd项目(
    bknd-setup-auth
  • 已配置密码策略
  • 基于邮件的重置:具备邮件发送能力(外部服务)

Important Context

重要说明

Bknd provides:
  • Server-side
    changePassword()
    method for admin/system password changes
  • No built-in forgot-password flow - you must implement token generation, email sending, and validation
Bknd提供:
  • **服务器端
    changePassword()
    **方法,用于管理员/系统发起的密码修改
  • 无内置找回密码流程 - 你需要自行实现令牌生成、邮件发送和验证逻辑

When to Use UI Mode

何时使用UI模式

  • Admin-initiated password resets via admin panel
UI steps: Admin Panel > Data > users > Select user > Edit
Note: Direct password editing via UI sets raw value - use server-side method for proper hashing.
  • 管理员通过管理面板发起密码重置
UI步骤: 管理面板 > 数据 > 用户 > 选择用户 > 编辑
注意:通过UI直接编辑密码会设置原始值 - 请使用服务器端方法以确保正确哈希。

When to Use Code Mode

何时使用代码模式

  • Implementing forgot-password flow with email
  • Adding change-password functionality for logged-in users
  • Admin tools for password resets
  • 实现基于邮件的找回密码流程
  • 为已登录用户添加密码修改功能
  • 管理员密码重置工具

Server-Side Password Change

服务器端密码修改

Using changePassword() Method

使用changePassword()方法

typescript
import { serve } from "bknd/adapter/node";
import { defineConfig } from "bknd";

export default serve(
  defineConfig({ /* config */ }),
  {
    async seed(ctx) {
      // Change password by user ID
      await ctx.app.module.auth.changePassword(1, "newSecurePassword123");

      // Or find user first
      const { data: user } = await ctx.em.repo("users").findOne({
        email: "user@example.com",
      });
      if (user) {
        await ctx.app.module.auth.changePassword(user.id, "newPassword456");
      }
    },
  }
);
Method signature:
typescript
changePassword(userId: number | string, newPassword: string): Promise<boolean>
Constraints:
  • User must exist
  • User must use password strategy (not OAuth)
  • Password is automatically hashed using configured hashing method
typescript
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");
      }
    },
  }
);
方法签名:
typescript
changePassword(userId: number | string, newPassword: string): Promise<boolean>
约束条件:
  • 用户必须存在
  • 用户必须使用密码策略(而非OAuth)
  • 密码会通过配置的哈希方法自动哈希

Building Forgot-Password Flow

构建找回密码流程

Since Bknd doesn't have built-in forgot-password, implement a custom flow:
由于Bknd没有内置找回密码功能,需实现自定义流程:

Step 1: Create Reset Token Entity

步骤1:创建重置令牌实体

typescript
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 = unused, 1 = used
  }),
});
typescript
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 = 已使用
  }),
});

Step 2: Request Reset Endpoint

步骤2:重置请求端点

typescript
import { randomBytes } from "crypto";

async function requestPasswordReset(email: string, ctx: any) {
  const api = ctx.api;

  // Check if user exists (don't reveal in response)
  const { data: user } = await api.data.readOneBy("users", { email });
  if (!user) {
    return { success: true, message: "If email exists, reset link sent" };
  }

  // Generate secure token
  const token = randomBytes(32).toString("hex");
  const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour

  // Store reset token
  await api.data.createOne("password_resets", {
    email,
    token,
    expires_at: expiresAt.toISOString(),
    used: 0,
  });

  // Send email (use your email service: SendGrid, Resend, etc.)
  const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
  // await emailService.send({ to: email, subject: "Password Reset", ... });

  return { success: true, message: "If email exists, reset link sent" };
}
typescript
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: "Password Reset", ... });

  return { success: true, message: "如果邮箱存在,重置链接已发送" };
}

Step 3: Validate and Reset Password

步骤3:验证并重置密码

typescript
async function resetPassword(token: string, newPassword: string, ctx: any) {
  const api = ctx.api;
  const auth = ctx.app.module.auth;

  // Find valid token
  const { data: resetRecord } = await api.data.readOneBy("password_resets", {
    token,
    used: 0,
  });

  if (!resetRecord) {
    throw new Error("Invalid or expired reset token");
  }

  // Check expiration
  if (new Date(resetRecord.expires_at) < new Date()) {
    throw new Error("Reset token has expired");
  }

  // Find user
  const { data: user } = await api.data.readOneBy("users", {
    email: resetRecord.email,
  });
  if (!user) {
    throw new Error("User not found");
  }

  // Change password (properly hashed)
  await auth.changePassword(user.id, newPassword);

  // Mark token as used
  await api.data.updateOne("password_resets", resetRecord.id, { used: 1 });

  return { success: true };
}
typescript
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 };
}

Step 4: React Frontend

步骤4:React前端

Request Reset Form:
tsx
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>Check your email for reset instructions.</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <button type="submit" disabled={status === "loading"}>
        {status === "loading" ? "Sending..." : "Send Reset Link"}
      </button>
    </form>
  );
}
Reset Password Form:
tsx
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>Invalid reset link.</p>;

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (password !== confirmPassword) {
      setError("Passwords do not match");
      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 || "Reset failed");
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p className="error">{error}</p>}
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="New Password"
        minLength={8}
        required
      />
      <input
        type="password"
        value={confirmPassword}
        onChange={(e) => setConfirmPassword(e.target.value)}
        placeholder="Confirm Password"
        required
      />
      <button type="submit">Reset Password</button>
    </form>
  );
}
找回密码表单:
tsx
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>
  );
}
重置密码表单:
tsx
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>
  );
}

Authenticated Password Change

已认证用户的密码修改

For logged-in users changing their own password:
typescript
async function changePassword(currentPassword: string, newPassword: string) {
  const api = new Api({ host: "http://localhost:7654", storage: localStorage });

  // Verify current password by re-authenticating
  const { data: userData } = await api.auth.me();
  if (!userData?.user) throw new Error("Not authenticated");

  const { ok } = await api.auth.login("password", {
    email: userData.user.email,
    password: currentPassword,
  });

  if (!ok) throw new Error("Current password is incorrect");

  // Call custom password change endpoint
  const res = await fetch("/api/password-change", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ newPassword }),
  });

  if (!res.ok) throw new Error("Failed to change password");
}
针对已登录用户修改自身密码:
typescript
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("密码修改失败");
}

Security Considerations

安全注意事项

Token Security

令牌安全

typescript
// Good: Cryptographically secure token
import { randomBytes } from "crypto";
const token = randomBytes(32).toString("hex"); // 64 chars

// Bad: Predictable token
const token = Math.random().toString(36); // NOT secure!
typescript
// 推荐:加密安全的令牌
import { randomBytes } from "crypto";
const token = randomBytes(32).toString("hex"); // 64位字符

// 不推荐:可预测的令牌
const token = Math.random().toString(36); // 不安全!

Rate Limiting

速率限制

typescript
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; // Max 3 per hour

  record.count++;
  return true;
}
typescript
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;
}

Common Pitfalls

常见误区

Not Hashing Password

未哈希密码

typescript
// Wrong - bypasses hashing
await api.data.updateOne("users", userId, {
  strategy_value: "plainPassword123",
});

// Correct - uses configured hashing
await ctx.app.module.auth.changePassword(userId, "plainPassword123");
typescript
// 错误 - 绕过哈希
await api.data.updateOne("users", userId, {
  strategy_value: "plainPassword123",
});

// 正确 - 使用配置的哈希方法
await ctx.app.module.auth.changePassword(userId, "plainPassword123");

Email Enumeration

邮箱枚举

typescript
// Wrong - reveals email existence
if (!user) return { error: "Email not found" };

// Correct - doesn't reveal
return { success: true, message: "If email exists, reset link sent" };
typescript
// 错误 - 暴露邮箱是否存在
if (!user) return { error: "邮箱未找到" };

// 正确 - 不暴露此信息
return { success: true, message: "如果邮箱存在,重置链接已发送" };

OAuth User Password Reset

OAuth用户密码重置

typescript
const { data: user } = await api.data.readOneBy("users", { email });
if (user?.strategy !== "password") {
  return { error: "This account uses social login" };
}
typescript
const { data: user } = await api.data.readOneBy("users", { email });
if (user?.strategy !== "password") {
  return { error: "此账户使用社交登录" };
}

Verification

验证

bash
undefined
bash
undefined

1. Request reset

1. 请求重置

curl -X POST http://localhost:7654/api/password-reset/request
-H "Content-Type: application/json"
-d '{"email": "user@example.com"}'
curl -X POST http://localhost:7654/api/password-reset/request
-H "Content-Type: application/json"
-d '{"email": "user@example.com"}'

2. Reset password (use token from email/DB)

2. 重置密码(使用邮箱/数据库中的令牌)

curl -X POST http://localhost:7654/api/password-reset/confirm
-H "Content-Type: application/json"
-d '{"token": "<token>", "password": "newPassword123"}'
curl -X POST http://localhost:7654/api/password-reset/confirm
-H "Content-Type: application/json"
-d '{"token": "<token>", "password": "newPassword123"}'

3. Login with new password

3. 使用新密码登录

curl -X POST http://localhost:7654/api/auth/password/login
-H "Content-Type: application/json"
-d '{"email": "user@example.com", "password": "newPassword123"}'
undefined
curl -X POST http://localhost:7654/api/auth/password/login
-H "Content-Type: application/json"
-d '{"email": "user@example.com", "password": "newPassword123"}'
undefined

DOs and DON'Ts

注意事项

DO:
  • Use
    changePassword()
    method for proper hashing
  • Generate cryptographically secure tokens
  • Set short expiration (1 hour max)
  • Mark tokens as used immediately
  • Return consistent responses (don't reveal email existence)
  • Rate limit reset requests
DON'T:
  • Store/transmit passwords in plain text
  • Use predictable tokens (Math.random)
  • Allow unlimited reset attempts
  • Keep tokens valid indefinitely
  • Allow password reset for OAuth users
建议:
  • 使用
    changePassword()
    方法确保正确哈希
  • 生成加密安全的令牌
  • 设置短过期时间(最长1小时)
  • 立即将令牌标记为已使用
  • 返回一致的响应(不暴露邮箱是否存在)
  • 限制重置请求速率
禁止:
  • 明文存储/传输密码
  • 使用可预测的令牌(如Math.random)
  • 允许无限次重置尝试
  • 令牌长期有效
  • 允许OAuth用户重置密码

Related Skills

相关技能

  • bknd-setup-auth - Configure authentication system
  • bknd-login-flow - Login/logout functionality
  • bknd-registration - User registration setup
  • bknd-session-handling - Manage user sessions
  • bknd-custom-endpoint - Create custom API endpoints
  • bknd-setup-auth - 配置身份验证系统
  • bknd-login-flow - 登录/登出功能
  • bknd-registration - 用户注册设置
  • bknd-session-handling - 管理用户会话
  • bknd-custom-endpoint - 创建自定义API端点