Внедрение единого входа в Medusa

Введение

Медуза — это безголовая коммерция с открытым исходным кодом, которая предлагает готовые функции, необходимые для создания платформы электронной коммерции. Важной особенностью Medusa является гибкость в адаптации функций в соответствии с вашими потребностями.

Medusa позволяет пользователям аутентифицироваться на платформе, используя адрес электронной почты и пароль или токен на предъявителя. Однако процесс аутентификации можно настроить благодаря гибкости Medusa.

Это руководство проведет вас через процесс настройки потока аутентификации вашего сервера Medusa. Для этого вы позволите своим клиентам входить в систему с помощью волшебной ссылки, полученной в их электронной почте.

Вы можете найти окончательный код для этого руководства в этом репозитории GitHub.

Что такое Медуза?

Medusa — это компонуемый движок, который сочетает в себе удивительный опыт разработчиков с бесконечными настройками. Вы можете добавить дополнительные конечные точки для выполнения пользовательских действий или добавить новые сервисы для реализации бизнес-логики; например, чтобы изменить поток аутентификации. Вы также можете использовать подписки для построения автоматизации; например, чтобы отправить электронное письмо с помощью поставщика уведомлений, когда произойдет определенное событие.

Предпосылки

Чтобы следовать этому руководству, вам потребуется следующее:

  1. Работающее серверное приложение Medusa: вы можете следовать краткому руководству, чтобы начать работу.
  2. Ваш сервер Medusa должен быть настроен для работы с PostgreSQL и Redis. Вы можете следовать руководству Документация по настройке сервера, чтобы узнать, как это сделать.
  3. Служба электронной почты для отправки электронных писем для входа без пароля: вы можете следовать этому руководству, чтобы включить плагин Sendgrid, доступный в экосистеме Medusa.
  4. Стартер Next.js для тестирования нового потока аутентификации, добавленного на сервер Medusa. Тем не менее, вы все равно можете использовать другую структуру витрины.
  5. yarn менеджер пакетов: вы можете следовать этим инструкциям, чтобы установить его. Вы также можете использовать npm в качестве альтернативы.

Вход без пароля

Вход без пароля — это стратегия проверки личности пользователя без пароля. Несколько альтернатив для достижения этого включают факторы владения, такие как одноразовые пароли [OTP] или зарегистрированные номера смартфонов, биометрические данные с использованием сканирования отпечатков пальцев или сетчатки глаза и магические ссылки, отправляемые пользователю по электронной почте.

В этом уроке вы сосредоточитесь на реализации стратегии волшебной ссылки. См. поток ниже:

  1. Покупатели посещают магазин Medusa, пишут свою электронную почту и нажимают кнопку Войти.
  2. Магазин Medusa запрашивает конечную точку аутентификации на сервере Medusa, который отправляет покупателю электронное письмо с волшебной ссылкой.
  3. Клиенты нажимают на волшебную ссылку.
  4. Сервер Medusa проверяет ссылку, регистрирует пользователей, устанавливает файл cookie сеанса и перенаправляет клиентов на витрину.

Добавить беспарольный сервис

Первым шагом является создание службы, которая будет генерировать событие для отправки электронных писем с волшебной ссылкой клиентам и проверки токенов из этих волшебных ссылок.

Начните с установки пакета jsonwebtoken, который будет использоваться для входа и проверки токенов:

yarn add jsonwebtoken

Далее создайте файл src/services/password-less.ts со следующим содержимым:

import { CustomerService, EventBusService, TransactionBaseService } from '@medusajs/medusa'
import { MedusaError } from 'medusa-core-utils'
import { EntityManager } from 'typeorm'
import jwt from 'jsonwebtoken'

class PasswordLessService extends TransactionBaseService {
  protected manager_: EntityManager
  protected transactionManager_: EntityManager
  private readonly customerService_: CustomerService
  private readonly eventBus_: EventBusService
  private readonly configModule_: any;
  private readonly jwt_secret: any;

  constructor(container) {
    super(container)
    this.eventBus_ = container.eventBusService
    this.customerService_ = container.customerService
    this.configModule_ = container.configModule

    const { projectConfig: { jwt_secret } } = this.configModule_
    this.jwt_secret = jwt_secret
  }

  async sendMagicLink(email, isSignUp) {
    const token = jwt.sign({ email }, this.jwt_secret, { expiresIn: '8h' })

    try {
      return await this.eventBus_.withTransaction(this.manager_)
        .emit('passwordless.login', { email, isSignUp, token })
    } catch (error) {
      throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `There was an error sending the email.`)
    }
  }

  async validateMagicLink(token) {
    let decoded
    const { projectConfig: { jwt_secret } } = this.configModule_

    try {
      decoded = jwt.verify(token, jwt_secret)
    } catch (err) {
      throw new MedusaError(MedusaError.Types.INVALID_DATA, `Invalid auth credentials.`)
    }

    if (!decoded.hasOwnProperty('email') || !decoded.hasOwnProperty('exp')) {
      throw new MedusaError(MedusaError.Types.INVALID_DATA, `Invalid auth credentials.`)
    }

    const customer = await this.customerService_.retrieveRegisteredByEmail(decoded.email).catch(() => null)
    
    if (!customer) {
      throw new MedusaError(MedusaError.Types.NOT_FOUND, `There isn't a customer with email ${decoded.email}.`)
    }

    return customer
  }
}

export default PasswordLessService

Первый метод, sendMagicLink, создает и подписывает токен с адресом электронной почты клиента, полученным в качестве параметра, и сроком действия один час. Затем он создает событие passwordles.login, используя eventBus, поэтому PasswordLessSubscriber, который вы создадите позже, может выполнить свой обработчик для отправки электронной почты.

Метод validateMagicLink проверяет наличие токена и его действительность, то есть, что им не манипулировала третья сторона или что срок его действия не истек. Наконец, он пытается получить клиента, используя customerService, и в случае успеха возвращает полученного клиента.

Добавить беспарольные конечные точки

Следующим шагом является добавление пользовательских конечных точек, чтобы вы могли использовать стратегию из витрины.

Создайте файл src/api/index.ts со следующим содержимым:

import { Router, json } from 'express'
import cors from 'cors'
import jwt from 'jsonwebtoken'
import { projectConfig } from '../../medusa-config'

const corsOptions = {
  origin: projectConfig.store_cors.split(','),
  credentials: true
}

const route = Router()

export default () => {
  const app = Router()
  app.use(cors(corsOptions))
  app.use(json());

  app.use('/auth', route)

  route.post('/passwordless/sent', async (req, res) => {
    const manager = req.scope.resolve('manager')
    const customerService = req.scope.resolve('customerService')
    const { email, isSignUp } = req.body

    let customer = await customerService.retrieveRegisteredByEmail(email).catch(() => null)

    if (!customer && !isSignUp) {
      res.status(404).json({ message: `Customer with ${email} was not found. Please sign up instead.` })
    }

    if (!customer && isSignUp) {
      customer = await customerService.withTransaction(manager).create({
        email,
        first_name: '--',
        last_name: '--',
        has_account: true
      })
    }

    const passwordLessService = req.scope.resolve('passwordLessService')

    try {
      await passwordLessService.sendMagicLink(customer.email, isSignUp)
      return res.status(204).json({ message: 'Email sent' })
    } catch (error) {
      return res.status(404).json({ message: `There was an error sending the email.` })
    }
  })

  route.get('/passwordless/validate', async (req, res) => {
    const { token } = req.query
    const { projectConfig } = req.scope.resolve('configModule')

    if (!token) {
      return res.status(403).json({ message: 'The user cannot be verified' })
    }

    const passwordLessService = req.scope.resolve('passwordLessService')

    try {
      const loggedCustomer = await passwordLessService.validateMagicLink(token)

      req.session.jwt_store = jwt.sign(
        { customer_id: loggedCustomer.id },
        projectConfig.jwt_secret!,
        { expiresIn: '30d' }
      )

      return res.status(200).json({ ...loggedCustomer })
    } catch (error) {
      return res.status(403).json({ message: 'The user cannot be verified' })
    }
  })

  return app
}

В этом файле конечная точка /passwordless/sent выполняет некоторые проверки перед вызовом passwordLessService для отправки события.

Проверка включает проверку наличия зарегистрированного клиента с этим адресом электронной почты. Если нет, он возвращает ответ с просьбой сначала зарегистрироваться. Если зарегистрированного клиента нет, а запрос signUp, в базе данных создается новый клиент.

Наконец, когда клиент уже получен или создан, он вызывает метод sendMagicLink для passwordLessService для отправки электронного письма.

Конечная точка /passwordless/validate проверяет наличие токена в строке запроса и проверяет его с помощью метода validateMagicLink на странице passwordLessService. Если токен действителен, служба извлечет и вернет сведения о покупателе, чтобы конечная точка могла установить сеанс и отправить ответ на витрину магазина.

Добавить подписчика без пароля

Последним шагом является создание подписчика с функцией обработчика для отправки электронных писем с волшебной ссылкой всякий раз, когда возникает событие passwordless.login.

Для отправки электронных писем в этом руководстве используется SendGrid в качестве поставщика уведомлений, но вы можете использовать поставщика электронной почты по своему выбору, так как процесс будет очень похожим. Создайте файл src/subscribers/password-less.ts со следующим содержимым:

class PasswordLessSubscriber {
  protected sendGridService: any;
  constructor({ eventBusService, sendgridService }) {
    this.sendGridService = sendgridService;
    eventBusService.subscribe('passwordless.login', this.handlePasswordlessLogin);
  }

  handlePasswordlessLogin = async (data) => {
    await this.sendGridService.sendEmail({
      to: data.email,
      from: process.env.SENDGRID_FROM,
      templateId: data.isSignUp ? process.env.SENGRID_REGISTER_TEMPLATE_ID : process.env.SENGRID_LOGIN_TEMPLATE_ID,
      dynamic_template_data: {
        token: data.token
      },
    })
  }
}

export default PasswordLessSubscriber;

Обработчик handlePasswordlessLogin использует службу SendGrid с динамическим шаблоном для отправки волшебной ссылки, по которой клиенты будут щелкать, чтобы войти в систему. Это электронное письмо включает URL-адрес витрины с токеном, переданным в качестве строки запроса. Клиенты могут щелкнуть или скопировать URL-адрес для входа в систему, пароль вводить не нужно.

Убедитесь, что в Sendgrid есть два шаблона: один для регистрации, а другой для входа. Кроме того, не забудьте получить их идентификаторы и добавить эти значения в файл .env. Эти идентификаторы будут получены вашим PasswordLessSubscriber при отправке электронного письма.

SENGRID_LOGIN_TEMPLATE_ID=d-750a..........................
SENGRID_REGISTER_TEMPLATE_ID=d-03b........................

Настройте новый поток аутентификации на витрине

В этом разделе будет протестирована новая стратегия аутентификации, добавленная на ваш сервер Medusa. В этом руководстве в качестве примера используется Витрина стартового магазина Next.js, но вы можете использовать любой другой фреймворк, и процесс будет очень похожим.

Страница авторизации

Откройте файл src/modules/account/components/login/index.tsx и замените его содержимое следующим кодом:

import { LOGIN_VIEW, useAccount } from "@lib/context/account-context"
import Button from "@modules/common/components/button"
import Input from "@modules/common/components/input"
import React, { useState } from "react"
import { FieldValues, useForm } from "react-hook-form"

interface SignInCredentials extends FieldValues {
  email: string
}

const Login = () => {
  const { loginView } = useAccount()
  const [_, setCurrentView] = loginView
  const [authError, setAuthError] = useState<string | undefined>(undefined)
  const [linkSent, setLinkSent] = useState<string | undefined>(undefined)

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignInCredentials>()

  const onSubmit = handleSubmit(async (credentials) => {
    const response = await fetch("http://localhost:9000/auth/passwordless/sent", {
        method: "POST",
        body: JSON.stringify(credentials),
        headers: { "Content-Type": "application/json" },
      },
    )

    if (!response.ok) response.json().then((data) => setAuthError(data.message))

    if (response.ok) setLinkSent("Check your email for a login link.")
  })

  return (
    <div className="max-w-sm w-full flex flex-col items-center">
      {!linkSent && (
        <>
          <h1 className="text-large-semi uppercase mb-6">Welcome back</h1>
          <p className="text-center text-base-regular text-gray-700 mb-8">
            Sign in to access an enhanced shopping experience.
          </p>
          <form className="w-full" onSubmit={onSubmit}>

            <div className="flex flex-col w-full gap-y-2">
              <Input
                label="Email"
                {...register("email", { required: "Email is required" })}
                autoComplete="email"
                errors={errors}
              />
            </div>

            {authError && !linkSent && (
              <div className="text-rose-500 w-full text-small-regular mt-2">
                {authError}
              </div>
            )}
            <Button className="mt-6">Enter</Button>
          </form>

          <span className="text-center text-gray-700 text-small-regular mt-6">
            Not a member?{" "}
            <button
              onClick={() => setCurrentView(LOGIN_VIEW.REGISTER)}
              className="underline"
            >
          Join us
        </button>.</span>
        </>
      )}

      {linkSent && (
        <>
          <h1 className="text-large-semi uppercase mb-6">Login link sent!</h1>
          <div className="bg-green-100 text-green-500 p-4 my-4 w-full">
            <span>{linkSent}</span>
          </div>
        </>
      )}
    </div>
  )
}

export default Login

Основные изменения, которые можно наблюдать:

  • Обновление функции handleSubmit для запроса конечной точки /auth/passwordless/sent на отправку клиенту электронного письма с волшебной ссылкой.
  • Удаление поля пароля.
  • Элемент div для отображения успешного уведомления, если электронное письмо было отправлено правильно.

Страница регистрации

Откройте файл src/modules/account/components/register/index.tsx и замените его содержимое следующим:

import { LOGIN_VIEW, useAccount } from "@lib/context/account-context"
import Button from "@modules/common/components/button"
import Input from "@modules/common/components/input"
import Link from "next/link"
import { useState } from "react"
import { FieldValues, useForm } from "react-hook-form"

interface RegisterCredentials extends FieldValues {
  email: string
}

const Register = () => {
  const { loginView } = useAccount()
  const [_, setCurrentView] = loginView
  const [authError, setAuthError] = useState<string | undefined>(undefined)
  const [linkSent, setLinkSent] = useState<string | undefined>(undefined)

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<RegisterCredentials>()

  const onSubmit = handleSubmit(async (credentials) => {
    credentials.isSignUp = true
    const response = await fetch("http://localhost:9000/auth/passwordless/sent", {
        method: "POST",
        body: JSON.stringify(credentials),
        headers: { "Content-Type": "application/json" },
      },
    )

    if (!response.ok) response.json().then((data) => setAuthError(data.message))

    if (response.ok) setLinkSent("Check your email to activate your account.")
  })

  return (
    <div className="max-w-sm flex flex-col items-center mt-12">
      {!linkSent && (
        <>

          <h1 className="text-large-semi uppercase mb-6">Become a Acme Member</h1>
          <p className="text-center text-base-regular text-gray-700 mb-4">
            Create your Acme Member profile, and get access to an enhanced shopping
            experience.
          </p>
          <form className="w-full flex flex-col" onSubmit={onSubmit}>
            <div className="flex flex-col w-full gap-y-2">
              <Input
                label="Email"
                {...register("email", { required: "Email is required" })}
                autoComplete="email"
                errors={errors}
              />
            </div>
            {authError && (
              <div>
            <span className="text-rose-500 w-full text-small-regular">
              {authError}
            </span>
              </div>
            )}
            <span className="text-center text-gray-700 text-small-regular mt-6">
          By creating an account, you agree to Acme&apos;s{" "}
              <Link href="/content/privacy-policy">
            <a className="underline">Privacy Policy</a>
          </Link>{" "}
              and{" "}
              <Link href="/content/terms-of-use">
            <a className="underline">Terms of Use</a>
          </Link>
          .
        </span>
            <Button className="mt-6">Join</Button>
          </form>
          <span className="text-center text-gray-700 text-small-regular mt-6">
        Already a member?{" "}
            <button
              onClick={() => setCurrentView(LOGIN_VIEW.SIGN_IN)}
              className="underline"
            >
          Sign in
        </button>
        .
      </span></>
      )}

      {linkSent && (
        <>
          <h1 className="text-large-semi uppercase mb-6">Registration complete!</h1>
          <div className="bg-green-100 text-green-500 p-4 my-4 w-full">
            <span>{linkSent}</span>
          </div>
        </>
      )}
    </div>
  )
}

export default Register

Изменения, которые вы можете наблюдать там:

  • Обновление функции дескриптора для запроса конечной точки /auth/passwordless/sent на отправку электронного письма с волшебной ссылкой клиенту после регистрации.
  • Удаление полей имени, фамилии, пароля и телефона в регистрационной форме.
  • Элемент div для отображения успешного уведомления, если электронное письмо было успешно отправлено.

Страница проверки

Создайте новую страницу src/pages/account/validate.tsx со следующим содержимым:

import Layout from "@modules/layout/templates"
import React, { ReactElement, useEffect, useState } from "react"
import { useRouter } from "next/router"
import { useAccount } from "@lib/context/account-context"
import Spinner from "@modules/common/icons/spinner"

const Validate = () => {
  const { refetchCustomer, retrievingCustomer, customer } = useAccount()
  const [authError, setAuthError] = useState<string | undefined>(undefined)

  const router = useRouter()

  useEffect(() => {
    const token = router.query.token
    if (token) {
      fetch(`http://localhost:9000/auth/passwordless/validate?token=${token}`,
        { credentials: "include" })
        .then((response) => {
          if (!response.ok) {
            response.json().then((data) => setAuthError(data.message))
          }
          refetchCustomer()
        })
    }
  }, [refetchCustomer, retrievingCustomer, router])

  if (authError) {
    return (
      <div className="flex items-center justify-center w-full min-h-[640px] h-full text-red-600">
        The link to login is invalid or has expired.
      </div>
    )
  }

  if (retrievingCustomer || !customer) {
    return (
      <div className="flex items-center justify-center w-full min-h-[640px] h-full text-gray-900">
        <Spinner size={36} />
      </div>
    )
  }

  if (!retrievingCustomer && customer) {
    router.push("/account")
  }

  return <div></div>
}

Validate.getLayout = (page: ReactElement) => {
  return (
    <Layout>
      {page}
    </Layout>
  )
}

export default Validate

Когда пользователи нажимают на волшебную ссылку, полученную по электронной почте, они будут перенаправлены на /account/validate страницу витрины магазина. Эта страница отправит запрос на сервер Medusa, чтобы проверить, является ли токен, переданный в качестве параметра запроса в магической ссылке, действительным. Если токен действителен, пользователи будут авторизованы и перенаправлены на страницу учетной записи, в противном случае будет показано сообщение об ошибке.

URL-адрес страницы витрины, на которую пользователь перенаправляется по волшебной ссылке, должен совпадать с URL-адресом, добавленным в шаблоны SendGrid.

Протестируйте беспарольную стратегию

Чтобы проверить стратегию без пароля, сначала в терминале выполните следующие команды, чтобы преобразовать файлы TypeScript в файлы JavaScript на вашем сервере Medusa:

yarn run build

Затем запустите сервер Medusa, выполнив следующую команду

yarn start

и запустите витрину Next.js:

yarn run dev

Затем перейдите по URL-адресу http://localhost:8000. Нажмите на ссылку Учетная запись на панели навигации, и вы должны открыть эту страницу.

Вы не можете войти прямо сейчас, потому что вам нужно сначала зарегистрироваться. Нажмите на ссылку Присоединиться под кнопкой Войти, и вы должны перейти на страницу регистрации.

Заполните форму, указав свой адрес электронной почты, и нажмите кнопку Присоединиться. Вы должны увидеть уведомление о том, что регистрация завершена.

Теперь проверьте свою электронную почту, и вы должны увидеть новое электронное письмо с шаблоном, который вы настроили ранее в Sendgrid.

Нажмите кнопку Активировать учетную запись. В вашем браузере откроется новая вкладка по адресу http://localhost:8000/account/validate , если ваша магическая ссылка действительна, вы будете перенаправлены на страницу профиля:

Выйдите из своей учетной записи, перейдите на страницу входа и попросите новую волшебную ссылку для входа. Вы должны получить новое письмо с шаблоном входа от Sendgrid с новой волшебной ссылкой. Нажмите кнопку «Войти», и вы снова войдете в систему без ввода пароля.

Что дальше?

Как видите, настроить поток аутентификации на сервере Medusa очень просто. Отныне вы можете:

  • Расширьте беспарольную стратегию, чтобы отправлять одноразовые пароли (OTP) по электронной почте или SMS.
  • Добавьте социальные методы, такие как Facebook, Twitter или GitHub, используя плагин Medusa Auth.
  • Реализуйте многофакторную аутентификацию (MFA) для администраторов.

Вы также можете ознакомиться со следующими ресурсами, чтобы больше узнать о ядре Medusa:

Если у вас есть какие-либо проблемы или вопросы о Medusa, не стесняйтесь обращаться к команде Medusa через Discord.