Skip to content

通过前面的努力,我们已经实现了一个基本且完整的前后端全栈博客项目。

那么接下来呢,我们对这个项目敲敲打打,看看哪些地方有隐患,哪些地方需要改善,哪些地方需要调整。

js
// 点击删除文章按钮
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) 和所使用的签名算法 (如 HMACSHA256RSA)。

json
{
    "alg": "HS256",
    "typ": "JWT"
}

负载 (Payload)

包含声明 (claims),即关于实体 (通常是用户) 及其他数据的陈述。

json
{
    "sub": "1234567890",
    "name": "John Doe",
    "admin": true
}

签名 (Signature)

通过将编码后的头部、负载和一个密钥结合起来生成,用于验证消息在传输过程中未被更改。

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

JWT 的工作原理

  1. 用户通过身份验证后,服务器生成一个 JWT 并返回给用户。
  2. 用户在后续请求中将 JWT 作为凭证发送,通常放在 Authorization 头中。
  3. 服务器验证 JWT 的有效性,如果有效,则允许访问受保护的资源。

关于 JWT 的介绍大概就是这样,可能部分读者还是一脸懵逼,没关系,接下来我们动手实践。

为了实现 JWT 验证,我们将会用到 fast-jwt 这个 npm 依赖包。

那么我们需要在什么地方用到这个包呢?那就是在登录的时候,生成 jwt 签名,然后连同用户信息,一起发送给浏览器。

js
// /config.js 文件
export const config = {
    jwt: {
        key: "coolApi666", // 密钥
        algorithm: "HS256", // 算法
        expiresIn: "7d", // 过期时间
    },
};

先在根目录下创建一个 config.js 文件,内容如上,配置了 jwt 的密钥,算法和过期时间。

js
// /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

在返回用户信息之前,将用户的 idusername 通过 signSync 来进行加密,生成 token,连同用户信息一起返回给客户端。

登录返回token

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

token解密数据

我们把 token 复制,随便在浏览器搜索 jwt 在线解密,就能得到图中的数据。

可以看到,这一串字符串经过 jwt 解密后,就能得到我们的用户 id 和用户名。

那么我们试想一下,每次请求接口,把这串字符上传,后端解密后,是不是就能得到用户 ID 了呢?这样我们就不用手动上传作者的 id 了。

那么一个重要的问题来了,这个 token 字符串可以被篡改吗?别急,我们后面验证。

js
// /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 保存到本地存储中。

js
// /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,再设置到 headersAuthorization 中,这样每个请求都会自动带上我们的 token 传给后端接口。

js
// /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 为空对象 {},然后对 jwttoken 进行 验证解析

如果验证并解析成功,则把解析的结果赋值给 req.session,否则提示 token 错误或已过期

经过这么一操作,我们就能把前后端传参中的所有 author(作者) 参数全部去掉了,我们直接从 req.session.id 来获取当前用户的 ID。

接口去掉author参数

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

前端去掉author参数

而上图,左侧红色部分,则是前端代码去掉了 author 参数后的效果。

这样,我们就把原本需要手动上传的作者 id,通过 jwt 来自动获取和接收了。

再回到我们之前的问题,要是 token 被篡改了怎么办?

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidXNlcm5hbWUiOiLpmYjpmo_mmJMiLCJpYXQiOjE3MzM4MzQwNDAsImV4cCI6MTczNDQzODg0MH0.ShVROYjqMxK-mY9jbMfke-XkSdG0VFwkXC6qFgZAjzA

这是我们登录后得到的 token。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidXNlcm5hbWUiOiLpmYjpmo_mmJMiLCJpYXQiOjE3MzM4MzQwNDAsImV4cCI6MTczNDQzODg0MH1.ShVROYjqMxK-mY9jbMfke-XkSdG0VFwkXC6qFgZAjzA

很简单,我们把第二个点号 . 前面的数字 0 改成数字 1

0改成1后的返回值

可以看到,我们仅仅只是把其中的数字 0 改成 1,整个 token 就解析失败了。

那么答案就是,通过 jwt 来进行前后端用户数据的传递,是不可篡改的。

这样,我们就能保证,用户张三登录成功后,只能对其自己的文章进行添加、删除、修改,是无法通过修改自己的 token 来操作其他人的文章的。

这样我们就实现了,jwt 的登录和文章管理,也进一步让我们的整个系统更加安全。

何以解忧,唯有代码。不忘初心,方得始终。