Koa2 + Vue【JWT鉴权之路】

870 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第10天,点击查看活动详情

前言:下周轮到我做技术分享,寻思分享点啥能配的上我滴身份,嗯?我是什么身份?

———— JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案,本文介绍它的原理和用法。

———— 下面说下春野的JWT鉴权之路。

阮一峰—JWT

一、JWT

1.跨域认证的问题

互联网服务离不开用户认证。一般流程是下面这样:

  • 用户向服务器发送用户名和密码。
  • 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
  • 服务器向用户返回一个 session_id,写入用户的 Cookie。
  • 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
  • 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。

举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?

一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。

另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。

2.JWT 的原理

JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。

{
  "姓名": "张三",
  "角色": "管理员",
  "到期时间": "2018年7月1日0点0分"
}

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。

服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

3.瞅瞅JWT

从前有一天,维基百科说:JSON Web Token(JWT,读作 [/dʒɒt/]),是一种基于JSON的、用于在网络上声明某种主张的令牌(token)。

它由三部分组成: 头信息(header), 消息体(payload)和签名(signature)阮一峰—JWT 它是一个很长的字符串,中间用点(.)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。

写成一行,就是下面的样子。

Header.Payload.Signature

1.1 Header

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

// HS256 表示使用了 HMAC-SHA256 来生成签名。
{
  "alg": "HS256",
  "typ": "JWT"
}

上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。 最后,将上面的 JSON 对象使用 Base64URL 算法(详见后文)转成字符串。

1.2 Payload

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

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

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

{
  "sub": "KaiHui",
  "name": "Chunye",
  "id": "156",
  "admin": true,
  "iat": "7200000"
}

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

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

1.3 Signature

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

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

key = 'secretkey'  
unsignedToken = encodeBase64(header) + '.' + encodeBase64(payload)  
signature = HMAC-SHA256(key, unsignedToken)

1.未签名的令牌由base64url编码的头信息和消息体拼接而成(使用"."分隔)
2.签名则通过私有的key计算而成

3.在未签名的令牌尾部拼接上base64url编码的签名(同样使用"."分隔)就是JWT了:
token = HMACSHA256(base64UrlEncode(header) + 
		'.' + base64UrlEncode(payload) ,
		secret)

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

1.4 Base64URL

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

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

二、JWT 的使用方式

方案一:

1.客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。

2.此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

Authorization: Bearer <token>

3.服务端使用自己保存的key计算、验证签名以判断该JWT是否可信。

方案二:

跨域的时候,JWT 就放在 POST 请求的数据体里面。

三、JWT 的几个特点

  • JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。

  • JWT 不加密的情况下,不能将秘密数据写入 JWT。

  • JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。

  • JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。

  • JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。

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

四、思路

1.前端登陆校验

  • 首先表单校验,校验成功账号密码发送后台。
  • 后台接收数据,进行数据校验,校验成功使用JWT生成Token返回给前端。
  • 前端请求成功,前端拿到Token之后把Token存储到Cookie或者localStorage中。
  • 之后部分请求做token权限验证处理(后续会在所有token中加入token有效认证,如果无效,则重新登陆)

2.权限校验

  • 部分前端请求时请求头都会携带Token
  • 后台收到请求先校验token,如果token合法(token正确、没过期、有权限),则执行next()。
  • 否则直接返回401以及对应的message。

五、上代码

前端项目基本配置

  • Vue3 + Vite + Element-plus

f9229ffbc55b4ad9a9894138705bdcd5.png

login.vue

// 主要代码 login.vue
<template>
  <div id="login">
    <div class="login-wrapper">
      <div class="title-wrapper">
        <h1 class="title">登录</h1>
      </div>
      <div class="form-wrapper">
        <el-form
          ref="formRef"
          :model="formData"
          :rules="rulesAccount"
          label-width="80px"
        >
          <el-form-item label="用户名" prop="username">
            <el-input v-model="formData.username"></el-input>
          </el-form-item>
          <el-form-item label="密码" prop="password">
            <el-input type="password" v-model="formData.password"></el-input>
          </el-form-item>
          <el-form-item class="btn-item">
            <el-button type="primary" @click="login">登录</el-button>
          </el-form-item>
          <el-form-item class="btn-item">
            <el-button type="primary" @click="verifyToken"
              >验证token有效性</el-button
            >
          </el-form-item>
        </el-form>
      </div>
    </div>
  </div>
</template>

<script setup>
// 引入reactive(定义引用类型)
import { reactive } from "vue";
// 引入路由函数
import { useRouter } from "vue-router";
// 引入axios
import axios from "axios";
// element提示
import { open1, open4 } from "../../hooks/ElMessage.js";
// 引入login校验规则
import { rulesAccount } from "./config/login-config";

// 定义双向绑定参数
let formData = reactive({
  username: "",
  password: "",
});

// 注册路由
const router = useRouter();

// 登陆验证
const login = async () => {
  const { username, password } = formData;
  console.log(formData, "formData");

  // 向后台发送账号密码,获取返回信息
  const tokenResult = await axios.post("/api/token", {
    username,
    password,
  });

  // 如果返回错误码
  const CTXBODY = tokenResult.data;
  if (CTXBODY.code === 10001) {
    open4(CTXBODY.msg);
    return;
  }

  // 存储token
  localStorage.setItem("token", tokenResult.data.token);
  // 跳转页面
  router.push("/list");
  // 登陆成功提示
  open1(CTXBODY.msg);
};

// 检验token有效性
const verifyToken = async () => {
  const userToken = localStorage.getItem("token");
  console.log(userToken);

  const verifyTokenMsg = await axios.post("/api/token/verify", {
    userToken,
  });
  if (verifyTokenMsg.data.code === 200) {
    open1(verifyTokenMsg.data.msg);
    return;
  }
  open4("token失效!");
};
</script>

list.vue

// 主要代码
<template>
  <div id="list">
    <el-button type="primary" @click="getContent">获取文章内容</el-button>
    <el-button type="primary" @click="addContent">新增文章内容</el-button>

    <h1>{{ resultDate }}</h1>
  </div>
</template>

<script setup>
import axios from "axios";
import { Base64 } from "js-base64";
import { ref } from "vue";

// 初始化响应值
let resultDate = ref();

// 普通请求,不携带token
const getContent = async () => {
  const result = await axios.get("/api/content");
  resultDate.value = result.data;
  console.log(resultDate.value);
};

// 需鉴权请求,携带token
const addContent = async () => {
  const result = await axios({
    url: "/api/content",
    method: "post",
    headers: { Authorization: _encode() },
  });
  resultDate.value = result.data;
};

// 生成加密token
const _encode = () => {
  const token = localStorage.getItem("token");
  const encoded = Base64.encode(token + ":");
  return `Basic ${encoded}`;
};
</script>

vite.config.js

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
// 解决跨域
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
  plugins: [vue()],
});

后台详解

  • 基本配置

9e2a51956601444a8d4dd56fa6119d99.png

  • 新建文件夹jwt-server
  • npm init -y (初始化项目,生成package.json文件)
  • npm install koa @koa/router koa-bodyparser basic-auth jsonwebtoken --save 安装第三方依赖
  • 新建app.js入口文件 并且配置 在这里插入图片描述
  • 配置启动方式并启动项目 在这里插入图片描述
  • 启动成功 在这里插入图片描述

验证用户名密码

users.js

// app/data/users.js
// 模拟数据库信息
const users = [
  {
    id: 1,
    username: "zhangsan",
    password: 123456,
    nickname: "张三",
  },
  {
    id: 2,
    username: "李四",
    password: 123456,
    nickname: "李四",
  },
];

module.exports = users;

app.js

const Koa = require("koa");
const bodyParser = require("koa-bodyparser");
// 引入token相关路由
const tokenRouter = require("./app/api/token");

const app = new Koa();

// 解析前端发送过来的数据, ctx.request.body
app.use(bodyParser());

// 注册 token 相关路由
app.use(tokenRouter.routes());

// 端口号配置
app.listen(3000);

token.js

// app/api/token.js
const Router = require("@koa/router");
const users = require("../data/users");

const tokenRouter = new Router({
  // 设置路由前缀 /token
  prefix: "/token",
});

tokenRouter.post("/", async (ctx) => {
  // 解构读取信息
  const { username, password } = ctx.request.body;

  // 验证账号密码是否正确
  const result = verifyUsernamePassword(username, parseInt(password));

  // 返回结果
  ctx.body = {
    ...result,
  };
});

function verifyUsernamePassword(username, password) {
  const index = users.findIndex((item) => {
    return item.username === username && item.password === password;
  });

  // users是否包含返回的数据,如果包含返回下标(为true),否则返回undefined
  const user = users[index];

  // 定义返回值
  const codeMssage = {
    code: 200,
    msg: "登陆成功~",
  };

  // 验证错误,返回错误参数
  if (!user) {
    codeMssage.code = 10001;
    codeMssage.msg = "账号密码错误~";
    return codeMssage;
  }

  // 验证成功
  return codeMssage;
}

// 导出
module.exports = tokenRouter;

第一阶段效果

账号密码验证

生成Token

  • token.js新增获取token代码(generateToken头部引入) token获取

新增创建工具方法文件

// core/util.js

const jwt = require("jsonwebtoken");
const { secretKey, expiresIn } = require("../config/config");

// 使用jwt生成token,传入用户id和权限
function generateToken(uid, scope) {
  const token = jwt.sign(
    {
      uid,
      scope,
    },
    secretKey,
    {
      expiresIn,
    }
  );

  return token;
}

module.exports = { generateToken };

新增JWT配置文件

config/config.js

module.exports = {
  // 密钥
  secretKey: "Jwt%._CYweb#",
  // 有效时间
  expiresIn: 24 * 60 * 60,
};

第二阶段效果

成功获取到token

验证token有效性

在平时我们写项目的时候,可以直接在axios里封装请求携带token,(持久保持Token有效性)

前端login.vue中新增代码

// 检验token有效性
const verifyToken = async () => {
  const userToken = localStorage.getItem("token");
  
  const verifyTokenMsg = await axios.post("/api/token/verify", {
    userToken,
  });
  
  if (verifyTokenMsg.data.code === 200) {
    open1(verifyTokenMsg.data.msg);
    return;
  }
  open4("token失效!");
};

token校验

token.js 中引入auth方法进行token校验

// token.js 新增代码 (头部引入Auth)
// 验证token有效性的接口
tokenRouter.post("/verify", async (ctx) => {
  const token = ctx.request.body.userToken;
  const isValid = await Auth.verifyToken(token);
  if (isValid) {
    ctx.body = {
      code: 200,
      msg: "token验证成功",
    };
    return;
  }
  ctx.body = {
    code: 401,
    msg: "token失效",
  };
});

新增auth文件,对token校验

// middlewares/auth.js
const jwt = require("jsonwebtoken");
const { secretKey } = require("../config/config");

class Auth {
  // 定义静态方法verifyToken,验证token
  static async verifyToken(token) {
    try {
      jwt.verify(token, secretKey);
      return true;
    } catch (e) {
      return false;
    }
  }
}

module.exports = {
  Auth,
};

第三阶段效果

token校验

token权限校验

新增content.js路由

// app/api/content.js 需在app.js里注册下路由
const Router = require("@koa/router");
const { Auth } = require("../../middlewares/auth");

const contentRouter = new Router({
  prefix: "/content",
});

// 获取文章内容,不需要鉴权
contentRouter.get("/", async (ctx) => {
  ctx.body = "获取文章内容成功";
});

// 新增这种post请求,需要验证token是否有效合法,添加中间件鉴权
contentRouter.post("/", new Auth().middleware, async (ctx) => {
  ctx.body = "新增文章内容成功";
});

module.exports = contentRouter;

Auth增加权限校验规则

const jwt = require("jsonwebtoken");
const { secretKey } = require("../config/config");
// 引入basic-auth,用作encode解密
const basicAuth = require("basic-auth");

class Auth {
  // 返回中间件函数
  get middleware() {
    return async (ctx, next) => {
      // 书写权限逻辑
      // 解析请求头的authorization
      const token = basicAuth(ctx.request);
      // 检验token是否为空
      let msg = "请求不合法";
      if (!token || token.name === null) {
        console.log(token.name, 11);
        ctx.body = {
          code: 10005,
          msg: msg,
        };
        return;
      }

      //   判断token是否正确(过期或不合法)
      try {
        var decoded = jwt.verify(token.name, secretKey);
      } catch (e) {
        // 1.token不合法
        // 2.token合法但过期 e.name TokenExpiredError
        if (e.name == "TokenExpiredError") {
          msg = "token已过期";
        }
        console.log(msg);
        ctx.body = {
          code: 10005,
          msg: msg,
        };
        return;
      }

      await next();
    };
  }

  // 定义静态方法verifyToken,验证token
  static async verifyToken(token) {
    try {
      jwt.verify(token, secretKey);
      return true;
    } catch (e) {
      return false;
    }
  }
}

module.exports = {
  Auth,
};

第四阶段效果图

token校验

角色权限校验

auth.js新增权限校验属性

// 权限数字控制api访问权限
  constructor(level) {
    // 用户
    Auth.USER = 2;
    // 管理员
    Auth.ADMIN = 8;
    // 传进来的用户权限
    this.level = level;
  }
  
  // 对权限进行对比,如果用户权限小于当前接口可操作权限,返回权限不足
  if (decoded.scope  <  this.level) {
        ctx.body = {
          code: 401,
          msg: "权限不足",
          request: `${ctx.method}`,
        };
        return;
  }
  await next();

content.js写入接口权限等级

jwt接口权限等级

token.js 里面获取用户生成token时的权限

生成token时的权限

第五阶段效果图

jwt权限验证

总结

异常处理最好写成全局的,要不然逻辑太冗余了。 像JSONWebToken这样好玩的东西咱们都理解了,那以后还不是海阔任鱼跃,天高任鸟飞了...咳 下班!

往期精彩文章链接

Token【JWT与传统认证流程】

相关资料


水平有限,还不能写到尽善尽美,希望大家多多交流,跟春野一同进步!!!