前言

本文记录node使用express-jwt校验token以及前端发送token的全过程

什么是Token?什么是JWT?

Token产生原因:常规模式的session存放用户登录态导致服务器压力大,服务器多的时候,需要同步session,于是诞生了token,存到客户端,由服务端被动验证

缺点:

  1. 被动验证,导致收回权限稍微困难.
  2. 每次请求都要携带,增加性能开销

JWT: Json Web Token,一种token的验证方式,本质上是带有前面的json数据.

JWT由三部分组成:

  1. Header:描述JWT的元数据,定义了生成签名的算法以及Token的类型
  2. Payload:用于存放实际需要传递的数据
  3. Signature:签名,服务器通过Payload、Header和一个密钥(secret)使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。

后端职责

使用md5对密码进行加盐加密放入数据库,使用jwt在合适的时机发放token以及token的校验,模块化以及代码复用.

后端项目目录

vscode的project-tree插件生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
footok
├─ app.js
├─ db
│ └─ dbConfig.js
├─ package-lock.json
├─ package.json
├─ routes
│ ├─ index.js //路由初始化+自定义状态异常
│ ├─ tasks.js //任务模块
│ └─ user.js //用户模块
├─ services
│ ├─ taskService.js //业务逻辑层 任务接口
│ └─ userService.js //用户接口
├─ utils
│ ├─ constant.js //自定义常量
│ ├─ index.js //封装连接mysql
│ ├─ md5.js //md5
│ └─ user-jwt.js //jwt验证和解析函数
└─ views

安装依赖

1
2
3
4
5
6
7
npm i --save express
npm i --save body-parser
npm i --save express-validator
npm i --save cors
npm i --save jsonwebtoken
npm i --save express-jwt
npm i --save mysql

db部分(数据库的配置)

1
2
3
4
5
6
7
8
9
10
const mysql = {
host: 'localhost', // 主机名称,一般是本机
port: '3306', // 数据库的端口号,如果不设置,默认是3306
user: 'yourname', // 创建数据库时设置用户名
password: 'yourpassword', // 创建数据库时设置的密码
database: 'yourdatabase', // 创建的数据库
connectTimeout: 5000 // 连接超时
}

module.exports = mysql;

utils部分

该部分负责md5.jwt和mysql的连接配置

constant.js 定义固定参数

1
2
3
4
5
6
7
module.exports = {
CODE_ERROR: -1, // 请求响应失败code码
CODE_SUCCESS: 0, // 请求响应成功code码
CODE_TOKEN_EXPIRED: 401, // 授权失败
PRIVATE_KEY: 'yourKey', // 自定义jwt加密的私钥
JWT_EXPIRED: 60 * 60 * 24, // 过期时间24小时
}

这里是定义参数的地方,还有些成功和失败的响应码可以和前端协商.

user-jwt.js 定义jwt校验规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const jwt = require('jsonwebtoken'); // 引入验证jsonwebtoken模块
const expressJwt = require('express-jwt'); // 引入express-jwt模块
const { PRIVATE_KEY } = require('./constant'); // 引入自定义的jwt密钥

// 验证token是否过期
const jwtAuth = expressJwt({
// 设置密钥
secret: PRIVATE_KEY,
// 设置为true表示校验,false表示不校验
credentialsRequired: true,
// 加入算法
algorithms:['HS256'],
// 自定义获取token的函数
getToken: (req) => {
if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
return req.headers.authorization.split(' ')[1]
} else if (req.query && req.query.token) {
return req.query.token
}
}
// 设置jwt认证白名单,比如/login登录接口不需要拦截
}).unless({
path: [
'/register',
'/login'
]
})

module.exports = {
jwtAuth,
}

这里比较容易踩坑的地方是这个Bearer,这里分两种情况:

  1. 前端请求发送token的时候,自己添加了Bearer,组成Bearer token
  2. 后端响应发送token的时候,自己添加了Bearer,组成Bearer token

不管是上面哪一种签发,验证都需要去掉Bearer,用后面的东西进行校验.

这个unless也比较容易踩坑,一般来说,登陆和注册是不需要权限的,所以默认会有这两个.

md5.js 定义md5

这部分就不用多说了,常规操作

1
2
3
4
5
6
const crypto = require('crypto'); // 引入crypto加密模块

function md5(s) {
return crypto.createHash('md5').update('' + s).digest('hex');
}
module.exports = md5;

index.js 封装数据库的查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const mysql = require('mysql');
const config = require('../db/dbConfig');

//连接mysql
function connect() {
const { host, user, password, database } = config;
return mysql.createConnection({
host,
user,
password,
database
})
}

//新建查询连接
function querySql(sql) {
const conn = connect();
return new Promise((resolve, reject) => {
try {
conn.query(sql, (err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
})
} catch (e) {
reject(e);
} finally {
//释放连接
conn.end();
}
})
}

//查询一条语句
function queryOne(sql) {
return new Promise((resolve, reject) => {
querySql(sql).then(res => {
console.log('res===',res)
if (res && res.length > 0) {
resolve(res[0]);
} else {
resolve(null);
}
}).catch(err => {
reject(err);
})
})
}

module.exports = {
querySql,
queryOne
}

services层

对于很久没有使用node的小伙伴,这里提醒一下,node获取get请求的请求体使用的方法是req.query.xxx,获取post的请求体的方法是req.body.xxx(前提是装了body-parser),以及单条数据查询的时候,最好使用解构的方式,比如说对于下面的sql查询

1
select count(*) as count from foodData where tag = '炒'

那么获取的时候,就应该是

1
let {count} = result[0];

如果下面的代码测试不行,可以将代码写在app.js里面测试,因为很大程度上是你的路径没有写对.

userService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
const {
querySql,
queryOne
} = require('../utils/index');
const md5 = require('../utils/md5');
const jwt = require('jsonwebtoken');
const {
CODE_ERROR,
CODE_SUCCESS,
PRIVATE_KEY,
JWT_EXPIRED
} = require('../utils/constant');


// 登录
function login(req, res) {
let {
username,
password
} = req.body;
// md5加盐加密
password = md5(md5(username + md5(password))) //储存密码
console.log('psw', password);
const query = `select * from userdata where username='${username}' and password='${password}'`;
querySql(query)
.then(user => {
if (!user || user.length === 0) {
res.json({
code: CODE_ERROR,
msg: '用户名或密码错误',
records: null
})
} else {
// 登录成功,签发一个token并返回给前端
const tokenStr = jwt.sign(
// payload:签发的 token 里面要包含的一些数据。
{
username
},
// 私钥
PRIVATE_KEY,
// 设置过期时间
{
expiresIn: JWT_EXPIRED
}
)

let userData = {
id: user[0].id,
username: user[0].username,
nickname: user[0].nickname,
avator: user[0].avator,
sex: user[0].sex,
};

res.json({
code: CODE_SUCCESS,
msg: '登录成功',
records: {
token: 'Bearer ' + tokenStr,
userData
}
})
}
})
}


// 注册
function register(req, res) {
let {
username,
password
} = req.body;
findUser(username)
.then(data => {
if (data) {
res.json({
code: CODE_ERROR,
msg: '用户已存在',
records: null
})
} else {
// md5加盐加密
password = md5(md5(username + md5(password)));
const query = `insert into userdata(username, password) values('${username}', '${password}')`;
querySql(query)
.then(result => {
if (!result || result.length === 0) {
res.json({
code: CODE_ERROR,
msg: '注册失败',
records: null
})
} else {
const queryUser = `select * from userdata where username='${username}' and password='${password}'`;
querySql(queryUser)
.then(user => {
const tokenStr = jwt.sign({
username
},
PRIVATE_KEY, {
expiresIn: JWT_EXPIRED
}
)

let userData = {
id: user[0].id,
username: user[0].username,
nickname: user[0].nickname,
avator: user[0].avator,
sex: user[0].sex,
};

res.json({
code: CODE_SUCCESS,
msg: '注册成功',
records: {
token: 'Bearer ' + tokenStr,
userData
}
})
})
}
})
}
})
}


// 通过用户名查询用户信息
function findUser(username) {
const query = `select id, username from userdata where username='${username}'`;
return queryOne(query);
}

module.exports = {
login,
register,
}

md5的加盐可以自己定义,比如一个secret key或者是什么别的常量,层级也可以相应深一点

这里的token我选择了在后端加上Bearer返回给前端

routes层

users

1
2
3
4
5
6
7
8
9
10
11
const express = require('express');
const router = express.Router();
const service = require('../services/userService');

// 用户登录路由
router.post('/login',service.login);

// 用户注册路由
router.post('/register',service.register);

module.exports = router;

index

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const express = require('express');
const userRouter = require('./user.js'); // 引入user路由模块
const { jwtAuth,decode } = require('../utils/user-jwt'); // 引入jwt认证函数
const router = express.Router(); // 注册路由

router.use(jwtAuth); // 注入认证模块

router.use('/', userRouter); // 注入用户路由模块

// 自定义统一异常处理中间件,需要放在代码最后
router.use((err, req, res, next) => {
// 自定义用户认证失败的错误返回
console.log('err===', err);
if (err && err.name === 'UnauthorizedError') {
const { status = 401, message } = err;
// 抛出401异常
res.status(status).json({
code: status,
msg: 'token失效,请重新登录',
data: null
})
} else {
const { output } = err || {};
// 错误码和错误信息
const errCode = (output && output.statusCode) || 500;
const errMsg = (output && output.payload && output.payload.error) || err.message;
res.status(errCode).json({
code: errCode,
msg: errMsg
})
}
})

module.exports = router;

这里有一点比较重要,如果没有分类的话,那么这里的router.use('/', userRouter);路径直接写斜杠即可

app层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const bodyParser = require('body-parser'); // 引入body-parser模块
const express = require('express'); // 引入express模块
const cors = require('cors'); // 引入cors模块
const routes = require('./routes/index'); //导入自定义路由文件,创建模块化路由
const app = express();

app.use(bodyParser.json()); // 解析json数据格式
app.use(bodyParser.urlencoded({extended: true})); // 解析form表单提交的数据application/x-www-form-urlencoded

app.use(cors()); // 注入cors模块解决跨域

app.use('/', routes);

app.listen(8081, () => { // 监听8081端口
console.log('服务已启动 http://localhost:8081');
})

依然是路径需要注意

至此后端的部分就已经写完了,接下来是前端的部分

前端职责

前端负责登陆和注册的页面编写,请求的编写,axios的封装

前端目录

这里比较多,简单说一下几个模块

1
2
3
4
5
6
7
- components
- userLogin.vue //登陆模块
- userRegister.vue //注册模块
- api
- apiInfo.js //接口信息
- axios.js /封装axios
- vite.config.js //vite配置

vite配置

这一部分主要是解决跨域的问题,然后起别名方便后续的代码路径编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import {
defineConfig
} from 'vite'
import { resolve } from 'path'
import vue from '@vitejs/plugin-vue'
function pathResolve(dir) {
return resolve(__dirname, ".", dir)
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve:{
alias:{
"@":pathResolve("src/"),
"assets":pathResolve("src/assets"),
"common":pathResolve("src/components/common"),
"userLogin":pathResolve("src/components/userLogin"),
"swiper":pathResolve("src/components/swiper"),
"share":pathResolve("src/components/share"),
"category":pathResolve("src/components/category"),
"map":pathResolve("src/components/map"),
"person":pathResolve("src/components/person"),
"store":pathResolve("src/stores"),
"plugins":pathResolve("src/plugins"),
"myApi":pathResolve("src/api"),
"utils":pathResolve("src/utils")
},
extensions:['.js','.json','.ts']
},
base: './',
build: {
target: 'modules',
outDir: 'dist', //指定输出路径
assetsDir: 'assets', // 指定生成静态资源的存放路径
minify: 'terser' // 混淆器,terser构建后文件体积更小
},
server: {
cors: true, // 默认启用并允许任何源
open: true, // 在服务器启动时自动在浏览器中打开应用程序
//反向代理配置,注意rewrite写法,开始没看文档在这里踩了坑
port: 3000,
https: false,
proxy: {
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/ipApi': {
target: 'http://pv.sohu.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ipApi/, '')
}
}
}
})

axios的封装

先讲一下axios的封装,这个对于后续的token发送特别重要,如果你已经会设置拦截器,这一部分可以跳过

目的:通过请求拦截器,对需要权限的接口自动携带token验证.

分为以下几个part:

  1. 基础配置
  2. 取消重复请求
  3. 自动携带token

基础配置

就是配置统一的url和超时配置

1
2
3
4
5
6
7
8
import axios from 'axios';
function myAxios(axiosConfig){
const service = axios.create({
baseURL:'http://localhost:3000',//设置统一url
timeout:10000 //十秒超时
})
}
export default myAxios;

取消重复请求

这个部分不是本文的重点,可以适当跳过

自动携带token

为了照顾ssr,这里要做一次判断然后再获取,发送token我们采取的方案是后端设置Bearer,所以这里前端直接发送即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
service.interceptors.request.use(
config=>{
removePending(config);
// 自动携带token
if(getTokenAuth()&&typeof window !== 'undefined'){
config.headers.Authorization = getTokenAuth();
}
options.repeat_request_cancel&&addPending(config);
console.log('config',config)
return config;
},
error=>{
return Promise.reject(error);
}
)

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import axios from 'axios';
import {getTokenAuth} from 'utils/auth';
// 拦截器
// customOption:boolean 是否开启取消重复请求
function myAxios(axiosConfig,customOption){
const service = axios.create({
baseURL:'http://localhost:3000',//设置统一url
timeout:10000 //十秒超时
})
let options = Object.assign({repeat_request_cancel:false},customOption);
service.interceptors.request.use(
config=>{
removePending(config);
// 自动携带token
if(getTokenAuth()&&typeof window !== 'undefined'){
config.headers.Authorization = getTokenAuth();
}
options.repeat_request_cancel&&addPending(config);
console.log('config',config)
return config;
},
error=>{
return Promise.reject(error);
}
)

service.interceptors.response.use(
response=>{
removePending(response);
return response
},
error=>{
error.config&&removePending(error.config);
return Promise.reject(error);
}
)
return service(axiosConfig);//返回的是一个promsie对象
}
// 取消重复请求
// 判断重复请求并存入队列
const pendingMap = new Map();

// 生成每个请求唯一的键
function getPendingKey(config){
let {url,method,params,data} = config;
if(typeof data === 'string')data=JSON.parse(data)//响应回来的config data是一个字符串
return [url,method,JSON.stringify(params),JSON.stringify(data)].join('&');
}

// 存储每个请求的值,也就是cancel方法,用于取消请求
function addPending(config){
const pendingKey = getPendingKey(config);
config.cancelToken = config.cancelToken || new axios.CancelToken((cancel)=>{
if(!pendingMap.has(pendingKey)){
pendingMap.set(pendingKey,cancel);
}
})
}

// 删除重复的请求
function removePending(config){
const pendingKey = getPendingKey(config);
if(pendingMap.has(pendingKey)){
const cancelToken = pendingMap.get(pendingKey);
cancelToken(pendingKey);
pendingMap.delete(pendingKey);
}
}


export default myAxios;

接口信息

这一部分是为了方便后期接口的管理而写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import myAxios from './axios';
// 用户登陆
export function userLoginAPI(data) {
return myAxios({
url: '/api/login',
method: 'post',
data
})
}
// 用户注册
export function userRegisterAPI(data){
return myAxios({
url:'/api/register',
method:'post',
data
})
}

登陆模块逻辑

页面的代码就不放了,大致的请求逻辑如下
将数据发送到后端,得到成功的响应后将token放置在前端的localStorage中,或者cookie中.这里选择了localStorage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import {userLoginAPI} from "myApi/apiInfo";
const rawData = toRaw(userData);
userLoginAPI(rawData)
.then(
({
data: {
code,
msg,
records: { token, userData },
},
}) => {
if (code === 0) {
localStorage.setItem("token",token);//将token存放到
router.push({ name: "index" });
} else {
loginTips.passwordTips = "";//清空密码提示
loginTips.userNameTips = msg;//回显错误内容
}
}
)
.catch((err) => {
console.log(err);
loginTips.passwordTips = "";
loginTips.userNameTips = "该用户不存在";
});

注册模块逻辑

页面的代码就不放了,注册的逻辑大概如下

  1. 做密码的校验,比如限制长度,大小写等.
  2. 发送用户信息,成功后设置token到本地,跳转到主页
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import {userRegisterAPI} from 'myApi/apiInfo';
const userRegister = (e) => {
if (
isSuccess.userNameSuccess &&
isSuccess.passWordSuccess &&
isSuccess.commitpswSuccess
) {
const rawData = toRaw(userData);
userRegisterAPI(rawData)
.then(({ data: { code, msg,records } }) => {
if (code === 0) {
let {token,userData} = records;
localStorage.setItem('token',token);
router.push({ name: "index" });

} else {
resgisterTips.userNameTips = "用户名已存在";
isSuccess.userNameSuccess = false;
}
})
.catch((err) => {
resgisterTips.userNameTips = "用户名已存在";
isSuccess.userNameSuccess = false;
console.log(err);
});
} else {
e.preventDefault();
if (!userData.username) {
resgisterTips.userNameTips = "用户名不能为空";
}
if (!userData.password) {
resgisterTips.passwordTips = "密码不能为空";
}
}
};

获取token

1
2
3
4
5
const TOKEN_KEY = 'token';

export function getTokenAuth(){
return localStorage.getItem(TOKEN_KEY);
}

总结

本次对jwt和md5加密的登陆注册进行了一次编写,还有一些没有了解到的,比如以下问题:

  1. 吊销token(了解到是express-jwt里面的isRevoked)
  2. 是否需要每次访问有权限的接口都携带token(需要,因为只有这样才能判断)
  3. token的续签

关联知识点:
web网络安全XSS,CSRF
token,cookie,session