ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

如何在 Medusa 中实现手机号短信登录(Phone Authentication)?

如何在 Medusa 中实现手机号短信登录(Phone Authentication)? 如何在 Medusa 中实现手机号短信登录Phone Authentication【免费下载链接】medusaThe worlds most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa这篇文章解决一个具体任务在 Medusa 应用中让顾客用「手机号 OTP一次性密码」登录并通过 Twilio 把 OTP 以短信形式发给顾客。完成后注册和登录链路会使用你自定义的phone-auth认证方式提交手机号生成 OTP验证 OTP 后返回可用于后续请求的 JWT token。整个实现基于一个自定义的 Authentication Module Provider、一个 Twilio Notification Module Provider 和一个事件 subscriber并可通过 API 请求完整验证。适用前提来自官方教程Node.js v20.19.0 或 v22.12.0Git CLIPostgreSQLTwilio 账号以及 Twilio Account SID、Auth Token 和一个用于发送短信的 Twilio 号码Twilio 只是发送 OTP 的一种方式。文档明确说明你可以换成其他短信服务商或任意其他发送 OTP 的方法。1. 安装 Medusa 应用npx create-medusa-applatest先输入项目名当被问到是否安装 Next.js Starter Storefront 时选择 Yes。安装完成后应用以 monorepo 形式安装后端在apps/backend目录Next.js Starter Storefront 在apps/storefront目录。安装成功会自动打开 Medusa Admin 面板按表单创建一个管理员用户后即可登录。后文所有后端自定义文件的路径都相对于apps/backend目录。如果安装过程报错可参考仓库中的 create-medusa-app 排错文档。2. 实现 Phone Authentication Module ProviderMedusa 通过 Authentication Module Provider 集成自定义认证逻辑。Provider 只负责认证本身不负责创建或管理顾客数据那由 Customer Module 完成。完整方法说明见 Auth Module Provider 文档。2.1 安装 jsonwebtokenProvider 用jsonwebtoken来签名和校验 OTPnpm install jsonwebtoken npm install types/jsonwebtoken --save-dev2.2 创建服务骨架创建src/modules/phone-auth/service.tsimport { AbstractAuthModuleProvider, AbstractEventBusModuleService, } from medusajs/framework/utils import { Logger, } from medusajs/types type InjectedDependencies { logger: Logger event_bus: AbstractEventBusModuleService } type Options { jwtSecret: string } class PhoneAuthService extends AbstractAuthModuleProvider { static DISPLAY_NAME Phone Auth static identifier phone-auth private options: Options private logger: Logger private event_bus: AbstractEventBusModuleService constructor(container: InjectedDependencies, options: Options) { // ts-ignore super(...arguments) this.options options this.logger container.logger this.event_bus container.event_bus } } export default PhoneAuthService服务必须继承AbstractAuthModuleProvider类并带两个静态属性identifierProvider 唯一标识和DISPLAY_NAME展示名称。构造函数的container提供模块可用资源这里用到logger打调试日志、event_bus发事件options是注册时传入的选项这里定义jwtSecret用于签名和校验 OTP。写完后会看到类型错误提示需要实现抽象方法下面的步骤逐个补齐。2.3 实现 validateOptionsvalidateOptions用于校验传给 Provider 的选项如果它抛错Medusa 应用将无法启动// other imports... import { MedusaError, } from medusajs/framework/utils class PhoneAuthService extends AbstractAuthModuleProvider { // ... static validateOptions(options: Recordany, any): void | never { if (!options.jwtSecret) { throw new MedusaError( MedusaError.Types.INVALID_DATA, JWT secret is required ) } } }即jwtSecret未配置时直接阻止应用启动而不是带着缺失的密钥运行。2.4 实现 register顾客或其他 actor 类型在应用中注册时必须同时拥有一个 auth identity 才能登录。register方法用自定义逻辑为该 actor 类型创建 auth identity// other imports... import { AuthenticationInput, AuthIdentityProviderService, AuthenticationResponse, } from medusajs/types class PhoneAuthService extends AbstractAuthModuleProvider { // ... async register( data: AuthenticationInput, authIdentityProviderService: AuthIdentityProviderService ): PromiseAuthenticationResponse { const { phone } data.body || {} if (!phone) { return { success: false, error: Phone number is required, } } try { await authIdentityProviderService.retrieve({ entity_id: phone, }) return { success: false, error: User with phone number already exists, } } catch (error) { const user await authIdentityProviderService.create({ entity_id: phone, }) return { success: true, authIdentity: user, } } } }逻辑要点从请求体提取phone缺失时返回Phone number is required先用手机号entity_id查询是否已存在 auth identity存在则返回User with phone number already exists否则用该手机号作为entity_id创建新的 auth identity并把创建结果放在authIdentity字段返回。2.5 实现 authenticate 与 generateOTP登录走的是「带 callback 校验」的两步认证authenticate先确认用户存在并生成 OTP然后用户凭 OTP 走 callback 验证。// other imports... import { AuthIdentityDTO, } from medusajs/types import jwt from jsonwebtoken class PhoneAuthService extends AbstractAuthModuleProvider { // ... async authenticate( data: AuthenticationInput, authIdentityProviderService: AuthIdentityProviderService ): PromiseAuthenticationResponse { const { phone } data.body || {} if (!phone) { return { success: false, error: Phone number is required, } } try { await authIdentityProviderService.retrieve({ entity_id: phone, }) } catch (error) { return { success: false, error: User with phone number does not exist, } } const { hashedOTP, otp } await this.generateOTP() await authIdentityProviderService.update(phone, { provider_metadata: { otp: hashedOTP, }, }) await this.event_bus.emit({ name: phone-auth.otp.generated, data: { otp, phone, }, }, {}) return { success: true, location: otp, } } async generateOTP(): Promise{ hashedOTP: string, otp: string } { // Generate a 6-digit OTP const otp Math.floor(100000 Math.random() * 900000).toString() // for debug this.logger.info(Generated OTP: ${otp}) const hashedOTP jwt.sign({ otp }, this.options.jwtSecret, { expiresIn: 60s, }) return { hashedOTP, otp } } }这个方法做了四件事确认手机号对应的用户存在用generateOTP生成一个 6 位 OTP并用jwtSecret签名后存入用户 auth identity 的provider_metadata.otp过期时间 60 秒发出phone-auth.otp.generated事件携带otp和phone后续用 Twilio 发短信就是监听这个事件最后返回location: otp表示还需要用户完成 OTP 验证。代码里this.logger.info(\Generated OTP: ...) 是调试用的日志输出集成 Twilio 之后应当删除见第 4 节。2.6 实现 validateCallbackvalidateCallback用于校验用户输入的 OTP校验通过后Medusa 会向调用方返回 JWT tokenclass PhoneAuthService extends AbstractAuthModuleProvider { // ... async validateCallback( data: AuthenticationInput, authIdentityProviderService: AuthIdentityProviderService ): PromiseAuthenticationResponse { const { phone, otp } data.query || {} if (!phone || !otp) { return { success: false, error: Phone number and OTP are required, } } const user await authIdentityProviderService.retrieve({ entity_id: phone, }) if (!user) { return { success: false, error: User with phone number does not exist, } } // verify that OTP is correct const userProvider user.provider_identities?.find((provider) provider.provider this.identifier) if (!userProvider || !userProvider.provider_metadata?.otp) { return { success: false, error: User with phone number does not have a phone auth provider, } } try { const decodedOTP jwt.verify( userProvider.provider_metadata.otp as string, this.options.jwtSecret ) as { otp: string } if (decodedOTP.otp ! otp) { throw new Error(Invalid OTP) } } catch (error) { return { success: false, error: error.message || Invalid OTP, } } const updatedUser await authIdentityProviderService.update(phone, { provider_metadata: { otp: null, }, }) return { success: true, authIdentity: updatedUser, } } }校验从请求 query 中取phone和otp用jwt.verify核对存储的 OTP 签名因为签名设置了 60 秒过期超时需要重新请求 OTP验证成功后把provider_metadata.otp置为null清理掉。2.7 导出模块定义在src/modules/phone-auth/index.ts导出 Provider 定义import PhoneAuthService from ./service import { ModuleProvider, Modules, } from medusajs/framework/utils export default ModuleProvider(Modules.AUTH, { services: [PhoneAuthService], })ModuleProvider的第一个参数声明该 Provider 属于哪个模块这里是Modules.AUTH第二个参数的services中列出的每个服务都会注册为认证 Provider。2.8 注册到 medusa-config.ts在medusa-config.ts中添加modules配置把 Provider 注册给 Auth Module// other imports... import { Modules, ContainerRegistrationKeys } from medusajs/framework/utils module.exports defineConfig({ // ... modules: [ { resolve: medusajs/medusa/auth, dependencies: [ Modules.CACHE, ContainerRegistrationKeys.LOGGER, Modules.EVENT_BUS, ], options: { providers: [ // default provider { resolve: medusajs/medusa/auth-emailpass, id: emailpass, }, { resolve: ./src/modules/phone-auth, id: phone-auth, options: { jwtSecret: process.env.PHONE_AUTH_JWT_SECRET || supersecret, }, }, ], }, }, ], })几个关键项dependencies中除 Auth Module 必需的 Cache Module 和 Logger 外这里额外注入了Modules.EVENT_BUS因为 Provider 用到了event_busid: phone-auth决定了 API 路由中使用的 Provider 标识options就是服务里定义的jwtSecret文档示例从环境变量PHONE_AUTH_JWT_SECRET读取缺省为supersecret。接着要在projectConfig.http中声明哪种 actor 类型可以用它module.exports defineConfig({ projectConfig: { // ... http: { // ... authMethodsPerActor: { user: [emailpass], customer: [emailpass, phone-auth], }, }, }, // ... })authMethodsPerActor的键是 actor 类型值是该类型可用的认证方式 ID 数组。这里为customer开启了phone-auth也可以同样方式为user、vendor等其他 actor 类型开启。3. 通过 API 验证手机号认证启动应用npm run start请求/store开头的路由需要 publishable API key打开http://localhost:9000/admin登录进入 Settings - Publishable API Keys点击表格中的 API key在其详情页复制该 key。下面命令中的{publishable_api_key}替换为这个值{reg_token}替换为第 3.1 步返回的 token。文档示例使用手机号19077890116请替换成你自己的号码。3.1 获取注册 tokencurl -X POST http://localhost:9000/auth/customer/phone-auth/register \ --header Content-Type: application/json \ --data { phone: 19077890116 }响应示例token 为文档示例值{ token: 123... }3.2 注册顾客用注册 token 作为 Bearer 认证向/store/customers注册顾客curl -X POST http://localhost:9000/store/customers \ --header x-publishable-api-key: {publishable_api_key} \ --header Content-Type: application/json \ --header Authorization: Bearer {reg_token} \ --data-raw { email: 19077890116gmail.com, phone: 19077890116, first_name: John, last_name: Smith }成功时返回创建的顾客详情文档示例{ customer: { id: cus_01JVPESW5SM1MSVPNM2MSC0ZEC, email: 19077890116gmail.com, first_name: John, last_name: Smith, phone: 19077890116, has_account: true, addresses: [] } }注意email是 Register Customer API 路由的必填字段文档的做法是「手机号 gmail.com」拼一个占位邮箱phone用与注册 token 相同的号码。注册成功后该顾客即可用手机号 OTP 认证。3.3 发起认证获取 OTPcurl -X POST http://localhost:9000/auth/customer/phone-auth \ --header Content-Type: application/json \ --data { phone: 19077890116 }响应示例{ location: otp }location: otp表示需要继续做 OTP 验证。此时查看 Medusa 应用日志应能看到调试输出的 OTP文档示例info: Generated OTP: 576794同一路由也可以用来重新发送 OTP用户没收到或距离上次发送已超一分钟。3.4 校验 OTP拿到 JWT向 callback 路由提交手机号和 OTP。phone参数中的需要 URL 编码为%2Botp用日志里显示的那个码下面476588是文档示例值curl -X POST http://localhost:9000/auth/customer/phone-auth/callback?phone%2B19077890116otp476588OTP 有效时响应示例{ token: 123... }这个 JWT 就是后续请求的认证凭据例如可以用来调用获取顾客详情的接口。若 OTP 已过期60 秒重新请求/auth/customer/phone-auth生成新的 OTP 即可。4. 集成 Twilio 短信发送 OTP这一步把调试日志替换成真实的短信创建一个 Twilio Notification Module Provider再写一个 subscriber 监听phone-auth.otp.generated事件触发发送。Provider 的通用概念见 Notification Module 文档。4.1 安装 Twilio SDKnpm install twilio4.2 创建 Twilio Notification Provider 服务创建src/modules/twilio-sms/service.tsimport { AbstractNotificationProviderService, } from medusajs/framework/utils import { Twilio } from twilio type InjectedDependencies {} type TwilioSmsServiceOptions { accountSid: string authToken: string from: string } class TwilioSmsService extends AbstractNotificationProviderService { static readonly identifier twilio-sms private readonly client: Twilio private readonly from: string constructor(container: InjectedDependencies, options: TwilioSmsServiceOptions) { super() this.client new Twilio(options.accountSid, options.authToken) this.from options.from } }identifier是模块唯一标识用于在 Medusa 中注册该 Provider。选项包含accountSid、authToken、from发送短信的 Twilio 号码在注册时传入。补齐validateOptions任一选项缺失时抛错阻止应用启动class TwilioSmsService extends AbstractNotificationProviderService { // ... static validateOptions(options: Recordany, any): void | never { if (!options.accountSid) { throw new Error(Account SID is required) } if (!options.authToken) { throw new Error(Auth token is required) } if (!options.from) { throw new Error(From is required) } } }Notification Provider 唯一必须实现的是send方法// other imports... import { ProviderSendNotificationDTO, ProviderSendNotificationResultsDTO, } from medusajs/types class TwilioSmsService extends AbstractNotificationProviderService { // ... async send( notification: ProviderSendNotificationDTO ): PromiseProviderSendNotificationResultsDTO { const { to, content, template, data } notification const contentText content?.text || await this.getTemplateContent( template, data ) const message await this.client.messages.create({ body: contentText, from: this.from, to, }) return { id: message.sid, } } async getTemplateContent( template: string, data?: Recordstring, unknown | null ): Promisestring { switch (template) { case otp-template: if (!data?.otp) { throw new Error(OTP is required for OTP template) } return Your OTP is ${data.otp} default: throw new Error(Template ${template} not found) } } }短信内容优先取content.text没有则用模板渲染——这里定义了otp-template模板输出Your OTP is otp。返回的id是发送消息的 Twilio SID会存入数据库的通知记录。4.3 导出模块定义并注册import { ModuleProvider, Modules, } from medusajs/framework/utils import TwilioSMSNotificationService from ./service export default ModuleProvider(Modules.NOTIFICATION, { services: [TwilioSMSNotificationService], })在medusa-config.ts的modules中注册 Notification Module 和该 Providermodule.exports defineConfig({ // ... modules: [ // ... { resolve: medusajs/medusa/notification, options: { providers: [ // default provider { resolve: medusajs/medusa/notification-local, id: local, options: { name: Local Notification Provider, channels: [feed], }, }, { resolve: ./src/modules/twilio-sms, id: twilio-sms, options: { channels: [sms], accountSid: process.env.TWILIO_ACCOUNT_SID, authToken: process.env.TWILIO_AUTH_TOKEN, from: process.env.TWILIO_FROM, }, }, ], }, }, ], })channels: [sms]声明该 Provider 支持sms渠道后续通过channel: sms发送通知时会路由到它。把 Twilio 凭据写入.env下面等号后的值是文档示例占位替换为你在 Twilio Console 首页获取的真实值TWILIO_ACCOUNT_SIDAC... TWILIO_AUTH_TOKEN05... TWILIO_FROM1...TWILIO_ACCOUNT_SIDTwilio 账号 SIDTWILIO_AUTH_TOKENTwilio auth tokenTWILIO_FROM用于发送短信的 Twilio 号码需是你从 Twilio 购买的号码。4.4 监听 OTP 事件并发送短信创建src/subscribers/send-otp.tsimport { SubscriberArgs, type SubscriberConfig, } from medusajs/medusa import { Modules } from medusajs/framework/utils export default async function sendOtpHandler({ event: { data: { phone, otp, } }, container, }: SubscriberArgs{ phone: string, otp: string }) { const notificationModuleService container.resolve( Modules.NOTIFICATION ) await notificationModuleService.createNotifications({ to: phone, channel: sms, template: otp-template, data: { otp, }, }) } export const config: SubscriberConfig { event: phone-auth.otp.generated, }subscriber 必须导出一个异步函数和一个带event属性的配置对象。事件触发时它从 Medusa 容器解析出 Notification Module 服务调用createNotifications走sms渠道发送Notification Module 会把实际发送委托给sms渠道对应的 Provider即上面的 Twilio Provider。4.5 重新验证重复第 3 节的认证流程发起/auth/customer/phone-auth后OTP 会通过 Twilio 发送到顾客手机号用收到的 OTP 请求 callback 路由即可拿到 JWT token。验证通过后删除generateOTP中的调试日志行this.logger.info(Generated OTP: ${otp})5. 可选在 Next.js Starter Storefront 中接入手机号登录如果第 1 步安装了 Next.js Starter Storefrontapps/storefront目录可以把默认的邮箱密码登录替换为手机号登录也可以保留两种方式。以下函数与组件的完整代码见 Phone Authentication 教程 的 Step 4这里给出请求侧的核心实现。先安装手机号输入组件npm install react-phone-number-input在src/lib/data/customer.tsstorefront 内添加三个函数均基于预配置的 JS SDKexport const authenticateWithPhone async (phone: string) { try { const response await sdk.auth.login(customer, phone-auth, { phone, }) if ( typeof response string || !response.location || response.location ! otp ) { throw new Error(Failed to login) } return true } catch (error: any) { return error.toString() } } export const verifyOtp async ({ otp, phone, }: { otp: string phone: string }) { try { const token await sdk.auth.callback(customer, phone-auth, { phone, otp, }) await setAuthToken(token) const customerCacheTag await getCacheTag(customers) revalidateTag(customerCacheTag) await transferCart() return true } catch (e: any) { return e.toString() } } export const registerWithPhone async ({ firstName, lastName, phone, }: { firstName: string lastName: string phone: string }) { try { const { token: regToken } await sdk.client.fetch { token: string } (/auth/customer/phone-auth/register, { method: POST, body: { phone, }, }) await setAuthToken(regToken as string) const headers { ...(await getAuthHeaders()), } const email ${phone}gmail.com const customerData { email, first_name: firstName, last_name: lastName, phone, } await sdk.store.customer.create( customerData, {}, headers ) return await authenticateWithPhone(phone) } catch (error: any) { return error.toString() } }authenticateWithPhone请求/auth/customer/phone-auth只有返回location: otp才算成功verifyOtp请求/auth/customer/phone-auth/callback成功后写入认证 token、刷新顾客缓存标签并调用transferCart把购物车从访客转移到已登录顾客registerWithPhone先取注册 token创建顾客邮箱同样按「手机号 gmail.com」占位再发起authenticateWithPhone顾客随后会收到 OTP。UI 侧按教程改造扩展login-template.tsx的LOGIN_VIEW枚举增加REGISTER_PHONE和SIGN_IN_PHONE新增otp、register-phone、login-phone三个组件OTP 输入框为 6 位分段输入、带 60 秒重发倒计时并把默认视图改为LOGIN_VIEW.SIGN_IN_PHONE手机号输入使用react-phone-number-input的PhoneInput。验证方式分别在 Medusa 项目和 storefront 项目目录运行npm run dev打开http://localhost:8000点击右上角 Account应看到仅含手机号输入的登录表单输入已注册的手机号并提交后进入 OTP 表单填入短信收到的 6 位数字后自动提交登录成功即进入个人 profile 页面。6. 可选禁止顾客修改手机号手机号是登录身份本身允许修改会导致登录不可用。文档给出两层限制storefront 的 profile 页把src/modules/account/components/overview/index.tsx中展示customer?.email的span改为展示customer?.phone并从src/app/[countryCode]/(main)/account/dashboard/profile/page.tsx中删除ProfileEmail和ProfilePhone区块。API 层只改前端不能阻止直接调 API所以在src/api/middlewares.ts中对/store/customers/me的 POST 请求加中间件请求体含phone时返回 400import { defineMiddlewares } from medusajs/framework/http export default defineMiddlewares({ routes: [ { matcher: /store/customers/me, method: [POST], middlewares: [ async (req, res, next) { const { phone } req.body as Recordstring, string if (phone) { return res.status(400).json({ error: Phone number is not allowed to be updated, }) } next() }, ], }, ], })限制与后续OTP 有效期 60 秒expiresIn: 60s过期后需重新请求/auth/customer/phone-auth生成新 OTP该路由同时承担重发 OTP 的用途。Register Customer API 路由要求 email 字段本方案用「手机号 gmail.com」占位因此 profile 页默认显示的是假邮箱需要按第 6 步改为显示手机号。本教程只覆盖 customer。同一套 Provider 可用于其他 actor 类型在authMethodsPerActor中为对应类型加上phone-auth并把 API 路径中的customer换成相应类型如/auth/user/phone-auth。注意 Medusa Admin 的登录表单不可定制admin 用户的手机号登录需要自建 admin dashboard。完整代码、可导入 Postman 的 OpenAPI 示例见教程页提供的示例仓库入口参考文档Implement Phone Authentication and Integrate Twilio SMS。【免费下载链接】medusaThe worlds most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表