在Node.Js中使用JWT实现Token用户验证

2,313 阅读7分钟

1. 用户验证方式

1.1. cookie & session

流程是下面这样:

    1. 用户向服务器发送用户名和密码。

    2. 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。

    3. 服务器向用户返回一个 session_id,写入用户的 Cookie。

    4. 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。

    5. 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

缺点:

    1. 扩展性(scaling)不好,如果是服务器集群,就要求每台服务器都能够读取session,工程量大

    2. 对于浏览器外的其他客户端(比如iOS、Android),必须手动的设置cookie和session

    3. Cookie的大小限制是4KB,对于复杂的需求来说是不够的

    4. Cookie是明文传递的,所以存在安全性的问题

解决方案:所有数据都保存在客户端,每次请求都发回服务器,JWT 就是这种方案的一个代表。

1.2. JWT

在目前的前后端分离的开发过程中,使用token来进行身份验证的是最多的情况,我们用JWT来生成token来验证。步骤是

    1. 当用户第一次登陆时,用户名名密码都正常后,后端根据用户名等通过JWT生成一个token,在响应头中带给前端
    
    2. 当用户访问其他资源时,在后端对携带过来的token进行验证,验证通过,才能访问

2. JWT实现token机制

2.1. token组成

image.png JWT生成token分为三部分:Header,Payload,Signature

2.1.1. Header

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。

    {
      "alg": "HS256",
      "typ": "JWT"
    }
    
    alg:algorithm的缩写,即采用的加密算法,默认是 HMAC SHA256HS256),采用同一个密钥进行

加密和解密,这种方式叫做对称加密。为了更好的安全性,一般采用RS256,非对称加密

    typ:JWT,固定值,通常都写成JWT即可;

最后,将上面的 JSON 对象使用 Base64URL 算法(详见后文)转成字符串。

2.1.2. Payload

Payload是携带的数据,也是个 JSON 对象比如我们可以将用户的id和name放到payload中。JWT 规定了7个官方字段,供选用

    iss (issuer):签发人
    exp (expiration time):过期时间
    sub (subject):主题
    aud (audience):受众
    nbf (Not Before):生效时间
    iat (Issued At):签发时间
    jti (JWT ID):编号

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

这个 JSON 对象也要使用 Base64URL 算法转成字符串。

2.1.3. Signature

Signature 部分是对前两部分的签名,防止数据篡改

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

    HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。

2.1.4. Base64URL

前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法。

2.1.5. 非对称加密

前面提到 HS256 是个对称加密算法,如果密钥暴露,就是件非常危险的事情。

这个时候我们可以使用非对称加密,RS256:

    私钥(private key):用于发布令牌

    公钥(public key):用于验证令牌

可以使用openssl来生成一对私钥和公钥:

    Mac直接使用terminal终端即可

    Windows默认的cmd终端是不能直接使用的,建议直接使用git bash终端
    
    openssl

    > genrsa -out private.key 1024

    > rsa -in private.key -pubout -out public.key

生成的公钥 私钥文件如下 image.png

3. Node.js模拟用户验证

3.1. 创建工程结构,安装所需包

image.png

所需依赖如下

image.png

3.2. 用户注册

步骤:

    1. 用户输入注册用户名和密码,后端获取到进行验证。

    2. 首先验证用户名和密码是否有效,比如是否为空,现有的数据库是否已有相同的用户名

    3. 数据有效后,对密码进行md5加密,放入数据库中

核心代码如下:

router:

    const Router = require('koa-router');
    const userRouter = new Router({prefix: '/users'});
    const {verifyUsers,handlePassword} = require("../middleware/user.middleware.js")
    const UserController = require('../controller/userController.js')

    //中间件是一步步过去的,先是验证用户有效性,再对密码进行加密,最后创建用户。
    userRouter.get('/',verifyUsers,handlePassword, UserController.create);

user.middleware.js

    const errTypes = require("../constants/error-types.js");
    const service = require("../service/userService.js")
    const md5paassword = require("../utils/password-handle.js")
    const verifyUsers = async (ctx, next) => {
      // 1.获取用户名和密码
      const { userName, password } = ctx.request.body;

      //判断用户账号或者密码不为空
      if (!userName || !password) {
        const error = new Error(errTypes.NAME_OR_PASSWORD_IS_REQUIRED);

        return ctx.app.emit("error", error,ctx);
      }
      // 3.判断这次注册的用户名是没有被注册过
      const result = await service.getUserByName(userName);
      if (result.length) {
        const error = new Error(errTypes.USER_ALREADY_EXISTS);
        return ctx.app.emit('error', error, ctx);
      }
      //如果有效,则进入下一个中间件
      await next();
    };

    const handlePassword = async(ctx,next)=>{
        //将密码加密后继续调用后面的中间键
        const { password } = ctx.request.body;
        ctx.request.body.password = md5paassword(password)
        await next()
    }
    module.exports = { verifyUsers,handlePassword };

userController.js

    const UserService =require('../service/UserService.js')
    class UserController {
      async create(ctx, next) {
        //1.获取用户传递的参数
        const user = ctx.request.body;
        //2.查询参数
        const result = await UserService.create(user);
        //3.返回数据
        ctx.body = "hello";
      }
    }
    module.exports = new UserController()

userService.js

    const connect = require("../app/dataBase.js");
    class UserService {
      async create(user) {
        const { userName, password } = user;
        const statement = `insert into user (name,password) values (?,?)`;
        const result = await connect.execute(statement, [userName, password]);
        return result[0];
      }
      async getUserByName(userName) {
        const statement = `SELECT * FROM user WHERE name = ?;`;
        const result = await connect.execute(statement, [userName]);
        return result[0];
      }
    }
    module.exports = new UserService();

现在我们来验证下:使用postman调用/users接口,将参数带入验证。

image.png

数据库有数据了,并且密码加密了。

image.png

如果我们再一次访问

image.png

3.3. token验证

token的验证机制上面提过了,现在就直接代码

auth.router.js

const Router = require('koa-router');
const authRouter = new Router();

const {
  login,
  success
} = require('../controller/auth.controller');
const {
  verifyLogin,
  verifyAuth
} = require('../middleware/auth.middleware');

//登陆时只验证用户名 密码是否正确,然后返回一个token回去
authRouter.post('/login', verifyLogin,login);

//访问其他资源时,就比对token
authRouter.get('/test', verifyAuth, success);

module.exports = authRouter;

auth.middleware.js


    const jwt = require("jsonwebtoken");
    const { PUBLIC_KEY } = require("../app/config");

    const errTypes = require("../constants/error-types");
    const service = require("../service/userService");
    const md5password = require("../utils/password-handle")
    const verifyLogin = async (ctx, next) => {
      //1.获取用户名和密码
      const { userName, password } = ctx.request.body;

      //2.判断用户名和密码是否为空
      if (!userName || !password) {
        const error = new Error(errTypes.NAME_OR_PASSWORD_IS_REQUIRED);
        return ctx.app.emit("error", error, ctx);
      }
      // 3.判断用户是否存在
      const result = await service.getUserByName(userName);
      const user = result[0]
      if (!user) {
        const error = new Error(errTypes.USER_DOES_NOT_EXISTS);
        return ctx.app.emit("error", error, ctx);
      }

      // 4.判断密码是否和数据库中的密码是一致(加密)
      if(md5password(password) !== user.password ){
        const error = new Error(errTypes.PASSWORD_IS_INCORRENT);
        return ctx.app.emit("error", error, ctx);
      }
      //将user信息放入ctx,一边后面的中间件处理
      ctx.user = user
      await next();
    };

    const verifyAuth =async(ctx,next)=>{
        console.log('验证登陆的授权');
        //获取token
        const authorization =ctx.headers.authorization

        if(!authorization){
            const error =new Error(errTypes.UNAUTHORIZATION);
            return ctx.app.emit('error',error,ctx)
        }
        //因为是postman发送过来的,因此会多出Bearer这个字符
        const token = authorization.replace('Bearer ', '');
        //验证token

        try {
            const result =jwt.verify(token,PUBLIC_KEY,{
                algorithms:['RS256']
            })
            ctx.user =result
            await next()
        } catch (error) {
            const err =new Error(errTypes.UNAUTHORIZATION)
            return ctx.app.emit('error',err,ctx)
        }
    }

    module.exports ={
        verifyLogin,
        verifyAuth
    }

auth.controller.js

  const jwt = require('jsonwebtoken');
  const authService =require('../service/auth.servicece')
  const { PRIVATE_KEY } = require('../app/config')
  class authController {
    async login(ctx, next) {
      const {id,userName} = ctx.user
      const token =jwt.sign({id,userName},PRIVATE_KEY,{
        //24h后失效
        expiresIn:60*60*24,
        //非对称加密
        algorithm:'RS256'
      })
      ctx.body = {id,userName,token};
    }
    async success(ctx,next){
      ctx.body = "授权成功"
  }
  }
  module.exports = new authController()

auth.servicece.js

  const connect = require("../app/dataBase.js");

  class authService {
    async create(user) {
      const { userName, password } = user;

      const statement = `insert into user (name,password) values (?,?)`;

      const result = await connect.execute(statement, [userName, password]);
      return result[0];
    }
    async getUserByName(userName) {
      const statement = `SELECT * FROM user WHERE name = ?;`;
      const result = await connect.execute(statement, [userName]);
      return result[0];
    }
  }
  module.exports = new authService();

现在我们登陆下试试:

image.png 可以看到response里面是由token返回给我们。在postman里面设置一个全局变量,这样,在test接口我们直接用这个变量放到header里面

image.png

可以看到是授权成功的

image.png

我们在token里面更改一下,看看会发生什么事情。

image.png

此外还可以在代码里修改token的过期时间,postman里面进行验证。

最后贴上 github地址

github.com/xiaoboRao/n…

参考文章

restfulapi.cn/