前言

前陣子參與一個電商的開發案,參考一些開源專案的設計,包含火紅的 Shopify 替代方案-Medusa,研究完後大致了解該怎麼實作和設計一系列跟密碼重設、忘記密碼、註冊登入…等電商功能,所以就有了此篇文章來紀錄一下過程,實作內容是 Medusa 的原始碼,會以一個 high-level 的設計來闡述設計的流程與該注意的事項

以使用者觀點來看忘記密碼/重設密碼這件事

一般來說,我們在用任何有登入、註冊相關的網站,都會有「忘記密碼」的按鈕,但實際上網站後台的資料庫並不會存我們密碼的明碼(理論上都不應該 QQ),所以當按下忘記密碼後,我們會收到一個電子信箱,裡頭並不會原封不動地將我們的密碼傳回來,因為該網站的資料庫存的是 hashed 過後的密碼,而 hash 是無法被逆向回推的,所以這時候信件裡應該是一組 url,點擊後會跳轉到該網站寫好的重設密碼的頁面,讓該使用者輸入密碼與確認密碼,以便重新設定一組新密碼.

例如下圖的 email 截圖,圖片來源: https://designmodo.com/reset-password-emails/

以上就是一般使用者的操作流程,這些流程大家都再熟悉不過,但背後的系統流程是怎麼運做的呢?

簡述設計流程

既然要收發信件,肯定需要有寄信服務,寄送信件的服務有很多種,最常見的就是SendGrid,我在實作中有註冊一組帳號來取得發送 email 的 token,但後來開發上有受到一些阻礙與問題,最終改用 這篇文章這篇文章 提到的 GCP 支援的 SendGrid Email API 免費方案來完成寄信功能,目前運作起來一切正常

GCP_SendGrid

有了寄信服務之後,接下來要解決的是怎麼確保重設密碼的人就是該帳號的擁有者,避免中間過程被盜取身份,竄改該使用者的密碼?

這個問題是最關鍵的,也就是身份認證的資安議題

一般來說,身份認證(Authentication)肯定會需要 DB 來儲存該 user 的個人資料與帳號密碼,因此實作中我使用 PostgreSQL 作為 DB 的選用,而現今認證機制大多使用 JWT,好處是省去 server 再拿 session id 撈一次資料庫的成本,直接用 base64 將 payload 內容 decode,拿到其中就算公開也沒差的無隱私資料,例如:user id,接著服務當場拿該 user id 去問 DB 該 user 的資料,當然也會帶著 JWT token 去詢問,此時 JWT 的第三部分(signature)的 secret 就是判斷該 token 是否有被篡改的依據,若成功驗證,就能成功取得該 user 的資料,完成身份認證(Authentication)甚至授權(Authorization)的流程

以上就是 JWT 驗證的一般流程,一切的建立都是該 user 有被簽發到該 JWT token,才有後續的驗證流程,但如果今天使用者根本沒有登入,卻又要鎖定該使用者的身份時,也就是處理類似忘記密碼這種情境的話,又該怎麼做呢?

其實做法很間單,就再簽發一個臨時的 JWT token 給服務,讓他在跳轉至重設密碼的頁面時,能夠鎖定並確認是該 user 本人來修改密碼,但可想可知,這個臨時的 JWT token 必須在有效期間(ex: 10 分鐘)過後就失效,避免夜長夢多,某天 token 外洩影響客戶權益

大致了解整個處理機制之後,就來實作看看

實作忘記密碼與重設密碼

實作的過程中,會需要在資料庫中創建 user table,來儲存 user 的資料,舉例來說,Medusa 在 PostgreSQL 內的 user table schema 設計可能長這樣:

                            Table "public.customer"
       Column       |           Type           | Collation | Nullable | Default
--------------------+--------------------------+-----------+----------+---------
 id                 | character varying        |           | not null |
 email              | character varying        |           | not null |
 first_name         | character varying        |           |          |
 last_name          | character varying        |           |          |
 billing_address_id | character varying        |           |          |
 password_hash      | character varying        |           |          |
 phone              | character varying        |           |          |
 has_account        | boolean                  |           | not null | false
 created_at         | timestamp with time zone |           | not null | now()
 updated_at         | timestamp with time zone |           | not null | now()
 deleted_at         | timestamp with time zone |           |          |
 metadata           | jsonb                    |           |          |

再來要實作一個向 DB 取用資料的 service,我個人認爲像是電商這類領域,資料模型(model)的欄位設計也算是可預期的,也就是 schema 的設計是有共識的、變動較小,因此使用 ORM 來和 DB 欄位做 mapping 帶來的負擔相對較小,例如大多電商都會有這些要存放的資料:Product, User, Address…等等 table,存放的資料與欄位都是可預期的.

因此,server 可用 repository pattern 搭配 ORM 來實作一個跟 DB 互動的服務 UserRepository,負責所有與 User table 的 CRUD 取用邏輯

import { EntityRepository, Repository } from "typeorm";
import { User } from "../models/user";

@EntityRepository(User)
export class UserRepository extends Repository<User> {}

再來,有了 user 的 repository 服務後,接下來可能會再建立一個 userService 組合 usrRepository 的 CRUD 功能來完成跟 user 相關的業務邏輯

class UserSerivce {
  constructor() {
    // DI...
  }

  async retrieve(userId: string, config: FindConfig<User> = {}): Promise<User> {
    // ...
  }

  async retrieveByApiToken(
    apiToken: string,
    relations: string[] = []
  ): Promise<User> {
    // ...
  }

  async retrieveByEmail(
    email: string,
    config: FindConfig<User> = {}
  ): Promise<User> {
    // ...
  }

  async update(userId: string, update: UpdateUserInput): Promise<User> {
    // ...
  }

  async delete(userId: string): Promise<void> {
    // ...
  }
}

再來開一個 API,在 Node.js 可以透過類似 awilixtypedi 這類 dependency injection 的第三方開源套件來完成依賴服務的注入,以利每個 API endpoint 背後都可以綁定 req 物件來取得對應的 service.若開發上採用其他語言的話,各自也都有對應的 dependency injection 的 solution 可以使用,例如:Java Spring Boot 就可以用 @Autowired 來完成.

底下是重設密碼的 API 端口 /store/customer/reset-password,透過 DI 將 userService 取出來,取得該 user 的資訊以簽發 reset password 的 JWT token

// Ref: https://github.com/medusajs/medusa/blob/233d6904f89d6f25789c879f123df7d069dfdb66/packages/medusa/src/api/routes/store/customers/reset-password-token.ts

export default async (req, res) => {
  const validated = (await validator(
    StorePostCustomersCustomerPasswordTokenReq,
    req.body
  )) as StorePostCustomersCustomerPasswordTokenReq;

  // Get user service through DI
  const customerService: CustomerService = req.scope.resolve(
    "customerService"
  ) as CustomerService;

  const customer = await customerService.retrieveRegisteredByEmail(
    validated.email
  );

  // Will generate a token and send it to the customer via an email provider
  const manager: EntityManager = req.scope.resolve("manager");
  await manager.transaction(async (transactionManager) => {
    return await customerService
      .withTransaction(transactionManager)
      .generateResetPasswordToken(customer.id);
  });

  res.sendStatus(204);
};

generateResetPasswordToken 的實作內容在底下,將該 user 的 password 作為 JWT 的 secret 來源,簽發一個用於 reset password 的 JWT token,時效是 15 分鐘,除此之外,由於 Medusa 有實作一個通用的 EventBus 服務,可以用來串接任意事件傳遞的服務(ex: Redis),所以 eventBus 這個服務將會發送 password reset 的事件,給所有監聽此事件的 subscriber 做後續的處理

// Ref: https://github.com/medusajs/medusa/blob/233d6904f89d6f25789c879f123df7d069dfdb66/packages/medusa/src/services/user.ts#L321

/**
   * Generate a JSON Web token, that will be sent to a user, that wishes to
   * reset password.
   * The token will be signed with the users current password hash as a secret
   * a long side a payload with userId and the expiry time for the token, which
   * is always 15 minutes.
   * @param {string} userId - the id of the user to reset password for
   * @return {string} the generated JSON web token
   */
  async generateResetPasswordToken(userId: string): Promise<string> {
    return await this.atomicPhase_(async (transactionManager) => {
      const user = await this.retrieve(userId, {
        select: ["id", "email", "password_hash"],
      })
      const secret = user.password_hash   // Use this user's password as hash secret
      const expiry = Math.floor(Date.now() / 1000) + 60 * 15   // 15 mins
      const payload = { user_id: user.id, email: user.email, exp: expiry }
      const token = jwt.sign(payload, secret)

      // Notify subscribers
      await this.eventBus_
        .withTransaction(transactionManager)
        .emit(UserService.Events.PASSWORD_RESET, {
          email: user.email,
          token,
        })

      return token
    })
  }

此例我就有依據他設計的介面,實作一個 subscriber,用來監聽 user.password_reset 這個事件,並透過 SendGrid 服務來發送信件通知該 user 的帳號有重設密碼的意圖,一旦該 user 有點擊忘記密碼的按鈕就會收到通知,如文章開頭的截圖所看到的:Someone request that the password be reset for the following account... If this was a mistake, just ignore this email and nothing will happen

其中,重設密碼的 url 是 ${projectConfig.store_cors}/account/resetPassword?token=${rawData.token},會內嵌在信件裏讓該使用者點擊跳轉至前端頁面,讓該 user 在網頁上操作重新設定密碼

class CustomerPasswordResetSubscriber {
  private sendgridService_;

  // `user.password_reset` -> Triggered when a customer requests to reset their password.
  // Ref: https://github.com/medusajs/medusa/blob/5af39e7beba1eecff4750aa470d9aa4135112923/docs/content/advanced/backend/subscribers/events-list.md#customer-events
  constructor({ eventBusService, sendgridService }) {
    eventBusService.subscribe("user.password_reset", this.handlePasswordReset);
    this.sendgridService_ = sendgridService;
  }

  handlePasswordReset = async (rawData: CustomerPasswordResetData) => {
    try {
      console.log("[SEND EMAIL] Customer password reset");
      console.log("Received raw data: ", rawData);

      const segments = rawData.token.split(".");
      let payload = "";
      if (segments.length === 3) {
        payload = segments[1]; // Second part is base64 encoded payload
      }
      if (!payload) {
        console.error(`Invalid token: ${rawData.token}`);
        console.error(
          `Failed to send customer password reset email ${rawData.email}`
        );
      }
      const decodedPayload: DecodedTokenPayload = JSON.parse(
        Buffer.from(payload, "base64").toString("ascii")
      );
      console.log("Received decoded token payload: ", decodedPayload);

      const resetPasswordUrl = `${projectConfig.store_cors}/account/resetPassword?token=${rawData.token}`;

      const sendOptions: SendOptions = {
        templateId: process.env.SENDGRID_RESET_PASSWORD_ID,
        from: process.env.SENDGRID_FROM,
        to: rawData.email, // old email
        dynamicTemplateData: {
          name: "Madi",
          content: `Customer password reset. Please click this url to reset your password. ${resetPasswordUrl}`,
        },
      };
      console.log("Send email options: ", sendOptions);

      const result = await this.sendgridService_.sendEmail(sendOptions);
      if (result?.[0]?.statusCode === 202) {
        console.log(
          `Successfully send customer password reset email to ${rawData.email}`
        );
      } else {
        console.error(
          `Failed to send customer password reset email to ${rawData.email}`
        );
      }
    } catch (err) {
      console.error(err);
    }
  };
}

接著,前端也要在 ${projectConfig.store_cors}/account/resetPassword 增加一個頁面來處理 token 這個 query parameter,透過 base64 解碼取得 user 的 email,最後再透過另一個修改密碼的 API 來真的重新設定密碼

結論

以上就是常見的電商重設密碼的處理流程,中間過度了一個臨時的 reset password 這個 JWT token,並且透過 redis 充當前後端事件傳遞的服務,並實作特定事件的 subscriber 來客製化發送事件的處理邏輯,甚至能夠客製化每個 sendgrid 的信件 template,讓整個重設密碼的機制受到資安的保護與盡到該做的通知責任

當然,除了重設密碼的情境以外,像是客戶下訂單、使用者註冊、使用者登入、庫存補貨…等等情境,也能夠透過 redis 的事件傳遞機制讓後端發送 email 給特定的使用者,實作上就不需要考慮臨時簽發 JWT token,相對來說更為簡單