通过前面的努力,我们已经实现了一个基本且完整的前后端全栈博客项目。
那么接下来呢,我们对这个项目敲敲打打,看看哪些地方有隐患,哪些地方需要改善,哪些地方需要调整。
// 点击删除文章按钮
document.querySelector(".panel").addEventListener("click", async (evt) => {
if (evt.target.className === "del") {
const user = JSON.parse(localStorage.getItem("user"));
const id = Number(evt.target.dataset.id);
// 把拿到的数据变成JSON结构
const { code, data, msg } = await utilHttp("/api/article/delete", {
id: id,
author: user.id,
});
utilShowMsg(msg);
if (code === 0) {
apiArticleSelect();
}
}
});请看以上请求,有 2 个参数,一个是文章 id,一个是作者 id。
这里会有个问题,作者 id 是手动上传的,那么我们可以随意更改这个作者的 id,来伪造成其他作者发布和操作文章。
这样显然是不行的,我们可以用什么来解决这个问题呢?那就是 JWT。
先来了解一下 JWT 相关信息:
JWT (JSON Web Token) 是一种开放标准 (RFC 7519),用于在各方之间以紧凑和自包含的方式安全地传输信息。
JWT 通常用于身份验证和授权。
JWT 由三部分组成,分别用点 (.) 分隔:
头部 (Header)
通常包含两部分:令牌的类型 (JWT) 和所使用的签名算法 (如 HMAC,SHA256 或 RSA)。
{
"alg": "HS256",
"typ": "JWT"
}负载 (Payload)
包含声明 (claims),即关于实体 (通常是用户) 及其他数据的陈述。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}签名 (Signature)
通过将编码后的头部、负载和一个密钥结合起来生成,用于验证消息在传输过程中未被更改。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)JWT 的工作原理
- 用户通过身份验证后,服务器生成一个
JWT并返回给用户。 - 用户在后续请求中将
JWT作为凭证发送,通常放在Authorization头中。 - 服务器验证
JWT的有效性,如果有效,则允许访问受保护的资源。
关于 JWT 的介绍大概就是这样,可能部分读者还是一脸懵逼,没关系,接下来我们动手实践。
为了实现 JWT 验证,我们将会用到 fast-jwt 这个 npm 依赖包。
那么我们需要在什么地方用到这个包呢?那就是在登录的时候,生成 jwt 签名,然后连同用户信息,一起发送给浏览器。
// /config.js 文件
export const config = {
jwt: {
key: "coolApi666", // 密钥
algorithm: "HS256", // 算法
expiresIn: "7d", // 过期时间
},
};先在根目录下创建一个 config.js 文件,内容如上,配置了 jwt 的密钥,算法和过期时间。
// /apis/admin/login.js 文件
import { createSigner } from "fast-jwt";
import { mysqlPool } from "../../mysql.js";
import { ajvValid } from "../../utils.js";
import { config } from "../../config.js";
const signSync = createSigner(config.jwt);
export const apiSchema = {
// 元数据
meta: {
tags: ["用户"],
summary: "用户登录",
},
// 请求协议
request: {
type: "object",
properties: {
username: {
type: "string",
title: "用户名",
minLength: 2,
maxLength: 20,
},
password: {
type: "string",
title: "密码",
minLength: 6,
maxLength: 50,
},
},
required: ["username", "password"],
additionalProperties: false,
},
// 返回协议
response: {
type: "object",
properties: {
token: {
type: "string",
description: "登录令牌",
},
},
},
};
// 接口逻辑
export default async (req) => {
try {
await ajvValid(apiSchema.request, req.body);
// ------------------------------------------------------
// 从连接池中获取一个数据库连接
const db = await mysqlPool.getConnection();
// 查询数据库是否有对应的用户数据
const [rows] = await db.query({
sql: "SELECT * FROM `user` WHERE `username`=:username LIMIT 1",
values: {
username: req.body.username,
},
});
// ------------------------------------------------------
// 如果查到了用户数据,说明该用户名已注册
if (rows.length <= 0) {
return {
code: 1,
msg: "用户未注册",
};
}
// ------------------------------------------------------
// 判断密码是否匹配
const [user] = rows; // rows是一个数组,我们这里用user变量去取数组的第一个值
if (user.password !== req.body.password) {
return {
code: 1,
msg: "密码错误",
};
}
const token = signSync({
id: user.id,
username: user.username,
});
// 释放数据库连接
db.release();
// 返回成功信息
return {
code: 0,
msg: "登录成功",
token: token,
data: user,
};
} catch (err) {
if (err?.from === "ajv") {
return {
code: 1,
msg: err.data,
};
} else {
return {
code: 1,
msg: "未知错误",
};
}
}
};然后来到 /apis/admin/login.js 文件,我们导入 fast-jwt 中的 createSigner,导入 config.js 文件的配置并创建 jwt 的生成函数 signSync。
在返回用户信息之前,将用户的 id 和 username 通过 signSync 来进行加密,生成 token,连同用户信息一起返回给客户端。

这是登录接口的返回效果。

我们把 token 复制,随便在浏览器搜索 jwt 在线解密,就能得到图中的数据。
可以看到,这一串字符串经过 jwt 解密后,就能得到我们的用户 id 和用户名。
那么我们试想一下,每次请求接口,把这串字符上传,后端解密后,是不是就能得到用户 ID 了呢?这样我们就不用手动上传作者的 id 了。
那么一个重要的问题来了,这个 token 字符串可以被篡改吗?别急,我们后面验证。
// /public/admin/index.js 文件
// 点击登录按钮
document.querySelector(".login").addEventListener("click", async () => {
const username = document.querySelector(".username").value;
const password = document.querySelector(".password").value;
const { code, data, token, msg } = await utilHttp("/api/admin/login", {
username: username,
password: password,
});
utilShowMsg(msg);
if (code === 0) {
localStorage.setItem("user", JSON.stringify(data));
localStorage.setItem("token", token);
// 1.5 秒后,跳转到文章管理页面
setTimeout(() => {
location.href = "/admin/article.html";
}, 1500);
}
});返回后,我们在 /public/admin/index.js 文件的登录逻辑中,拿到 token,并把 token 保存到本地存储中。
// /public/utils.js 文件
// 请求封装
const utilHttp = async (url, data, action = "common") => {
const token = localStorage.getItem("token") || "";
// 初始化参数
const params = {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
};
// 普通请求
if (action === "common") {
params.headers["Content-Type"] = "application/json;charset=utf-8";
params.body = JSON.stringify(data);
}
// 上传文件
if (action === "upload") {
const formData = new FormData();
formData.append("file", data);
params.body = formData;
}
// 收到的返回值
const response = await fetch(url, params);
// 把拿到的数据变成JSON结构
const result = await response.json();
return result;
};然后在 /public/utils.js 文件的 http请求封装 逻辑中,从本地存储拿到 token,再设置到 headers 的 Authorization 中,这样每个请求都会自动带上我们的 token 传给后端接口。
// /server.js 文件
import { createServer } from "node:http";
import { readFileSync, existsSync } from "node:fs";
import { createVerifier } from "fast-jwt";
import { lookup } from "mime-types";
import { apiMaps } from "./routes.js";
import { config } from "./config.js";
const verifySync = createVerifier({ key: config.jwt.key });
const server = createServer(async (req, res) => {
req.session = {};
if (req.url.startsWith("/api/") === true) {
if (["/api/admin/login", "/api/admin/register"].includes(req.url) === false) {
try {
req.session = verifySync(req.headers.authorization?.split(" ")?.[1] || "none");
} catch (err) {
res.end(
JSON.stringify({
code: 1,
msg: "token 错误或已过期",
})
);
return;
}
}
}
// 其他代码
// ......
// ......
});接下来,在 /server.js 文件中,导入 fast-jwt 中的 createVerifier 方法,并通过配置中的 jwt.key 生成验证函数。
设置默认的每个请求的会话 req.session 为空对象 {},然后对 jwt 的 token 进行 验证解析。
如果验证并解析成功,则把解析的结果赋值给 req.session,否则提示 token 错误或已过期。
经过这么一操作,我们就能把前后端传参中的所有 author(作者) 参数全部去掉了,我们直接从 req.session.id 来获取当前用户的 ID。

请看截图,左侧红色背景部分,就是去掉的后端接口中的 author 参数。

而上图,左侧红色部分,则是前端代码去掉了 author 参数后的效果。
这样,我们就把原本需要手动上传的作者 id,通过 jwt 来自动获取和接收了。
再回到我们之前的问题,要是 token 被篡改了怎么办?
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidXNlcm5hbWUiOiLpmYjpmo_mmJMiLCJpYXQiOjE3MzM4MzQwNDAsImV4cCI6MTczNDQzODg0MH0.ShVROYjqMxK-mY9jbMfke-XkSdG0VFwkXC6qFgZAjzA
这是我们登录后得到的 token。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidXNlcm5hbWUiOiLpmYjpmo_mmJMiLCJpYXQiOjE3MzM4MzQwNDAsImV4cCI6MTczNDQzODg0MH1.ShVROYjqMxK-mY9jbMfke-XkSdG0VFwkXC6qFgZAjzA
很简单,我们把第二个点号 . 前面的数字 0 改成数字 1。

可以看到,我们仅仅只是把其中的数字 0 改成 1,整个 token 就解析失败了。
那么答案就是,通过 jwt 来进行前后端用户数据的传递,是不可篡改的。
这样,我们就能保证,用户张三登录成功后,只能对其自己的文章进行添加、删除、修改,是无法通过修改自己的 token 来操作其他人的文章的。
这样我们就实现了,jwt 的登录和文章管理,也进一步让我们的整个系统更加安全。
