# 实现找回密码与修改密码｜Creght

> 在 Creght Func 里用 ctx.users.find / checkPassword / setPassword 实现邮箱验证码找回密码与登录态改密码：含完整代码、防邮箱枚举的三条规则和错误对照表。

[![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)API for AI](/)

[查看 llms.txt](/llms.txt)

概览

- [Creght AI 编程指南](/api.md)

AI 可发现性

- [如何优化 llms.txt](/api/optimize-llms-txt.md)

站点配置

- [实现基于域名的多语言路由](/api/domain-locale-routing.md)

后端

- [使用 Func 构建站点后端能力](/api/func-backend.md)
- [使用 Func 接入支付宝电脑网站支付](/api/func-alipay-payment.md)

集成

- [使用集成发送邮件与验证码](/api/func-email-integration.md)

登录与用户

- [注册时验证邮箱](/api/auth-verified-registration.md)
- [实现找回密码与修改密码](/api/auth-password-reset.md)
- [在 Func 里实现登录](/api/auth-func-login.md)

本页目录

- [准备](#prepare)
- [文件与调用名](#files)
- [指自己用 ctx.auth，指别人用 ctx.users](#namespace)
- [场景一：未登录，用邮箱验证码找回密码](#reset)
- [三条必须遵守的规则](#rules)
- [场景二：登录状态下修改密码](#change)
- [页面代码](#page)
- [不要自己实现的部分](#dont)
- [错误对照](#errors)
- [自测](#test)

登录与用户/实现找回密码与修改密码

# 实现找回密码与修改密码

站点的「忘记密码」和「修改密码」写在 Func 里：ctx.users.find 决定要不要发验证码，ctx.email.verifyCode 验码，ctx.users.setPassword 改密码。含两个场景的完整代码、三条不写就会出问题的规则，以及哪些事不用自己实现。

复制 Markdown 链接

在站点上做「忘记密码」和「修改密码」：Creght **没有** 现成的找回密码接口，
客户端 SDK 里也没有 `resetPassword()`。你在 Func 里写这两个方法，页面用 `invoke()` 调它们。
密码哈希、验证码的一次性与限频、改完清掉旧会话、密码爆破限流由平台保证，你只写「凭什么允许改」。

**本文范围**

覆盖 `ctx.users.find`、
`ctx.users.checkPassword`、 `ctx.users.setPassword`，配合
`ctx.email.sendCode` / `verifyCode` 完成两个场景： **未登录用邮箱验证码找回密码**、
**登录状态下修改密码**。注册侧的邮箱验证见
[注册时验证邮箱](/api/auth-verified-registration.md)。

## 准备

1. 项目已连接邮件集成（编辑器 **后端 → 集成**），见
    [使用集成发送邮件与验证码](/api/func-email-integration.md)。没有它就发不出验证码。
2. `talizen` 客户端 0.2.37 或更高，只影响编辑器里的类型提示。

与注册设置 **无关**：Auth 面板里的「开放注册」「注册必须验证邮箱」「仅后端函数」怎么设，
都不影响找回密码。不用去改它们。

## 文件与调用名

Func 是站点源文件，路径去掉扩展名就是调用 key， `.` 后面是方法名：

| 文件 | 页面里怎么调 |
| --- | --- |
| `/backend/func/auth/reset-password.ts` | `invoke('auth/reset-password.requestCode', …)`<br>`invoke('auth/reset-password.confirm', …)` |
| `/backend/func/auth/change-password.ts` | `invoke('auth/change-password', …)`（导出名为 `main` 时不写方法名） |

## 指自己用 ctx.auth，指别人用 ctx.users

| 要做的事 | 用哪个 |
| --- | --- |
| 读当前登录的人 | `ctx.auth.currentUser()` / `ctx.auth.requireUser()` |
| 按邮箱/账号/id 找某个用户 | `ctx.users.find({ email })`，找不到返回 `null` |
| 验某个用户的现有密码 | `ctx.users.checkPassword({ userId, password })` |
| 改某个用户的密码 | `ctx.users.setPassword({ email, password })` |

`ctx.users` 的三个方法都能指到 **项目里任何一个用户**，所以指错人就是改错账号。
`email`、 `account`、 `userId` **只能填一个**，同时填会报错。

## 场景一：未登录，用邮箱验证码找回密码

分两次请求：先要码，再拿码换新密码。

```typescript
// /backend/func/auth/reset-password.ts
import type { TalizenFuncContext } from 'talizen/func-runtime'

const SCENE = 'reset_password'
const MIN_PASSWORD = 8

// 第一步：要验证码
export function requestCode(input: { email?: string }, ctx: TalizenFuncContext) {
  const email = (input.email ?? '').trim()
  if (!email) throw new Error('请填写邮箱')

  // 先查有没有这个账号，再决定要不要发信
  const user = ctx.users.find({ email })
  if (user) {
    try {
      ctx.email.sendCode({ to: email, scene: SCENE })
    } catch (err) {
      // 发信失败（限频、集成异常）也不要抛给浏览器，写进日志自己看
      console.error('send reset code failed', String(err))
    }
  }

  // 无论账号是否存在，返回同一个结果
  return { ok: true }
}

// 第二步：验码并改密码，同一次请求内完成
export function confirm(
  input: { email?: string; code?: string; password?: string },
  ctx: TalizenFuncContext,
) {
  const email = (input.email ?? '').trim()
  const code = (input.code ?? '').trim()
  const password = input.password ?? ''

  if (!email || !code) throw new Error('请填写邮箱和验证码')
  if (password.length < MIN_PASSWORD) throw new Error('密码至少 8 位')

  // 先确认账号存在，再验码：验证码命中即失效，对不存在的邮箱验码会白白
  // 消耗掉真实用户的一次机会
  const user = ctx.users.find({ email })
  if (!user) throw new Error('验证码错误或已过期')

  if (!ctx.email.verifyCode({ to: email, scene: SCENE, code }))
    throw new Error('验证码错误或已过期')

  // 验过了才走到这一行。setPassword 不接收验证码，能不能改由你上面的
  // 代码决定
  ctx.users.setPassword({ email, password })
  return { ok: true }
}
```

`scene` 把不同用途的验证码隔开，只能用字母、数字、下划线、连字符，1–32 个字符。
用户在「登录」场景拿到的码，改密码时用不了。

## 三条必须遵守的规则

这三条都不会报错，写漏了站点照样能跑，但会留下真实的问题：

| 必须这么做 | 否则 |
| --- | --- |
| 发码 **之前** 先 `ctx.users.find` | 任何人都能用你的「忘记密码」表单，让你的域名往任意邮箱发信——花你的钱，也伤发件域名的信誉 |
| 账号存在与不存在， **返回同一个结果** | 返回 `{ found: false }` 就等于提供了一个查询接口，任何人都能逐个探测哪些邮箱在你站点上注册过 |
| 捕获 `setPassword` 的报错，换成统一提示 | 同上：404「用户不存在」直接回给浏览器，效果和上一条一样 |

页面上配合这一点： **无论接口返回什么，都显示同一句提示**，比如「如果该邮箱已注册，
我们已经发送了验证码」，不要根据返回值分支。

## 场景二：登录状态下修改密码

先用 `ctx.users.checkPassword` 验原密码，再改：

```typescript
// /backend/func/auth/change-password.ts
import type { TalizenFuncContext } from 'talizen/func-runtime'

export function main(
  input: { oldPassword: string; password: string },
  ctx: TalizenFuncContext,
) {
  const user = ctx.auth.requireUser()
  if (input.password.length < 8) throw new Error('密码至少 8 位')

  if (!ctx.users.checkPassword({ userId: user.id, password: input.oldPassword }))
    throw new Error('原密码不正确')

  ctx.users.setPassword({ userId: user.id, password: input.password })
  return { ok: true }
}
```

**userId 只能来自 requireUser()**

不要写
`ctx.users.setPassword({ userId: input.userId, … })`——那是让浏览器指定改谁的密码，
任何一个登录用户都能改别人的。这是这段代码唯一会出大事的写法。同理，验证码的收件人取
`user.email`，不要取 `input` 里的地址。

`checkPassword` 返回布尔值：密码不对返回 `false`，不抛错。账号是用 Google 等
第三方登录注册的、没有设过密码时也返回 `false`——这类账号要改密码，改用上面场景一那套
邮箱验证码流程（收件人用 `ctx.auth.requireUser().email`）。

> 同一个人连续验错密码太多次， `checkPassword` 会抛 **429**（锁 1 小时）。
> 这个额度与登录接口是同一份，所以刚才登录失败几次的人，改密码时也会更早被锁。
> 把 429 的提示文案单独写一下（「尝试次数过多，请稍后再试」），别混进「原密码不正确」里，
> 否则用户会一直重试到锁死。

## 页面代码

```tsx
import { useState } from 'react'
import { invoke } from 'talizen/func'
import { useAuth } from 'talizen/auth'

export function ForgotPasswordForm() {
  const [step, setStep] = useState<'email' | 'code'>('email')
  const [email, setEmail] = useState('')
  const [code, setCode] = useState('')
  const [password, setPassword] = useState('')
  const [hint, setHint] = useState('')

  async function requestCode() {
    await invoke('auth/reset-password.requestCode', { email })
    // 固定文案：不要根据返回值判断这个邮箱有没有注册
    setHint('如果该邮箱已注册，我们已经发送了验证码')
    setStep('code')
  }

  async function confirm() {
    try {
      await invoke('auth/reset-password.confirm', { email, code, password })
      window.location.href = '/login'
    } catch (err) {
      setHint(err instanceof Error ? err.message : '重置失败，请重试')
    }
  }

  // ...表单略
}

// 登录状态下修改密码
async function changePassword(oldPassword: string, password: string) {
  await invoke('auth/change-password', { oldPassword, password })
  await useAuth().logout()          // 会话已在服务端失效，本地状态也清掉
  window.location.href = '/login'
}
```

**改完密码，用户会被登出**

`setPassword` 会清掉
这个用户的 **全部** 会话，包括他当前这一个。所以两个流程成功之后都要跳登录页，
不能停在原页面继续用旧登录态。

## 不要自己实现的部分

下面这些已经由平台保证，在 Func 里重写一遍只会更弱：

| 不要写 | 已经有的行为 |
| --- | --- |
| 用 `Math.random()` 生成验证码、用 `ctx.db` 或 `ctx.cache` 存验证码 | `sendCode` 生成的码默认 6 位、10 分钟有效、命中即失效、常数时间比对（位数与有效期在集成里配） |
| 自己数「验证码错了几次」 | 同一个码猜错 5 次自动作废 |
| 自己限制「多久能再发一封」 | 同一收件人 10 分钟最多 5 封，单项目每天 500 封 |
| 自己给密码做哈希，或把密码存进 JSON 表 | `setPassword` 用 bcrypt，明文不落库、不进日志 |
| 改完密码后自己去清会话 | `setPassword` 自动清掉该用户全部会话（你也没有会话表的入口） |
| 自己数「密码猜错几次」做锁定 | `checkPassword` 与登录接口共用同一份额度，错 5 次锁 1 小时 |

要你自己写的只有三件： **密码长度与强度规则**（平台只拒绝空密码）、
**凭什么允许改**（验证码、原密码，或你自己的风控），以及 **提示文案与跳转**。

## 错误对照

| 情况 | 行为 |
| --- | --- |
| `find` 没找到用户 | 返回 `null`，不抛错 |
| 验证码不对、已过期、根本没发过 | `verifyCode` 一律返回 `false`，不区分 |
| 同一收件人频繁要码 | `sendCode` 抛错（限频） |
| 项目没连邮件集成 | `sendCode` 抛错，提示去集成面板配置 |
| `setPassword` 的用户不存在 | 抛错（404）。捕获它，不要原样回给浏览器 |
| 账号只用第三方登录，没有密码 | `setPassword` 抛 404、 `checkPassword` 返回 `false`。平台不会给它新建一条密码登录方式 |
| 账号被停用 | 抛错（403）。改了密码也登不进去，所以直接失败 |
| 同一个邮箱对应多个账号 | `setPassword({ email })` 抛 409，不会改「第一个」。改用 `account` 或 `userId` |
| `checkPassword` 连续验错太多次 | 抛 429，锁 1 小时，与登录接口共用额度 |
| 三个字段填了两个以上 | 抛错（400）。 `email`、 `account`、 `userId` 只能填一个 |

## 自测

不用先发布站点。在编辑器的 Func 编辑器里直接运行方法并填入示例输入，或者用 CLI：

```bash
echo '{"email":"you@example.com"}' > input.json

creght func run --site_id=<project_id>/<site_id> \
  --key=auth/reset-password.requestCode \
  --input=input.json
```

先用一个真实注册过的邮箱跑 `requestCode`，确认收到码；再用一个没注册过的邮箱跑一次，
确认 **返回值一模一样** 而且没有收到信——这就验证了上面第二条规则。

相关： [注册时验证邮箱](/api/auth-verified-registration.md)、
[使用集成发送邮件与验证码](/api/func-email-integration.md)、
[使用 Func 构建站点后端能力](/api/func-backend.md)。

![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)

此网站使用 [Creght](/) 创建

![微信客服](https://fsu.creght.com/site/2066727200882692096/1785119134612__image.png)

微信客服

## 链接

- [价格](/price.md)
- [解决方案](/solution.md)
- [客户案例](/customers.md)
- [帮助中心](/help.md)
- [联系我们](/contact.md)
- [更新记录 & 博客](/blogs.md)
- [退款说明](/tuikuan.md)

## 资源

- [全部资源](/resources.md)
- [模板](/templates.md)
- [组件库](https://creghtlib.site.creght.com)
- [动效库](/effects.md)
- [Figma to Creght](/figma2creght.md)
- [API](/api.md)

## 产品对比

- [对比上线了](/creght-vs-sxl.md)
- [对比凡科建站](/creght-vs-fkw.md)
- [自己写代码 vs Creght](/compare/self-coding.md)
- [外包 vs 自己做](/compare/outsourcing.md)

## 协议

- [用户协议](/legal/terms.md)
- [隐私政策](/legal/privacy.md)
- [可接受使用政策](/legal/acceptable-use.md)

## 社交媒体

- [小红书](https://www.xiaohongshu.com/user/profile/5a38606811be10715f4895b6)
- [哔哩哔哩](https://space.bilibili.com/513308095)

[蜀ICP备2023038192号-2](https://beian.miit.gov.cn)
