HTTP 是一个无状态的协议,一次请求结束后,再次给服务器发送请求,服务器就不知道这个请求是谁发来的了(同一个 IP 不代表同一个用户),在 Web 应用中,用户的认证和鉴权是非常重要的一环,在实践中有多种解决方案,这里,我们使用 JWT(JSON Web Token) 来实现用户的认证。
JWT是什么
JWT 是跨域认证的一种解决方案,JWT由三部分组成:Header(头部)、Payload(负载)、Signature(签名)
一个JWT的结构通常如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJuYW1lIjoiYWJjIiwicGFzc3dvcmQiOiIxMTExMTEifSwiZXhwIjoxNTkxOTMzODcyLCJpYXQiOjE1OTE5MzAyNzJ9.oKAj1dYjiHaNmKB4l5hUU84yycwZMIMLg47Rt5QxKFQ
它通过 点(.) 分隔成三个部分,第一份部分是 Header(头部),第二部分是 Payload(负载),第三部分是Signature(签名)
Header(头部)
Header 是一个JSON 对象,描述JWT的元数据,数据结构通常如下:
{
"alg": 'HS256',
"tpy": "JWT"
}
alg 属性表示签名的算法,默认是 HS256;
typ 属性表示这个令牌(token)的类型,JWT 令牌统一写为:JWT;
需要将这个JSON 对象转换成 base64;
Payload(负载)
Payload 也是一个JSON 对象,用来存放实际需要传递的数据。JWT 规定了 7 个官方字段供选用:
-
iss (issuer):签发人
-
exp (expiration time):过期时间
-
sub (subject):主题
-
aud (audience):受众
-
nbf (Not Before):生效时间
-
iat (Issued At):签发时间
-
jti (JWT ID):编号
在 Payload 里,除了官方字段,还可以添加自定义字段,也就是我们要传递的实际数据,如:
{
username: 'Tom', // 用户名
exp: Math.floor(Date.now() / 1000) + 60 * 60 // 过期时间
}
同样需要将 Payload 转换成 base64
Signature(签名)
Signature 是对 Header 和 Payload 两部分的签名,其目的是为了防止篡改数据。
在生成签名之前,需要先指定一个秘钥,该秘钥只有服务器知道,然后使用 Header 里面指定的签名算(默认是 HMAC SHA256)法生成签名。
生成签名后,把 Header、Payload、Signature 三个部分通过 点(.) 分隔拼成一个字符串,返回给客户端。
基于Token的身份验证
原理
使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录。其原理如下:
-
客户端使用用户名跟密码请求登录;
-
服务端收到请求,去验证用户名与密码;
-
验证成功后,服务端会签发一个 Token,再把这个 Token发送给客户端;
-
客户端收到 Token 以后把它存储起来,比如放在Cookie 里或者 Local Storage 里;
-
客户端每次向服务端请求资源的时候需要带着服务端签发的 Token;
-
服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据;
基于JWT的实践
下面,我们使用 Nodejs 搭建一个简单的 Token 验证。
token 生成
使用 jsonwebtoken 依赖包生成 token
const jwt = require("jsonwebtoken");
const token = jwt.sign(
{
data: username,
// 设置 token 过期时间,一小时后,秒为单位
exp: Math.floor(Date.now() / 1000) + 60 * 60
},
secret
)
token 存储
客户端收到服务端发送的 token 后,将其存储到 Local Storage 里
async login() {
// 登录请求
const res = await axios.post("/login-token", {
username: this.username,
password: this.password
});
// 将 token 保存到 localStorage
localStorage.setItem("token", res.data.token);
}
发送请求时携带 token
客户端每次向服务端请求资源时,带上服务端签发的 Token。在请求拦截里,给请求头设置 Authorization 来携带 token
// 请求拦截,给请求头信息设置 Authorization
axios.interceptors.request.use(
config => {
const token = window.localStorage.getItem("token");
if (token) {
// 判断是否存在token,如果存在的话,则每个http header都加上token
// Bearer是JWT的认证头部信息
config.headers.common["Authorization"] = "Bearer " + token;
}
return config;
},
err => {
return Promise.reject(err);
}
);
登录接口
router.post("/login-token", async ctx => {
const { body } = ctx.request;
const { username, password } = body
// password 应根据 username 从数据库中获取
if (username === 'test' && password === 'test') {
ctx.body = {
message: "登录成功",
user: username,
// 使用 jsonwebtoken 依赖包 生成 token 返回给客户端
token: jwt.sign(
{
data: username,
// 设置 token 过期时间,一小时后,秒为单位
exp: Math.floor(Date.now() / 1000) + 60 * 60
},
secret
)
};
} else {
ctx.status = 401;
ctx.body = { code: 0, message: '用户名或密码错误' }
}
});
退出登录
客户端退出登录时只需从 localStorage 中清除 token 即可,服务端不需要任何操作
async logout() {
// 退出登录时将 token 从 localStorage 中删除
localStorage.removeItem("token");
}
完整代码
前端登录页面
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<!-- 引入样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- 引入组件库 -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
</head>
<body>
<div id="app">
<el-row style="margin-top: 15px ;">
<el-input v-model="username" placeholder="请输入用户名"></el-input>
</el-row>
<el-row style="margin-top: 15px ;">
<el-input v-model="password" show-password placeholder="请输入密码"></el-input>
</el-row>
<el-row style="margin-top: 15px ;">
<el-button type="primary" plain v-on:click="login" size="small">Login</el-button>
<el-button type="primary" plain v-on:click="logout" size="small">Logout</el-button>
<el-button type="primary" plain v-on:click="getUser" size="small">GetUser</el-button>
<el-button plain @click="logs=[]" size="small">Clear Log</el-button>
</el-row>
<!-- 日志 -->
<ul>
<li v-for="(log,idx) in logs" :key="idx">
{{ log }}
</li>
</ul>
</div>
<script>
// 请求拦截,给请求头信息设置 Authorization
axios.interceptors.request.use(
config => {
const token = window.localStorage.getItem("token");
if (token) {
// 判断是否存在token,如果存在的话,则每个http header都加上token
// Bearer 是JWT的认证头部信息
config.headers.common["Authorization"] = "Bearer " + token;
}
return config;
},
err => {
return Promise.reject(err);
}
);
// 响应拦截
axios.interceptors.response.use(
response => {
app.logs.push(JSON.stringify(response.data));
return response;
},
err => {
app.logs.push(err.message);
return Promise.reject(err);
}
);
var app = new Vue({
el: "#app",
data: {
username: "test",
password: "test",
logs: []
},
methods: {
async login() {
const res = await axios.post("/login-token", {
username: this.username,
password: this.password
});
// 将 token 保存到 localStorage
localStorage.setItem("token", res.data.token);
},
async logout() {
// 退出登录时将 token 从 localStorage 中删除
localStorage.removeItem("token");
},
async getUser() {
await axios.get("/getUser-token");
}
}
});
</script>
</body>
</html>
后端
const Koa = require('koa')
const router = require('koa-router')()
const static = require('koa-static')
const bodyParser = require('koa-bodyparser')
const app = new Koa();
const jwt = require("jsonwebtoken");
const jwtAuth = require("koa-jwt");
const secret = "it's a secret";
app.use(bodyParser())
app.use(static(__dirname + '/'));
router.post("/login-token", async ctx => {
const { body } = ctx.request;
const { username, password } = body
// password 应根据 username 从数据库中获取
if (username === 'test' && password === 'test') {
ctx.body = {
message: "登录成功",
user: username,
// 使用 jsonwebtoken 依赖包 生成 token 返回给客户端
token: jwt.sign(
{
data: username,
// 设置 token 过期时间,一小时后,秒为单位
exp: Math.floor(Date.now() / 1000) + 60 * 60
},
secret
)
};
} else {
ctx.status = 401;
ctx.body = { code: 0, message: '用户名或密码错误' }
}
});
router.get(
"/getUser-token",
jwtAuth({
secret
}),
async ctx => {
// 验证通过,state.user
console.log(ctx.state.user);
//获取session
ctx.body = {
message: "获取数据成功",
userinfo: ctx.state.user.data
};
}
)
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000)