ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

RedwoodJS dbAuth 无密码登录(Passwordless)实战:用邮箱验证码替代密码存储

RedwoodJS dbAuth 无密码登录(Passwordless)实战:用邮箱验证码替代密码存储 后端前端Web框架开发工具【免费下载链接】redwoodRedwoodGraphQL项目地址https://gitcode.com/gh_mirrors/re/redwood点击查看免费下载本文是一份完整的实战指南讲解如何基于 RedwoodJS 内置的 dbAuth 认证方案将传统的用户名 密码登录改造为仅需邮箱地址的无密码Passwordless登录系统向用户邮箱发送一次性、有时效性的验证码用户凭验证码即可完成登录。文中逐步覆盖 Prisma 数据模型改造、generateLoginToken服务函数、GraphQL SDL 暴露、auth.js认证函数定制、前端登录/验证码表单与页面、路由切换并深入packages/auth-providers/dbAuth源码说明 dbAuth 的登录校验链路帮助你在完全不依赖任何第三方认证服务的前提下把用户数据的采集面收敛到最小。为什么需要无密码登录RedwoodJS 自带的 dbAuth 是一种完全自托管的认证方案用户凭据存放在你自己的数据库里登录、注册、忘记密码页面都可以自己掌控不依赖任何第三方认证服务也没有按用户数计费的隐形成本。它的核心缺点是你自己负责保管用户密码——尽管 Redwood 遵循最佳实践密码经加盐 PBKDF2 哈希后存储见packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts中的hashPassword与legacyHashPassword调用。如果你不希望采集用户的密码无密码登录是一个理想的替代方案用随机生成的、有时效性的 token 取代密码。整个系统唯一需要收集的用户数据只有邮箱地址——即使数据库被攻破攻击者能拿到的也只是一个邮箱而不是可用于撞库的密码。无密码认证的工作原理无密码认证passwordless authentication的核心思想是用户输入邮箱地址系统生成一个随机 token而非密码并连同过期时间一起存入数据库系统通过邮件把 token或携带 token 的链接发送给用户用户点击链接或输入 token系统校验其有效性存在且未过期后直接为其建立登录会话登录成功后 token 即被销毁成为一次性凭证。在本方案中dbAuth 原有的密码哈希 salt字段被复用为token 哈希 salthashedPassword字段存储的是 token 的哈希值salt字段在登录成功后会被清空使 token 只能使用一次。前提说明本方案需要向用户发送邮件因此你还需要先搭建邮件发送能力。Redwood 官方提供了 Sending Emails 指南基于 nodemailer也可以用你熟悉的任意邮件服务。第 1 步修改 Prisma schema首先打开api/db/schema.prisma为User模型增加三个字段。改造后的模型如下高亮部分为新增字段model User { id Int id default(autoincrement()) name String? email String unique loginToken String loginTokenExpiresAt DateTime? salt String? }字段说明loginToken存储 token 的哈希值注意不是明文 token原理与密码存储一致loginTokenExpiresAttoken 的过期时间用于校验 token 是否仍然有效salt生成 token 哈希时使用的盐。与标准 dbAuth 不同的是这里它是可选的String?因为在无密码模式下登录成功后我们会清空 salt 来使 token 一次性失效。完成修改后执行迁移命令让数据库结构生效yarn rw prisma migrate dev第 2 步创建 generateLoginToken 服务函数接下来需要创建一个 API 端服务函数负责生成 token 并写入数据库。如果你还没有 users 服务先用生成器创建yarn rw g service users然后编辑api/src/services/users/users.js在文件顶部引入 CryptoJS并在文件末尾添加generateLoginToken函数// add this import to the top of the file import CryptoJS from crypto-js // add this to the bottom of the file export const generateLoginToken async ({ email }) { try { // look up if the user exists let lookupUser await db.user.findFirst({ where: { email } }) if (!lookupUser) { return { message: Login Request received } } // here were going to generate a random password of 6 numbers let randomNumber (() { let random CryptoJS.lib.WordArray.random(6) let randomString random.toString() let sixDigitNumber randomString.replace(/\D/g, ) if (sixDigitNumber.length 6) { sixDigitNumber sixDigitNumber.padStart(6, 0) } if (sixDigitNumber.length 6) { sixDigitNumber sixDigitNumber.slice(0, 6) } return sixDigitNumber.toString() })() console.log({ randomNumber }) // email the user this number let salt CryptoJS.lib.WordArray.random(30) let loginToken CryptoJS.PBKDF2(randomNumber, salt, { keySize: 256 / 32, }).toString() // now well update the user with the new salt and loginToken let loginTokenExpiresAt new Date() loginTokenExpiresAt.setMinutes(loginTokenExpiresAt.getMinutes() 15) let data { salt, loginToken, loginTokenExpiresAt, } await db.user.update({ where: { id: lookupUser.id }, data, }) return { message: Login Request received } } catch (error) { console.log({ error }) throw new UserInputError(error.message) } }这段代码的关键点随机 6 位数字验证码CryptoJS.lib.WordArray.random(6)生成随机字节串再过滤非数字字符并补齐/截断为 6 位绝不存储明文 tokenCryptoJS.PBKDF2(randomNumber, salt, { keySize: 256 / 32 })使用 PBKDF2 派生密钥数据库中只保存 token 的哈希值——这与 dbAuth 对密码的处理方式完全一致15 分钟有效期loginTokenExpiresAt被设置为当前时间 15 分钟不暴露用户是否存在无论邮箱是否注册都返回{ message: Login Request received }避免被用来枚举有效邮箱邮件发送点代码中的console.log({ randomNumber })是示意位置实际生产环境中应在此处调用邮件服务把验证码发给用户参考 Sending Emails同时可以删除该日志。第 3 步把 generateToken 加入 SDL 并隐藏 loginToken为了让前端能够调用这个函数需要把它暴露到 GraphQL。同时注意loginToken、loginTokenExpiresAt、salt这些字段绝不能出现在 SDL 中否则任何客户端都能通过 GraphQL 查询拿到 token 哈希与盐。编辑api/src/graphql/users.sdl.js或 TypeScript 项目中的users.sdl.tsexport const schema gql type User { id: Int! name: String email: String! } input CreateUserInput { name: String email: String! } input UpdateUserInput { name: String email: String! } type userTokenResponse { message: String! } type Mutation { createUser(input: CreateUserInput!): User! requireAuth updateUser(id: Int!, input: UpdateUserInput!): User! requireAuth deleteUser(id: Int!): User! requireAuth generateToken(email: String!): userTokenResponse! skipAuth } 注意User类型只暴露id、name、email三个安全字段generateTokenmutation 使用skipAuth指令因为用户此刻尚未登录调用方匿名即可请求验证码其余 mutation 保持requireAuth要求登录后才能操作。第 4 步改造 auth 函数核心改动集中在api/src/functions/auth.js——这是 dbAuth 的入口DbAuthHandler实例化处。在无密码模式下我们需要在loginOptions.handler中校验 token 是否过期并在成功后清空 salt 与过期时间让 token 一次性生效在signupOptions.handler中把username即邮箱写入email字段把传入的hashedPassword此时是随机字符串见第 8 步写入loginToken字段通过authFields告诉 dbAuthhashedPassword字段对应数据库中的loginToken。// ... other functions const loginOptions { handler: async (user) { let loginExpiresAt new Date(user?.loginTokenExpiresAt) let now new Date() if (loginExpiresAt now) { throw Login token expired } // if the user logged in with a token we need to break // the token. Well do this by clearing the salt and // expiration // this will make the token a one-time use db.user.update({ where: { id: user.id }, data: { loginTokenExpiresAt: null, salt: null, }, }) return user }, errors: { // here I modified the following, feel free to modify the other messages incorrectPassword: Incorrect token, }, } // we also need to update the signupOptions const signupOptions { handler: ({ username, hashedPassword, userAttributes }) { return db.user.create({ data: { email: username, loginToken: hashedPassword, salt: null, name: userAttributes.name, }, }) }, // ... other stuff } // and last we need to update the authFields const authHandler new DbAuthHandler(event, context, { db: db, authModelAccessor: user, authFields: { id: id, hashedPassword: loginToken, salt: salt, }, // ... other stuff })对源码的印证查看 DbAuthHandler.ts 可以发现login()方法的执行链路是先_verifyUser(username, password)校验用户与口令 → 再调用loginOptions.handler(user)→ 最后_loginResponse(handlerUser)。也就是说handler是在口令校验通过之后、建立会话之前执行的——这正是我们在这里做过期检查 一次性销毁的时机窗口口令校验环节_verifyPassword会读取authFields.hashedPassword指向的字段此处即loginToken与用户提交的验证码哈希比对同时用authFields.salt指定的字段参与哈希计算我们通过authFields的映射hashedPassword: loginToken完整复用了这套 PBKDF2 加盐哈希校验机制无需改动 dbAuth 底层的口令比对逻辑校验通过后handler若抛出异常异常信息会经由logIn()以{ error: ... }的形式返回前端参见 dbAuth.ts 前端客户端 的login实现。此外_verifyPassword在数据库中找不到匹配哈希时会抛出IncorrectPasswordError其消息文案正是通过loginOptions.errors.incorrectPassword定制的——把它改成Incorrect token会让用户看到与无密码流程一致的语义。第 5 步制作邮箱输入表单前端部分开始。先用生成器创建组件yarn rw g component LoginPasswordlessForm这会生成web/src/components/LoginPasswordlessForm/LoginPasswordlessForm.js。将其内容替换为import { Form, Label, TextField, PasswordField, Submit, FieldError, } from redwoodjs/forms import { navigate, routes, Link } from redwoodjs/router import { MetaTags, useMutation } from redwoodjs/web import { Toaster, toast } from redwoodjs/web/toast const GENERATE_LOGIN_TOKEN gql mutation generateLoginToken($email: String!) { generateLoginToken(email: $email) { message } } const LoginPasswordlessForm ({ setWaitingForCode, setEmail }) { const [generateLoginToken] useMutation( GENERATE_LOGIN_TOKEN, { onCompleted: () { toast.success(Check your email for a login link) setWaitingForCode(true) }, } ) const onSubmit async (data) { setEmail(data.email) const response await generateLoginToken({ variables: { email: data.email }, fetchPolicy: no-cache, }) if (response.error) { toast.error(response.error) } } return ( MetaTags titleLogin / main classNamerw-main Toaster toastOptions{{ className: rw-toast, duration: 6000 }} / div classNamerw-scaffold rw-login-container div classNamerw-segment header classNamerw-segment-header h2 classNamerw-heading rw-heading-secondaryLogin/h2 /header div classNamerw-segment-main div classNamerw-form-wrapper Form onSubmit{onSubmit} classNamerw-form-wrapper Label nameemail classNamerw-label errorClassNamerw-label rw-label-error Email /Label TextField nameemail classNamerw-input errorClassNamerw-input rw-input-error validation{{ required: { value: true, message: Email is required, }, }} / FieldError nameemail classNamerw-field-error / div classNamerw-button-group Submit classNamerw-button rw-button-blue Send Token /Submit /div /Form /div /div div classNamerw-login-link spanDonapos;t have an account?/span{ } Link to{routes.signup()} classNamerw-link Sign up! /Link /div /div /div /main / ) } export default LoginPasswordlessForm要点该表单只收集邮箱通过GENERATE_LOGIN_TOKENmutation 调用第 2、3 步实现的generateTokenfetchPolicy: no-cache确保每次都发起真实请求避免 Apollo 缓存导致重复点击时跳过服务端调用调用成功后将通过setWaitingForCode(true)把控制权交给父页面切换到验证码输入阶段表单 UI 复用了 Redwood 脚手架scaffold的 CSS 类名与yarn rw g dbAuth生成的默认登录页风格保持一致。第 6 步制作验证码输入表单再创建一个接收验证码的组件yarn rw g component LoginPasswordlessTokenForm编辑web/src/components/LoginPasswordlessTokenForm/LoginPasswordlessTokenForm.jsimport { useEffect, useRef } from react import { Form, Label, TextField, PasswordField, Submit, FieldError, } from redwoodjs/forms import { navigate, routes, Link } from redwoodjs/router import { MetaTags, useMutation } from redwoodjs/web import { Toaster, toast } from redwoodjs/web/toast import { useAuth } from src/auth const LoginPasswordlessTokenForm ({ setWaitingForCode, email, code }) { const { isAuthenticated, logIn } useAuth() useEffect(() { if (isAuthenticated) { navigate(routes.home()) } if (email code) { console.log(email, email) logIn({ username: email, password: code }) } }, [isAuthenticated, email, code, logIn]) const onSubmit async (data) { // login expects a username and password for dbauth // so we are passing them. const response await logIn({ username: email, password: data.loginToken }) if (response.error) { toast.error(response.error) } } return ( MetaTags titleLogin / main classNamerw-main Toaster toastOptions{{ className: rw-toast, duration: 6000 }} / div classNamerw-scaffold rw-login-container div classNamerw-segment header classNamerw-segment-header h2 classNamerw-heading rw-heading-secondary Login with Token /h2 /header div classNamerw-segment-main div classNamerw-form-wrapper Form onSubmit{onSubmit} classNamerw-form-wrapper Label nameemail classNamerw-label errorClassNamerw-label rw-label-error Email /Label TextField nameemail classNamerw-input errorClassNamerw-input rw-input-error readOnly{true} defaultValue{email} / FieldError nameemail classNamerw-field-error / Label nameloginToken classNamerw-label errorClassNamerw-label rw-label-error Token /Label TextField nameloginToken classNamerw-input errorClassNamerw-input rw-input-error / FieldError nameloginToken classNamerw-field-error / div classNamerw-button-group Submit classNamerw-button rw-button-blueLogin/Submit /div div classNamerw-button-group button classNamerw-button rw-button-blue onClick{() { setWaitingForCode(false) }} Get another Token /button /div /Form /div /div div classNamerw-login-link spanDonapos;t have an account?/span{ } Link to{routes.signup()} classNamerw-link Sign up! /Link /div /div /div /main / ) } export default LoginPasswordlessTokenForm这段代码的关键在于复用 dbAuth 的登录接口logIn({ username: email, password: data.loginToken })——dbAuth 的登录接口约定接收username和password在无密码模式下我们把邮箱当作 username、把验证码当作 password 传入。前端login函数会将二者连同method: login一起 POST 到 dbAuth 端点见 dbAuth.tsemail字段为只读readOnly{true}防止用户在拿到验证码后修改邮箱Get another Token 按钮通过setWaitingForCode(false)回到第一步重新请求验证码。第 7 步创建新的登录页两个表单的状态由父页面统一控制。生成新页面yarn rw g page LoginPasswordless编辑web/src/pages/LoginPasswordlessPage/LoginPasswordlessPage.jsimport { useEffect, useState } from react import { useLocation } from redwoodjs/router import { MetaTags } from redwoodjs/web import LoginPasswordlessForm from src/components/LoginPasswordlessForm/LoginPasswordlessForm import LoginPasswordlessTokenForm from src/components/LoginPasswordlessTokenForm/LoginPasswordlessTokenForm const LoginPasswordlessPage () { let [waitingForCode, setWaitingForCode] useState(false) let [email, setEmail] useState() let [code, setCode] useState() // onload set email from query string let { search } useLocation() useEffect(() { let params new URLSearchParams(search) // decode magic param let magic params.get(magic) let decoded window.atob(params.get(magic)) // if magic param exists, set email and waitingForCode if (magic) { // decoded is email:code let [email, code] decoded.split(:) setEmail(email) setCode(code) setWaitingForCode(true) } }, [search]) return ( MetaTags titleLoginPasswordless descriptionLoginPasswordless page / {!waitingForCode ( LoginPasswordlessForm setWaitingForCode{setWaitingForCode} setEmail{setEmail} / )} {waitingForCode ( LoginPasswordlessTokenForm email{email} setWaitingForCode{setWaitingForCode} code{code} / )} / ) } export default LoginPasswordlessPage这个页面实现了一种可选的魔法链接magic link体验邮件中可以附带一个形如https://your-site.com/login?magicbase64(邮箱:验证码)的链接页面加载时用URLSearchParams解析magic参数通过window.atob解码出email:code自动填入表单并直接触发logIn见第 6 步组件中useEffect对email code的判断没有魔法链接时页面退化为先输入邮箱 → 再输入验证码的两步流程由waitingForCode状态驱动。提示magic 链接中携带的是明文邮箱与验证码请确保站点使用 HTTPS并注意链接在邮件中的时效性与 token 的 15 分钟有效期保持一致。第 8 步改造注册页注册页不再要求设置密码只需收集邮箱。dbAuth 的signUp()需要username与password两个参数因此我们用浏览器随机生成一个无意义的临时密码填充它会被signupOptions.handler作为loginToken存入数据库但用户永远不会知道它也不会用它登录import { useRef } from react import { useEffect } from react import { Form, Label, TextField, PasswordField, FieldError, Submit, } from redwoodjs/forms import { Link, navigate, routes } from redwoodjs/router import { MetaTags } from redwoodjs/web import { toast, Toaster } from redwoodjs/web/toast import { useAuth } from src/auth const SignupPage () { const { isAuthenticated, signUp } useAuth() let randomString (length) { if (typeof length undefined) length 32 const characterSet ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 const array new Uint8Array(length) window.crypto.getRandomValues(array) const returnString Array.from(array) .map((value) characterSet[value % characterSet.length]) .join() return returnString } useEffect(() { if (isAuthenticated) { navigate(routes.home()) } }, [isAuthenticated]) // focus on username box on page load const emailRef useRef(null) useEffect(() { emailRef.current?.focus() }, []) const onSubmit async (data) { const response await signUp({ username: data.email, password: randomString(), // this is a random string and is not important }) if (response.message) { toast(response.message) } else if (response.error) { toast.error(response.error) } else { // user is signed in automatically toast.success(Welcome!) } } return ( MetaTags titleSignup / main classNamerw-main Toaster toastOptions{{ className: rw-toast, duration: 6000 }} / div classNamerw-scaffold rw-login-container div classNamerw-segment header classNamerw-segment-header h2 classNamerw-heading rw-heading-secondarySignup/h2 /header div classNamerw-segment-main div classNamerw-form-wrapper Form onSubmit{onSubmit} classNamerw-form-wrapper Label nameemail classNamerw-label errorClassNamerw-label rw-label-error Email /Label TextField nameemail classNamerw-input errorClassNamerw-input rw-input-error ref{emailRef} validation{{ required: { value: true, message: Email is required, }, }} / FieldError nameemail classNamerw-field-error / div classNamerw-button-group Submit classNamerw-button rw-button-blue Sign Up /Submit /div /Form /div /div div classNamerw-login-link spanAlready have an account?/span{ } Link to{routes.login()} classNamerw-link Log in! /Link /div /div /div /main / ) } export default SignupPage结合源码看注册链路_createUser()会先做三件事——校验username/password非空、按usernameMatch规则检查邮箱是否已存在、调用signupOptions.passwordValidation然后才执行signupOptions.handler({ username, hashedPassword, salt, userAttributes })。在无密码模式下随机临时密码经hashPassword(password)加盐哈希后作为hashedPassword传入 handler再由 handler 写入loginToken字段handler 同时把salt显式置为null因为后续真正的登录 token 会重新生成盐如果 handler 返回了用户对象dbAuth 会调用_loginResponse自动登录该用户见signup()所以注册成功后用户会直接进入站点——之后再走登录流程请求验证码即可。第 9 步更新路由最后把登录路由指向新的无密码登录页const Routes () { // other stuff return ( Router useAuth{useAuth} Route path/login page{LoginPasswordlessPage} namelogin / Route path/signup page{SignupPage} namesignup / {/*other routes*/} /Router ) }注意需要把LoginPasswordlessPage与SignupPage引入到Routes.js顶部路由的namelogin、signup保持不变这样第 5、6 步表单中routes.login()、routes.signup()的引用无需改动注册后新用户的首次登录体验建议注册成功即自动登录handler 返回 user或引导用户回到登录页输入邮箱请求验证码。无密码流程的整体时序把九步串联起来一次完整的无密码登录请求是用户在登录页输入邮箱前端调用generateTokenGraphQL mutationskipAuth服务端generateLoginToken生成 6 位随机码经 PBKDF2 加盐哈希后连同 15 分钟有效期写入User表并把验证码通过邮件发给用户用户输入验证码或点击 magic link前端调用logIn({ username: email, password: code })dbAuth 端点收到method: login请求_verifyUser用authFields.hashedPassword映射到loginToken与authFields.salt完成 PBKDF2 哈希比对见 DbAuthHandler.ts比对通过后执行loginOptions.handler校验loginTokenExpiresAt未过期随即清空salt与过期时间——token 作废不可再次使用dbAuth 签发 HttpOnly 会话 Cookie用户登录成功。安全注意事项一次性 token登录成功后清空salt使 token 哈希无法被再次校验这是防止 token 重放的关键时效性15 分钟有效期由loginTokenExpiresAt保证过期后handler抛出Login token expired用户需重新请求验证码不存明文数据库只保存 PBKDF2 哈希即使泄露也无法直接还原验证码不暴露用户存在性generateLoginToken对未注册邮箱同样返回成功提示配合第 4 步错误文案的定制可有效对抗邮箱枚举最小数据面整个系统中用户只需提供邮箱即便数据库被攻破攻击者拿到的也仅仅是邮箱地址而非可用于撞库的密码哈希日志脱敏如果沿用标准 dbAuth 的日志配置注意console.log({ randomNumber })这类调试输出在生产环境必须移除避免验证码泄漏到日志dbAuth 的 logger 会默认脱敏敏感参数见 logger 文档 的 Redaction 一节。小结通过以上九个步骤你已经在完全自托管的 dbAuth 之上实现了无密码认证数据库只存储 token 哈希与过期时间用户唯一需要提供的是邮箱验证码一次性且有时效。这套方案没有引入任何第三方认证依赖与 dbAuth 既有的 Cookie 会话、GraphQL 鉴权requireAuth/skipAuth机制完全兼容。若后续需要更强的防重放能力还可参考 dbAuth 的 WebAuthn 支持基于UserCredential模型与webAuthn配置实现 Touch ID / Face ID 等生物识别登录。赞分享后端前端Web框架开发工具【免费下载链接】redwoodRedwoodGraphQL项目地址https://gitcode.com/gh_mirrors/re/redwood点击查看免费下载相关推荐Redwood dbAuth 免密登录Passwordless实战指南用邮箱验证码替代密码Redwood dbAuth 免密登录Passwordless实战指南用邮箱验证码替代密码 导读 本指南以 Redwood 内置的 dbAuth 认证方案后端前端Web框架开发工具Redwood 无密码认证实战基于 dbAuth 搭建 Passwordless 登录Redwood 无密码认证实战基于 dbAuth 搭建 Passwordless 登录 在 Redwood 应用中安全性始终是首要考虑。当你不希望引入第三方后端前端Web框架开发工具Redwood 无密码认证实战基于 dbAuth 的邮箱登录令牌方案Redwood 无密码认证实战基于 dbAuth 的邮箱登录令牌方案 本指南面向使用 Redwood 框架的开发者讲解如何将默认的 dbAuth 用户名/密后端前端Web框架开发工具上一篇3个实战技巧Xenos进程注入工具实现动态链接库加载下一篇教育网和海外服务器如何配置Linux软件源LinuxMirrors --edu与--abroad模式使用指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表