koa实现jwt认证[前端后端代码]

268 阅读7分钟

基于Token的身份验证是无状态的,我们不将用户信息存在服务器或Session中。

这种概念解决了在服务端存储信息时的许多问题,NoSession意味着你的程序可以根据需要去增减机器,而不用去担心用户是否登录。基于Token的身份验证的过程如下:

1.用户通过用户名和密码发送请求。

2.程序验证。

3.程序返回一个签名的token 给客户端。

4.客户端储存token,并且每次用于每次发送请求。

5.服务端验证token并返回数据。

客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。 此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它

1.放在 Cookie 里面自动发送

2.放在 HTTP 请求的头信息Authorization字段里面

3.JWT 就放在 POST 请求的数据体里面。

Authorization: Bearer <token>

这里我选用的是第二种方法,放在 HTTP 请求的头信息Authorization字段里面。

后端代码

这里都只贴了核心代码, 完整后端代码参考 github.com/missingone6…

完整接口文档参考代码www.showdoc.com.cn/weshare/972…

该项目需要你已经装好 mongodb(存放用户账号密码等信息)和redis(存放登录注册验证码)

该项目提供了三个 api

  • /api/login/login(登录)
  • /api/login/register(注册)

使用 koa-jwt

index.js 中加入 koa-jwt 中间件。

import koa from 'koa';
import cors from '@koa/cors';
import compose from 'koa-compose'
import router from './routes/routes';
import JWT from 'koa-jwt';
import errorHandle from './common/errorHandle';

const jwt = JWT({
  secret: 'loginfsawtonikmegrgrr3gerago',
}).unless({
  // jwt白名单
  path: [
    /^\/public/,
    /^\/api\/login/,
    /^\/api\/public/,
  ]
});
const middleware = compose([
  cors(),
  errorHandle,
  jwt,
])
app.use(middleware);
app.use(router())
app.listen(8000);

其中 secret 是用于加密的key。

unless() 用于设置哪些 api 是不需要通过 token 验证的。也就是我们通常说的 public api,无需登录就能访问的 api。在这个例子中,设置了 /register/login 两个 api 无需 token 检查。

在使用 koa-jwt 后,所有的路由(除了 unless() 设置的路由除外)都会检查 Header 首部中的 token,是否存在、是否有效。只有正确之后才能正确的访问。否则会保存,被自定义的errorHandle中间件所捕捉到。

自定义 errorHandle中间件

使用了 koa-jwt 中间件后,如果没有token,或者token失效,该中间件会给出对应的错误信息。如果没有自定义中间件的话,会直接将 koa-jwt 暴露的错误信息直接返回给用户。

const errorHandle = (ctx, next) => {
  return next().catch((err) => {
    // Custom 401 handling if you don't want to expose koa-jwt errors to users
    if (401 == err.status) {
      ctx.status = 401;
      ctx.body = {
        code: 401,
        msg: 'Protected resource, use Authorization header to get access\n'
      }
    } else {
      ctx.status = err.status || 500
      ctx.body = Object.assign({
        code: ctx.status,
        msg: err.message
      }, process.env.NODE_ENV === 'development'
        ? { stack: err.stack }
        : {}
      )
      console.log(err.stack);
    }
  });
};
export default errorHandle;

登录实现

用户输入用户名和密码登录,如果用户名和密码正确的话,使用 jsonwebtoken.sign() 生成 token,并返回给客户端。客户端将token存储在localStorage里,在每次的 HTTP 请求中,都将 token 添加在 HTTP Header Authorazition: Bearer token 中。然后后端每次去验证该token的正确与否。只有token正确后才能访问到对应的资源。

async login(ctx) {
    const { cid, code, username, password } = ctx.request.body;
    // 检查验证码是否有效,是否正确
    const isCodeCorrectAndValid = await checkCode(cid, code);
    if (isCodeCorrectAndValid) {
      // 检查账号是否正确
      const user = await UsersModel.findOne({
        username
      })
      if (user === null) {
        ctx.body = {
          code: 404,
          msg: '用户名或者密码错误'
        }
      } else {
        if (user.password === password) {
          const token = jwt.sign({
            username: username,
            _id: user._id,
          }, config.JWT_SECRET, { expiresIn: config.JWT_EXPIRESIN });
          const arr = ['password'];
          let data = {};
          Object.keys(user.toJSON()).forEach((key) => {
            if (!arr.includes(key)) {
              data[key] = user[key];
            }
          });
          data = await addIsSignIn(data)
          ctx.body = {
            code: 200,
            data,
            msg: '登录成功',
            token: token,
          }
        } else {
          ctx.body = {
            code: 404,
            msg: '用户名或者密码错误'
          }
        }
      }
    } else {
      ctx.body = {
        code: 401,
        msg: '图片验证码错误'
      }
    }
}

需要注意的是,在使用 jsonwebtoken.sign() 时,需要传入的 secret 参数,这里的 secret 必须要与 前面设置 jwt() 中的 secret 一致。

更多关于 jsonwebtoken 的方法,可见:github.com/auth0/node-…

注册实现

账号、密码、验证码都合法后,再向用户邮箱发送链接。链接中的token参数是根据用户id生成的,这里不直接使用id,而是用token是为了防止用户伪造链接。当用户点击链接后,再将信息存入mongodb数据库。实际项目中,后续还需要对用户输入的字段进行验证。

   async register(ctx) {
    const { cid, code, username, password, name } = ctx.request.body;
    // 检查验证码是否有效,是否正确
    const isCodeCorrectAndValid = await checkCode(cid, code);
    if (isCodeCorrectAndValid) {
      // 检查username是否被注册
      const user = await UsersModel.findOne({
        username
      })
      if (user && (user.username !== undefined)) {
        ctx.body = {
          code: 404,
          msg: '用户名已注册过,请直接登录'
        }
      } else {
        // 检查name是否被注册
        const user2 = await UsersModel.findOne({
          name
        })
        if (user2 && (user2.name !== undefined)) {
          ctx.body = {
            code: 404,
            msg: '昵称与他人重复,请修改昵称'
          }
        } else {
          // 派发jwt,这里是为了防止用户伪造连接
          const token = jwt.sign({
            id,
          }, config.JWT_REGISTER_SECRET, {
            expiresIn: config.JWT_REGISTER_EXPIRESIN
          });
          // 信息存入redis
          await setValue(username, {
            password,
            name
          }, 30 * 60);
          // 发送邮件、验证邮箱否是是本人邮箱
          await send({
            subject: '注册-验证邮箱',
            data: {
              token,
            },
            route: '/confirm/register',
            expire: moment()
              .add(30, 'minutes')
              .format('YYYY-MM-DD HH:mm:ss'),
            email: username,
            name: name
          })

          ctx.body = {
            code: 200,
            msg: `验证链接已发至您的邮箱${username},请登录邮箱查看`,
          }
          return;

        }
      }
    } else {
      ctx.body = {
        code: 401,
        msg: '图片验证码错误'
      }
    }
  }
  // 用户验证注册接口
  async verifyRegister(ctx) {
    const { token } = ctx.request.body;
    if (token === undefined) {
      ctx.body = {
        code: 404,
        msg: '缺少参数'
      }
      return;
    }
    // 验证token是否正确
    let cert;
    try {
      cert = jwt.verify(token, config.JWT_REGISTER_SECRET);
    } catch (err) {
      ctx.body = {
        code: 404,
        msg: '很抱歉,token值无效或者链接已过期'
      }
      return;
    }
    const obj = await getHValue(cert.username);
    if (obj === null || isEmptyObject(obj)) {
      ctx.body = {
        code: 404,
        msg: '很抱歉,链接有误或者链接已过期'
      }
      return;
    }
    const { password, name } = obj;
    // 写入数据
    const data = new UsersModel({
      username: cert.username,
      password,
      name
    });
    const result = await data.save();
    ctx.body = {
      code: 200,
      msg: '注册成功'
    }
  }    

前端代码

在axios请求拦截器中,根据请求url,判断是否需要带上Authorization请求头。

在axios相应拦截器中,根据返回来的json,判断是否有token,有则将token存入localStorage。

import axios from "axios";
import { TOKEN } from "../storage/config";
import localStorage from "../storage/localStorage";
import { BASE_URL, TIMEOUT } from './config';
import errorHandle from "./errorHandle";
// 不需要鉴权
const PUBLIC_PATH_ARRAY = [
  /^\/public/,
  /^\/api\/login/,
  /^\/api\/public/,
]

const service = axios.create({
  baseURL: BASE_URL,
  timeout: TIMEOUT,
  headers: {
    "Content-Type": "application/json;charset=utf-8"
  }
});

service.interceptors.request.use((config) => {
  const token = localStorage.getItem(TOKEN);
  if (token
    && (!PUBLIC_PATH_ARRAY.some(item => item.test(config.url)))
    )
  ) {
    config.headers.common['Authorization'] = 'Bearer ' + token;
  }
  return config;
}, (error) => {
  errorHandle(error);
  return Promise.reject(error);
});

service.interceptors.response.use((response) => {
  if (response.status === 200) {
    //如果token存在则存在localStorage
    authorization && localStorage.setItem(TOKEN, authorization);
    return Promise.resolve(response.data); // 处理数据格式
  } else {
    return Promise.reject(response);
  }
}, (error) => {
  // 添加异常处理
  errorHandle(error);
  return Promise.reject(error);
});

export default service; 

我的思考&可以优化的点

续签

JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑,比如将jwt放入redis中并设置过期时间。所以,我们应该把jwt的过期时间设置的尽量短一点,比如30分钟。

然而,在实际操作中,总会遇到由于token快到了过期时间的临界点,而导致操作失败,从而需要再重新获取token重新操作一遍,用户体验感非常差。 所以,当离过期时间不足10分钟时,我们就派发新的token给用户,来实现token自动续签的功能。

后台获取token后,先解析token是否合法,合法再获取它的过期时间点,然后通过过期时间和当前系统时间的对比得出一个时间差,最后再判断这个时间差是否在10min内,是的话则结合你项目的场景刷新token返回给前端)具体参考下面核心代码:

import jwt from 'jsonwebtoken';
import config from '../../config';

/**
 * 判断token是否过期。
 * (过期则判断是否在10分钟之内,
 * 如果在10分钟之内则给用户返回新的token)
 */
const RefreshTokenHandle = async (ctx, next) => {
  //获取生成token时的过期时间,判断是否在允许过期延迟时间范围内,
  // 如果是则重新生成token返回,否则报错
  const payload = {};
  const oldToken = ctx.header.authorization.split(' ')[1];
  //防止token不传或token前缀不符,直接返回让后面的koa-jwt去处理
  if (!ctx.header.authorization || ctx.header.authorization.indexOf('Bearer ') == -1) {
    await next();
  } else {
    try {
      payload = jwt.verify(oldToken, config.JWT_SECRET);
    } catch (err) {
      console.log(err.message, new Date(err.expiredAt).getTime());
      throw err;
    }
    if (payload && payload.exp) {
      var allowTime = parseInt(payload.exp) - parseInt(new Date().getTime() / 1000);
      console.log(allowTime);
      if (allowTime <= 60 * 10) {
        const { username, _id } = payload;
        await next();
        // 解决重复生成jwt的问题,只记录过去5秒内原始jwt刷新生成新jwt的数据
        // 几秒内如果发现同样的jwt再次请求刷新,就返回相同的新的jwt数据。
        let newToken = await getValue(oldToken)
        if (newToken === null) {
          newToken = jwt.sign({
            username,
            _id,
          }, config.JWT_SECRET, {
            expiresIn: config.JWT_EXPIRESIN
          });
          await setValue(oldToken, newToken, 5);
        }
        ctx.body.token = newToken;
      } else {
        await next();
      }
    }
  }
}
export default RefreshTokenHandle;
    

假如用户发送了两个一模一样的请求a和请求b,由于网络原因,先发送的请求a反而后到达。那么就会造成重复生成jwt的问题。

解决方案:只记录过去5秒内原始jwt刷新生成新jwt的数据。几秒内如果发现同样的jwt再次请求刷新,就返回相同的新的jwt数据。

安全

JWT 中的paload默认是不加密的情况下,不能将秘密数据写入 JWT。

为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

项目地址

WeShare是一款发帖分享论坛。

🚀 登录模块实现了用户注册、登录,忘记密码(通过邮箱验证找回)、修改密码功能

🚀 帖子模块实现了发帖、删帖、收藏帖子、评论回复、评论点赞功能

🚀 用户模块实现了修改用户资料、展示用户资料、每日签到获取积分功能

项目亮点

🚀 采用jwt+localStorage进行会话管理,实现了用户在期限内免登录的效果

🚀 利用axios请求拦截器解决异步请求竞态问题

🚀 对首页帖子展示实现滚动加载(懒加载)

前端github.com/missingone6…

后端github.com/missingone6…

后端接口文档www.showdoc.com.cn/weshare/972…