什么是JWT?
JWT的本质就是一个字符串
,它是将用户信息保存到一个Json字符串中
,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。
JSON Web Token
是一个开放标准(RFC 7519)- 定义了一种
紧凑且独立
的方式,可以将各方之间的信息作为JSON对象
进行安全传输 - 该信息可以
验证
和信任
,因为是经过数字签名
的
JWT的构成
- 头部(Header)
- 有效载荷(Payload)
- 签名(Signature)
JWT分成了三个部分,每个部分都有黑点隔开
更多精彩内容,请微信搜索“前端爱好者
“, 戳我 查看 。
Header本质是个JSON,这个JSON里面有2个字段
- typ:token的类型,这里固定有JWT
- alg:使用的hash算法,例如:HMAC SHA256或者RSA
Header编码前后
- {“alg”:“HS256”,"typ":"JWT"}
- 编码后就是一段Base64字符串
Payload
- 存储需要传递的信息,如用户ID、用户名等
- 还包含元数据,如过期时间、发布人等
- 与Header不同,Playload可以加密
Playload编码前后
- {“user_id”:"xiaofengche"}
- 编码后就是一段Base64字符串
Signature
- 对Header和Payload部分进行签名
- 保证Token在传输的过程中没有被篡改或者损坏
Signature算法
Signature = HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)
生成完签名之后依然需要进行Base64编码
其优点如下:
- 支持跨域访问:cookie是无法跨域的,而token由于没有用到cookie(前提是将token放到请求头中),所以跨域后不会存在信息丢失问题
- 无状态:token机制在服务端不需要存储session信息,因为token自身包含了所有登录用户的信息,所以可以减轻服务端压力
- 更适用CDN:可以通过内容分发网络请求服务端的所有资料
- 更适用于移动端:当客户端是非浏览器平台时,cookie是不被支持的,此时采用token认证方式会简单很多
- 无需考虑CSRF:由于不再依赖cookie,所以采用token认证方式不会发生CSRF,所以也就无需考虑CSRF的防御
JWT工作原理
客户端(浏览器)通过POST
请求将用户名和密码传给服务器,服务端对用户名和密码进行核对,核对成功后将用户ID等其他信息作为JWT的有效载荷,将其与头部进行base64编码后形成一个JWT,然后后端将那一段字符串作为登录成功这个请求的返回结果返回给前端,然后前端将其保存在localStorage
或者sessionStorage
中。
之后前端每次请求都会把JWT字符串作为Http头里面的Authorization(鉴权),然后发送给后端,后端检查其是否存在,如果存在则验证JWT字符串的有效性(例如签名是否正确,令牌是否过期等)。
验证通过后,后端则使用JWT中包含的用户信息进行其他业务逻辑并返回相应的结果。
Session简介
Session是一种非常重要非常流行的用户认证
与授权
的方式。
认证
:让服务器知道你是是谁 授权
:让服务器知道你什么能干什么不能干
Session的优势
- 相比
JWT
,最大的优势就在于可以主动清除session
了(因为session是保存在服务端的,服务端可以主动清除;JWT是以Token形式保存在客户端,只要没过期,客户端就可以一直拿着Token来进行用户认证与授权) session
保存在服务器端,相对较为安全- 结合
cookie
使用,较为灵活,兼容性较好
session的劣势
cookie+session
在跨域场景表现并不好(cookie具有不可跨域性
)- 如果是
分布式
部署,需要做多机共享session
机制 - 基于cookie的机制很容易被CSRF(CSRF是
跨站请求伪造
,一种攻击,它可以用你的cookie进行攻击) 查询session
信息可能会有数据库查询操作(想要拿到完整的session信息还需要拿session_id去查询数据库,查询就需要时间和计算能力,这就会带来一定的性能问题。)
Session相关的概念介绍
- session:主要存放在
服务器端
,相对安全 - cookie:主要存放在
客户端
,并且不是很安全 - sessionStorage:仅在
当前会话
下有效,关闭页面或浏览器后被清除 - localstorage:除非被清除,否则永久保存
JWT vs. Session
可扩展性
JWT可以无缝接入水平拓展
,因为基于Token(令牌)的身份验证是无状态的,所以不需要在session中存储用户信息,应用程序可以轻松拓展,可以使用Token从不同的服务器中访问资源,而不用担心用户是否真的登录在某台服务器上。
安全性
这两种都是会受到攻击
的。
RESTful API
RESTful要求程序是无状态的,像session这种是有状态的认证方式,显然是不能做RESTful API的。
性能
客户端向服务端发出请求的时候,可能会有大量的用户信息在JWT中,每个Http请求都会产生大量的开销;如果用session的话只要少量的开销就可以了, 因为session_id非常小,JWT的大小可能是它的好几倍。
但是session_id也有缺点,查询完整信息需要session_id,这也是要消耗性能的;JWT字符串包含了完整信息,JWT就不需要数据库查询,性能消耗就少一点,JWT相当于用空间交换时间
时效性
JWT的时效性要比session差一点。因为JWT只有等到过期时间才可以销毁,无法实时更新,session可以在服务端主动手动销毁。
在Node.js中使用JWT
安装jsonwebtoken
npm i jsonwebtoken
签名
使用sign方法进行签名,它的第一个参数是JSON对象,第二个参数可以写密钥
> token = jwt.sign({name:"xiaofengche"},'secret');
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoieGlhb2ZlbmdjaGUiLCJpYXQiOjE2Mjg0MDYzMzF9.zOCf0dzBRvuBjOCcZZ5nuLbUGd4q05SQuFod48ScML4'
拿到token之后就可以传给客户端,客户端每次请求都可以拿着这个token放在头部传回给服务端,服务端拿到token之后就可以判断当前用户是谁了,有什么权限。
验证
使用解码 decode
就可以判断用户是谁
> jwt.decode(token);
{ name: 'xiaofengche', iat: 1628406331 }
这里的iat是指签名时的时间,单位是秒
需要验证用户信息是否被篡改,verify第二个参数是要加密钥的
> jwt.verify(token,'secret');
{ name: 'xiaofengche', iat: 1628406331 }
证明token是合法的,签名也是合法的
应用场景实现用户注册
// /routes/users.js
// 用户登录
router.post('/login', UsersControllers.loginUsers)
// controllers/users.js
//用户登录
async loginUsers(ctx) {
let user = {
username: 'admin',
pwd: 'admin'
}
let token = jwt.sign({
username: user.username
},'jianshu-server-jwt',{
expiresIn: 3600 * 24 * 7
})
// sign 第一个参数是JSON对象,第二个参数可以写密钥,第三个参数是token保存时长
ctx.body = {
token: token
}
}
设计用户Schema
需要重新设计Schema,添加密码这个字段。
// //models/users.js
const mongoose = require('mongoose');
//mongoose提供的Schema类生成文档Schema
const { Schema,model } = mongoose
const userSchema = new Schema({
//将没用的信息隐藏起来
__v:{type:Number,select:false},
//required表示这个属性是必选的
//default可以设置默认值
name:{type:String,required:true},
//像密码这种敏感信息不应该随便暴露,需要将其隐藏起来——select:false
password:{type:String,required:true,select:false},
});
//建立模型
//User:为文档集合名称
module.exports = model('User',userSchema);
在相关操作添加新字段的定义,更新models
//创建用户
async create(ctx){
//校验请求体的name位字符串类型并且是必选的
ctx.verifyParams({
//必选:required 删掉也是默认为true
name:{ type:'string',required:true },
password:{type:'string',required:true},
});
const user = await new User(ctx.request.body).save();
ctx.body = user;
}
//更新用户
async update(ctx){
// 校验参数是否为空
ctx.verifyParams({
//必选:required 删掉也是默认为true
name:{ type:'string',required:false },
password:{type:'string',required:false},
});
const user = await User.findByIdAndUpdate(ctx.params.id,ctx.request.body);
if(!user){ctx.throw(404,'用户不存在');}
ctx.body = user;
}
由于修改用户属性可以部分修改,所以需要修改更改路由的请求方法
//put是整体替换,现在的用户可以更新一部分属性
router.patch('/:id',update);
编写保证唯一性的逻辑(用户的唯一性)
在创建用户编写保证唯一性的逻辑,保证创建时用户名不重复
//更新用户
async update(ctx){
ctx.verifyParams({
//必选:required 删掉也是默认为true
name:{ type:'string',required:false },
password:{type:'string',required:false},
});
//获取请求体中的用户名
const {name} = ctx.request.body
// findOne返回符合条件的第一个用户
const repreatedUser = await User.findOne({name});
//如果有重复用户返回409状态码代表冲突
if(repreatedUser){
ctx.throw(409,"用户名已占用");
}
const user = await User.findByIdAndUpdate(ctx.params.id,ctx.request.body);
if(!user){ctx.throw(404,'用户不存在');}
ctx.body = user;
}
效果如下:
实现登录并获取token
登录接口设计
登录这个动作不属于用户增删改查的任何一种,可以模仿github采用POST+动词形式
用jsonwebtoken
生成token
首先在config.js配置密钥
secret:'jwt-secret',
在users.js引入jsonwebtoken和密钥,接着实现登录接口
const jsonwebtoken = require('jsonwebtoken');
const {secret} = require('../config');
//登录
async login(ctx){
ctx.verifyParams({
name:{type:'string',required:true},
password:{type:'string',required:true},
});
//登录有两种情况:
// 1. 用户名不存在或密码错误,登录失败;
// 2. 登录成功
//查找符合条件的第一个用户
const user = await User.findOne(ctx.request.body);
if(!user){ctx.throw(401,'用户名或密码不正确');}
//获取id和name
const {_id,name} = user;
//登录成功生成token,参数分别为用户不敏感的信息,签名密钥,过期时间
//1d:一天
const token = jsonwebtoken.sign({_id,name},secret,{expiresIn:'1d'}); // 重要
ctx.body = {token};
}
最后别忘了在routes->users.js注册接口
//delete是关键字,取别名
const {find,findById,create,update,delete:del,login} = require('../controllers/users');
router.post('/login',login)
效果演示:
Koa中间件实现用户认证与授权
总体思路
登录签发token
:在前端登录时先验证传递来的账户信息,如比对成功,就生成 token 令牌,返回给前端。(也可以像 session 那样直接ctx.cookies.set(key, value, [options])写入 cookie,比如 koa-session``)- 前端拿到
token 并进行保存
(通常使用localStorage
, 也可以是 cookie),在之后每次请求时由请求头携带(一般是Authorization字段)
发送给服务端。 - 访问验证token:对于需要登录权限才能访问的接口,先进行token认证(可以单独写一个验证中间件做一层拦截),确认token正确并还在有效期内,才能进行后续处理。
客户端拿到错误知道需要(重新)登录
。 - 用户退出登录时,
清理存在客户端的token
。
自己编写Koa中间件实现用户认证与授权
- 认证:验证token,并获取用户信息
在routes->users.js
编写认证中间件。
假设客户端是通过Authorization字段 加上Bearer 空格+token
这种形式把token传进来的,我们就知道怎么获取token了
// `routes->users.js`
const jsonwebtoken = require('jsonwebtoken');
const {secret} = require('../config');
const auth = async(ctx,next) => {
//当不设置authorization的时候把它设置为空字符串
const {authorization = ''} = ctx.request.header;
//去掉'Bearer '才是我们真正想要的token
const token = authorization.replace('Bearer ','');
//验证用户信息
try{
const user = jsonwebtoken.verify(token,secret); // 验证token
ctx.state.user = user;
} catch(err) {
//所有的验证失败手动抛成401错误,也就是未认证
ctx.throw(401,err.message);
}
await next();
}
最后把中间件放在需要认证的接口上
router.patch('/:id', auth,update);
router.delete('/:id',auth,del);
- 授权:使用中间件保护接口
在users.js控制器
中编写鉴权中间件(也可以像上面一样在routes->users.js里面)
async checkOwner(ctx,next){
//判断当前修改或删除的用户id是不是当前登录用户的id
if(ctx.params.id !== ctx.state._id){
//操作的对象不是自己就抛出错误
ctx.throw(403,'没有权限')
}
await next();
}
最后把中间件添加到需要鉴权的接口上
const {find,findById,create,update,delete:del,login,checkOwner} = require('../controllers/users');
router.patch('/:id', auth,checkOwner,update);
router.delete('/:id',auth,checkOwner,del);
用koa-jwt中间件实现用户认证与授权
- 安装koa-jwt:
npm i koa-jwt --save
这是一个第三方中间件,功能强大。有了这个中间件,我们就不需要自己编写中间件了。
- 使用中间件保护接口
引入中间件,只需一行代码就可以替换掉自己编写的认证中间件。
// app.js
// 引入jwt
const koajwt = require('koa-jwt');
const { secret } = require('./config/secret')
// 使用jwt
app.use(koajwt({
secret: secret
}).unless({ // 配置哪些接口不需要jwt认证
path: [/^\/users\/login/,/^\/users\/register/]
}))
-- 使用中间件获取用户信息
koa-jwt同样将用户信息存放在ctx.state.user
上,自定义授权中间件依然能正常使用。
const jwt = require('jsonwebtoken');
const { secret } = require('../config/secret') /引入加密字符串
// 验证用户登录
async verify(ctx) {
let token = ctx.header.authorization.replace('Bearer ', '')
try {
// 校验 token ,为密钥信息
let result = jwt.verify(token, secret)
await User.findOne({ _id: result._id }).then(res => {
if (res) {
// 给客户端返回token
ctx.body = {
code: 200,
msg: '认证成功',
user: res
}
} else {
ctx.body = {
code: 500,
msg: '认证失败'
}
}
}).catch(err => {
ctx.body = {
code: 500,
msg: err
}
})
} catch (err) {
ctx.body = {
code: 500,
msg: error
}
}
}