Wasp 自定义注册 Action 完整指南:在 Email 与用户名密码认证中深度接管注册流程
【免费下载链接】waspThe batteries-included full-stack framework for the AI era. Develop JS/TS web apps (React, Node.js, and Prisma) using declarative code that abstracts away complex full-stack features like auth, background jobs, RPC, email sending, end-to-end type safety, single-command deployment, and more.项目地址: https://gitcode.com/GitHub_Trending/wa/wasp
导读
Wasp 默认的注册流程开箱即用,但当你需要在注册时执行额外的校验、写入更多的用户数据、或调用任何自定义业务代码时,可以绕过内置实现,创建一个完全由自己掌控的注册 Action(custom sign-up action)。本文将基于 Wasp 0.17 的官方文档,结合本仓库生成器源码,完整讲解 Email 与用户名密码两种认证方式下自定义注册 Action 的写法、wasp/server/auth提供的底层 API、内置校验器规则以及相关的安全注意事项,帮助你安全地深度定制注册逻辑。
:::danger 高风险提示
自定义注册 Action 是复杂且危险的高级功能,官方不建议在缺乏充分理由时使用:任何微小的疏漏都可能直接危及应用安全。在动手之前,请先确认 Wasp 的自定义认证 UI(custom auth UI)与auth hooks(认证钩子)是否已经能满足你的需求——大多数场景下,这两个更轻量、更安全的方案就足够了。
:::
同时请特别注意:使用自定义注册 Action 时,你将无法使用 Wasp 生成的认证 UI,因此必须自己实现前端界面,并在自己的界面中调用你创建的自定义 Action。
在 main.wasp 中声明自定义 Action
无论使用哪种认证方式,自定义注册 Action 的声明方式都相同:在 main.wasp(0.17 时代为main.wasp,新版本为main.wasp.ts)中新增一个action,其fn指向@src/auth/signup.js(或.ts)导出的signup函数:
// ... action customSignup { fn: import { signup } from "@src/auth/signup.js", }这一步的本质是:Wasp 会把这个action编译为一个带类型安全的远程过程调用(RPC)端点,前端可以像调用其他 Action 一样调用它(例如通过useAction(customSignup)),而signup函数内部的逻辑完全由你掌控。
Email 认证的自定义注册 Action
完整实现(JavaScript / TypeScript)
下面是与 Wasp 内置实现几乎等价的起点代码,你可以在此基础上自由定制。文档同时提供了 JS 与 TS 两种版本:
// ... action customSignup { fn: import { signup } from "@src/auth/signup.js", }import { HttpError } from 'wasp/server' import { createEmailVerificationLink, createProviderId, createUser, ensurePasswordIsPresent, ensureValidEmail, ensureValidPassword, findAuthIdentity, getProviderData, sanitizeAndSerializeProviderData, sendEmailVerificationEmail, } from 'wasp/server/auth' export const signup = async (args, _context) => { ensureValidEmail(args) ensurePasswordIsPresent(args) ensureValidPassword(args) try { const providerId = createProviderId('email', args.email) const existingAuthIdentity = await findAuthIdentity(providerId) let providerData if (existingAuthIdentity) { // User already exists, handle accordingly // For example, throw an error or return a message throw new HttpError(400, 'Email already exists.') // Or, another example, you can check if the user is already // verified and re-send the verification email if not providerData = getProviderData(existingAuthIdentity.providerData) if (providerData.isEmailVerified) { throw new HttpError(400, 'Email already verified.') } } if (!providerData) { providerData = await sanitizeAndSerializeProviderData({ // The provider will hash the password for us, so we don't need to do it here. hashedPassword: args.password, isEmailVerified: false, emailVerificationSentAt: null, passwordResetSentAt: null, }) await createUser( providerId, providerData, // Any additional data you want to store on the User entity {} ) } // Verification link links to a client route e.g. /email-verification const verificationLink = await createEmailVerificationLink( args.email, '/email-verification' ) try { await sendEmailVerificationEmail(args.email, { from: { name: 'My App Postman', email: 'hello@itsme.com', }, to: args.email, subject: 'Verify your email', text: `Click the link below to verify your email: ${verificationLink}`, html: ` <p>Click the link below to verify your email</p> <a href="${verificationLink}">Verify email</a> `, }) } catch (e) { console.error('Failed to send email verification email:', e) throw new HttpError(500, 'Failed to send email verification email.') } } catch (e) { return { success: false, message: e.message, } } // Your custom code after sign-up. // ... return { success: true, message: 'User created successfully', } }TypeScript 版本:
// ... action customSignup { fn: import { signup } from "@src/auth/signup.js", }import { HttpError } from 'wasp/server' import { createEmailVerificationLink, createProviderId, createUser, ensurePasswordIsPresent, ensureValidEmail, ensureValidPassword, findAuthIdentity, getProviderData, sanitizeAndSerializeProviderData, sendEmailVerificationEmail, } from 'wasp/server/auth' import type { CustomSignup } from 'wasp/server/operations' type CustomSignupInput = { email: string password: string } type CustomSignupOutput = { success: boolean message: string } export const signup: CustomSignup< CustomSignupInput, CustomSignupOutput > = async (args, _context) => { ensureValidEmail(args) ensurePasswordIsPresent(args) ensureValidPassword(args) try { const providerId = createProviderId('email', args.email) const existingAuthIdentity = await findAuthIdentity(providerId) let providerData if (existingAuthIdentity) { // User already exists, handle accordingly // For example, throw an error or return a message throw new HttpError(400, 'Email already exists.') // Or, another example, you can check if the user is already // verified and re-send the verification email if not providerData = getProviderData<'email'>(existingAuthIdentity.providerData) if (providerData.isEmailVerified) throw new HttpError(400, 'Email already verified.') } if (!providerData) { providerData = await sanitizeAndSerializeProviderData<'email'>({ // The provider will hash the password for us, so we don't need to do it here. hashedPassword: args.password, isEmailVerified: false, emailVerificationSentAt: null, passwordResetSentAt: null, }) await createUser( providerId, providerData, // Any additional data you want to store on the User entity {} ) } // Verification link links to a client route e.g. /email-verification const verificationLink = await createEmailVerificationLink( args.email, '/email-verification' ) try { await sendEmailVerificationEmail(args.email, { from: { name: 'My App Postman', email: 'hello@itsme.com', }, to: args.email, subject: 'Verify your email', text: `Click the link below to verify your email: ${verificationLink}`, html: ` <p>Click the link below to verify your email</p> <a href="${verificationLink}">Verify email</a> `, }) } catch (e: unknown) { console.error('Failed to send email verification email:', e) throw new HttpError(500, 'Failed to send email verification email.') } } catch (e: any) { return { success: false, message: e.message, } } // Your custom code after sign-up. // ... return { success: true, message: 'User created successfully', } }关键流程逐段拆解
- 字段校验:入口处依次调用
ensureValidEmail、ensurePasswordIsPresent、ensureValidPassword,这与 Wasp 内置注册路由 email/signup.ts 中的ensureValidArgs完全一致——这组校验器正是 Wasp 默认认证流程内部使用的同一套实现。 - 构建 Provider ID:
createProviderId('email', args.email)生成认证身份的唯一标识,Email 在存储时是大小写不敏感的(见 overview.md)。 - 查重与分支处理:
findAuthIdentity(providerId)查询是否已存在同名认证身份。文档示例给出了两种策略:直接抛出HttpError(400, 'Email already exists.');或进一步检查isEmailVerified,仅在未验证时重发验证邮件。注意原文档中if (existingAuthIdentity)分支在 throw 之后还有一段“不可达”的占位代码,实际使用时请选择其一实现。而 Wasp 内置实现(email/signup.ts)还会做防用户枚举处理:对已存在且已验证的用户执行doFakeWork()假装耗时,避免攻击者探测哪些邮箱已注册——这是你自定义实现时应当参考的安全细节。 - 写入用户与认证数据:
sanitizeAndSerializeProviderData负责把明文密码哈希化(内部通过hashPassword处理,见 server/auth/utils.ts)并序列化为 JSON 字符串;createUser(providerId, providerData, {})的第三个参数是你要额外存储到User实体的字段对象(示例为空{}),最终通过 Prisma 在User实体上级联创建Auth与AuthIdentity记录(utils.ts)。 - 发送验证邮件:
createEmailVerificationLink(args.email, '/email-verification')生成指向客户端路由的验证链接,随后sendEmailVerificationEmail发送邮件;发送失败时抛出HttpError(500, ...)。示例中的from、subject、text、html均可按需替换为你的品牌信息与邮件模板。 - 注册后自定义逻辑:在
return之前的位置插入“Your custom code after sign-up”,例如初始化用户默认资源、发送欢迎通知、接入 CRM 等。 - 统一错误返回:外层
catch把所有异常收敛为{ success: false, message }结构,正常路径返回{ success: true, message },方便前端统一处理。
用户名密码认证的自定义注册 Action
完整实现(JavaScript / TypeScript)
// ... action customSignup { fn: import { signup } from "@src/auth/signup.js", }import { createProviderId, createUser, ensurePasswordIsPresent, ensureValidPassword, ensureValidUsername, sanitizeAndSerializeProviderData, } from 'wasp/server/auth' export const signup = async (args, _context) => { ensureValidUsername(args) ensurePasswordIsPresent(args) ensureValidPassword(args) try { const providerId = createProviderId('username', args.username) const providerData = await sanitizeAndSerializeProviderData({ // The provider will hash the password for us, so we don't need to do it here. hashedPassword: args.password, }) await createUser(providerId, providerData, {}) } catch (e) { console.error('Error creating user:', e) return { success: false, message: e.message, } } return { success: true, message: 'User created successfully', } }TypeScript 版本:
// ... action customSignup { fn: import { signup } from "@src/auth/signup", }import { createProviderId, createUser, ensurePasswordIsPresent, ensureValidPassword, ensureValidUsername, sanitizeAndSerializeProviderData, } from 'wasp/server/auth' import type { CustomSignup } from 'wasp/server/operations' type CustomSignupInput = { username: string password: string } type CustomSignupOutput = { success: boolean message: string } export const signup: CustomSignup< CustomSignupInput, CustomSignupOutput > = async (args, _context) => { ensureValidUsername(args) ensurePasswordIsPresent(args) ensureValidPassword(args) try { const providerId = createProviderId('username', args.username) const providerData = await sanitizeAndSerializeProviderData<'username'>({ // The provider will hash the password for us, so we don't need to do it here. hashedPassword: args.password, }) await createUser(providerId, providerData, {}) } catch (e: any) { console.error('Error creating user:', e) return { success: false, message: e.message, } } return { success: true, message: 'User created successfully', } }与 Email 版的主要差异
- 校验入口从
ensureValidEmail换成了ensureValidUsername,其余两个校验器相同——这与 Wasp 内置实现 username/signup.ts 的ensureValidArgs保持一致。 createProviderId('username', args.username)使用用户名作为身份标识,同样大小写不敏感存储。sanitizeAndSerializeProviderData只需hashedPassword一个字段,无需邮箱验证相关字段。- 流程中没有“已存在身份”的查重分支,也没有验证邮件环节——因为用户名密码认证不涉及邮箱验证。
createUser的第三个参数同样用于传入需要额外写入User实体的字段。
校验器(Validators)API 参考
官方建议直接使用wasp/server/auth导出的内置字段校验器——这些正是 Wasp 默认认证流程内部使用的同一套实现(server/auth/index.ts 从auth/validation.js统一导出)。从源码 validation.ts 可以看到每个校验器的确切规则:
用户名
ensureValidUsername(args)校验用户名是否存在:用户名不能为空(username must be present)。注意用户名以大小写不敏感方式存储。校验失败时抛出HttpError(422, 'Validation failed', { message })。
邮箱
ensureValidEmail(args)校验邮箱:不能为空,且必须是合法邮箱地址——校验实现使用@wasp.sh/lib-auth的isValidEmail,其定义比 HTML5 语法更宽,支持 Unicode。因此官方建议前端输入框不要使用type="email"(它遵循 HTML5 语法、不接受 Unicode),而改用type="text"+inputMode="email"+autoComplete="email"。邮箱同样大小写不敏感存储。
密码
ensurePasswordIsPresent(args)校验密码是否存在:密码不能为空(password must be present)。ensureValidPassword(args)校验密码强度:长度至少 8 个字符,并且必须包含一个数字(分别对应password must be at least 8 characters与password must contain a number两条规则)。
上述默认校验规则的完整说明可参见 overview.md 的 Default Validations 小节:在使用默认认证流程(内置 Auth UI 或内置 auth actions)时这些校验自动生效,而一旦改用自定义注册 Action,就必须由你在 Action 内自行调用这些校验器。
自定义 Action 与 Wasp 底层实现的对照
为了让你清楚“该改哪里、该保留哪里”,这里把自定义实现与 Wasp 生成器内置实现做一个关键对照:
| 环节 | Wasp 内置实现 | 你的自定义实现 |
|---|---|---|
| 参数校验 | ensureValidArgs(email/signup.ts / username/signup.ts) | 同样调用ensureValidEmail/ensureValidUsername/ensurePasswordIsPresent/ensureValidPassword |
| 密码哈希 | sanitizeAndSerializeProviderData内部调用hashPassword后JSON.stringify | 相同,不要自行预先哈希密码,交给该函数处理 |
| 建号 | createUser(providerId, providerData, userFields),Prisma 级联创建Auth+AuthIdentity | 相同,第三个参数可传入你的额外用户字段 |
| 防枚举 | 对已存在且已验证的邮箱执行doFakeWork()假装耗时(utils.ts) | 需要自行考虑,避免泄露“邮箱已注册”这一信息 |
| 钩子 | 内置路由会触发onBeforeSignupHook/onAfterSignupHook(见 email/signup.ts) | 自定义 Action不会自动触发这些钩子,如有需要请自行在代码中调用 |
| 错误处理 | rethrowPossibleAuthError把 Prisma P2002(唯一约束冲突)、P2003(外键失败)、P2021(缺表)等转换为 4xx/5xx(utils.ts) | 需要自行处理唯一约束等数据库错误,文档示例采用{ success: false, message }结构统一返回 |
一个重要的实际提醒:自定义 Action 绕过了onBeforeSignupHook与onAfterSignupHook。如果你原本依赖这些钩子做校验或联动(例如邀请码校验、欢迎邮件),请把它们的能力内联到自定义 Action 中,否则这些逻辑会悄然失效。
何时应该使用自定义注册 Action
结合 overview.md 的认证使用方式与本文文档,决策路径建议如下:
- 需要调整 UI→ 使用自定义认证 UI(各认证方法的 create-your-own-ui 文档),这是官方推荐路径。
- 需要在注册前后挂接逻辑→ 使用 auth hooks,例如
onBeforeSignupHook、onAfterSignupHook。 - 需要补充用户字段(如姓名、地址)→ 使用
auth.methods.{method}.userSignupFields配合defineUserSignupFields(见 overview.md 的 Customizing the Signup Process),字段值会在内置流程中自动写入User实体。 - 以上都不够,必须完全接管注册控制流(例如深度定制的多步注册、与外部身份系统联动、完全自定义的建号策略)→ 才考虑自定义注册 Action。
也就是说,自定义 Action 是最后的手段,它把注册的每一步控制权都交到你手上,同时也把所有安全责任交给了你。如果确实决定使用,请务必:保留全套字段校验、把密码交给sanitizeAndSerializeProviderData哈希、妥善处理“用户已存在”的分支与数据库唯一约束冲突,并在生产环境前对注册流程做完整的安全审查。
【免费下载链接】waspThe batteries-included full-stack framework for the AI era. Develop JS/TS web apps (React, Node.js, and Prisma) using declarative code that abstracts away complex full-stack features like auth, background jobs, RPC, email sending, end-to-end type safety, single-command deployment, and more.项目地址: https://gitcode.com/GitHub_Trending/wa/wasp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考