开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第10天,点击查看活动详情
前言:下周轮到我做技术分享,寻思分享点啥能配的上我滴身份,嗯?我是什么身份?
———— JSON Web Token(缩写 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 内部是没有换行的,这里只是为了便于展示,将它写成了几行。
写成一行,就是下面的样子。
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
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()],
});
后台详解
- 基本配置
- 新建文件夹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头部引入)
新增创建工具方法文件
// 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有效性
在平时我们写项目的时候,可以直接在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权限校验
新增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,
};
第四阶段效果图
角色权限校验
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写入接口权限等级
token.js 里面获取用户生成token时的权限
第五阶段效果图
总结
异常处理最好写成全局的,要不然逻辑太冗余了。 像JSONWebToken这样好玩的东西咱们都理解了,那以后还不是海阔任鱼跃,天高任鸟飞了...咳 下班!
往期精彩文章链接
相关资料
- jwt.io/introductio…
- www.ruanyifeng.com/blog/2018/0…
- www.bilibili.com/video/BV1S3…
- 本文源码:github.com/JinChunYe2/…
水平有限,还不能写到尽善尽美,希望大家多多交流,跟春野一同进步!!!