json web token 实践登陆以及校验码验证

去年我写了一篇介绍 jwt文章javascript

文章指出若是没有特别的用户注销及单用户多设备登陆的需求,可使用 jwt,而 jwt 的最大的特征就是无状态,且不加密。html

除了用户登陆方面外,还可使用 jwt 验证邮箱验证码,其实也能够验证手机验证码,可是鉴于我囊中羞涩,只能验证邮箱了。前端

另外,我已在个人试验田进行了实践,不过目前前端代码写的比较简陋,甚至没有失败的回馈提示。至于为何前端写的简陋,彻底是由于前端的代码量相比后端来说实在过于庞大...java

另外,若是你熟悉 graphql,也能够在本项目的 graphql-playground 中查看效果。数据库

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

发送验证码

校验以前,须要配合一个随机数供邮箱和短信发送。使用如下代码片断生成一个六位数字的随机码,你也能够把它包装为一个函数安全

const verifyCode = Array.from(Array(6), () => parseInt((Math.random() * 10))).join('')
复制代码

若是使用传统有状态的解决方案,此时须要在服务端维护一个用户邮箱及随机码的键值对,而使用 jwt 也须要给前端返回一个 token,随后用来校验验证码。bash

咱们知道 jwt 只会校验数据的完整性,而不对数据加密。此时当拿用户邮箱及校验码配对时,可是若是都放到 payload 中,而 jwt 使用明文传输数据,校验码会被泄露dom

// 放到明文中,校验码泄露
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' })
  }
}
复制代码

关注公众号山月行,记录个人技术成长,欢迎交流

欢迎关注公众号山月行,记录个人技术成长,欢迎交流
相关文章
相关标签/搜索