json web token 實踐登入以及校驗碼驗證

shanyue發表於2019-04-27

去年我寫了一篇介紹 jwt文章

文章指出如果沒有特別的使用者登出及單使用者多裝置登入的需求,可以使用 jwt,而 jwt 的最大的特徵就是無狀態,且不加密。

除了使用者登入方面外,還可以使用 jwt 驗證郵箱驗證碼,其實也可以驗證手機驗證碼,但是鑑於我囊中羞澀,只能驗證郵箱了。

另外,我已在我的試驗田進行了實踐,不過目前前端程式碼寫的比較簡陋,甚至沒有失敗的回饋提示。至於為什麼前端寫的簡陋,完全是因為前端的程式碼量相比後端來講實在過於龐大...

另外,如果你熟悉 graphql,也可以在本專案的 graphql-playground 中檢視效果。

本文地址 shanyue.tech/post/jwt-an…

傳送驗證碼

校驗之前,需要配合一個隨機數供郵箱和簡訊傳送。使用以下程式碼片段生成一個六位數字的隨機碼,你也可以把它包裝為一個函式

const verifyCode = Array.from(Array(6), () => parseInt((Math.random() * 10))).join('')
複製程式碼

如果使用傳統有狀態的解決方案,此時需要在服務端維護一個使用者郵箱及隨機碼的鍵值對,而使用 jwt 也需要給前端返回一個 token,隨後用來校驗驗證碼。

我們知道 jwt 只會校驗資料的完整性,而不對資料加密。此時當拿使用者郵箱及校驗碼配對時,但是如果都放到 payload 中,而 jwt 使用明文傳輸資料,校驗碼會被洩露

// 放到明文中,校驗碼洩露
jwt.sign({ email, verifyCode }, config.jwtSecret, { expiresIn: '30m' })
複製程式碼

那如何保證校驗碼不被洩露,而且能夠正確校驗資料呢

我們知道 secret 是不會被洩露的,此時把校驗碼放到 secret 中,完成配對

// 再給個半小時的過期時間
const token = jwt.sign({ email }, config.jwtSecret + verifyCode, { expiresIn: '30m' })
複製程式碼

在服務端傳送郵件的同時,把 token 再傳遞給前端,隨註冊時再傳送到後端進行驗證,這是我專案中關於校驗的 graphql 的程式碼。如果你不懂 graphql 也可以把它當做虛擬碼,大致應該都可以看的懂

type Mutation {
  # 傳送郵件
  # 返回一個 token,註冊時需要攜帶 token,用以校驗驗證碼
  sendEmailVerifyCode (
    email: String! @constraint(format: "email")
  ): String!
}
複製程式碼
const Mutation = {
  async sendEmailVerifyCode (root, { email }, { email: emailService }) {
    // 生成六個隨機數
    const verifyCode = Array.from(Array(6), () => parseInt((Math.random() * 10))).join('')
    // TODO 可以放到訊息佇列裡,但是沒有多少量,而且本 Mutation 還有限流,其實目前沒啥必要...
    // 與打點一樣,不關注結果
    emailService.send({
      to: email, 
      subject: '【詩詞絃歌】賬號安全——郵箱驗證',
      html: `您正在進行郵箱驗證,本次請求的驗證碼為:<span style="color:#337ab7">${verifyCode}</span>(為了保證您帳號的安全性,請在30分鐘內完成驗證)\n\n詩詞絃歌團隊`
    })
    return jwt.sign({ email }, config.jwtSecret + verifyCode, { expiresIn: '30m' })
  }
}
複製程式碼

題外話,傳送郵件也有幾個問題需要思考一下,不過這裡先不管它了,以後實現了再寫篇文章總結一下

  1. 如果郵件由服務提供,如何考慮非同步服務和同步服務
  2. 訊息佇列處理,發郵件不要求可靠性,更像是 UDP
  3. 為了避免使用者短時間內大量郵件傳送,如何實現限流 (RateLimit)

題外題外話,一般傳送郵件或者手機簡訊之前需要一個圖片校驗碼來進行使用者真實性校驗和限流。而圖片校驗碼也可以通過 jwt 進行實現

註冊

註冊就簡單很多了,對客戶端傳入的資料進行郵箱檢驗,校驗成功後直接入庫就可以了,以下是 graphql 的程式碼

type Mutation {
  # 註冊
  createUser (
    name: String!
    password: String!
    email: String! @constraint(format: "email")
    verifyCode: String!
    # 傳送郵件傳給客戶端的 token
    token: String!
  ): User!
}
複製程式碼
const Mutation = {
  async createUser (root, { name, password, email, verifyCode, token }, { models }) {
    const { email: verifyEmail } = jwt.verify(token, config.jwtSecret + verifyCode)
    if (email !== verifyEmail) {
      throw new Error('請輸入正確的郵箱') 
    }
    const user = await models.users.create({
      name,
      email,
      // 入庫時密碼做了加鹽處理
      password: hash(password)
    })
    return user
  }
}
複製程式碼

這裡有一個細節,對入庫的密碼使用 MD5 與一個引數 salt 做了不可逆處理

function hash (str) {
  return crypto.createHash('md5').update(`${str}-${config.salt}`, 'utf8').digest('hex')
}
複製程式碼

題外話,salt 是否可以與 JWTsecret 設定為同一字串?

再題外話,這裡的輸入正確郵箱的 Error 明顯不應該傳送至 Sentry (報警系統),而有的 Error 的資訊可以直接顯示在前端,如何對 Error 進行規範與分類

校驗碼由傳統方法實現與 jwt 比較

如果使用傳統方法,只需要一個 key/value 資料庫,維護手機號/郵箱與檢驗碼的對應關係即可實現,相比 jwt 而言要簡單很多。

登入

一個用 jwt 實現登入的 graphql 程式碼,把 user_iduser_role 置於 payload 中

type Mutation {
  # 登入,如果返回 null,則登入失敗
  createUserToken (
    email: String! @constraint(format: "email")
    password: String!
  ): String
}
複製程式碼
const Mutation = {
  async createUserToken (root, { email, password }, { models }) {
    const user = await models.users.findOne({
      where: {
        email,
        password: hash(password)
      },
      attributes: ['id', 'role'],
      raw: true
    })
    if (!user) {
      // 返回空代表使用者登入失敗
      return
    }
    return jwt.sign(user, config.jwtSecret, { expiresIn: '1d' })
  }
}
複製程式碼

關注公眾號山月行,記錄我的技術成長,歡迎交流

歡迎關注公眾號山月行,記錄我的技術成長,歡迎交流

相關文章