Section-7 JWT 在 Koa 框架中实现用户的认证与授权

Lesson-1 Session

Session是什么

其实就是用户的认证与授权。认证与授权又是什么?认证,就是让服务器知道你是谁,授信,就是让服务器知道你的权限是什么,什么能干,什么不能干

工作原理

以登陆为例,当客户端通过用户名与密码请求服务端,服务端就会生成身份认证相关的Seccion数据。生成Session数据之后,可能保存在内存里,也可能保存在内存数据库里(比如redis),并将Session ID返回给客户端(比如请求头里添加Set-Cookie:session=***)。客户端将Session ID存放到cookie里。接下来客户端的所有请求,都将附带该Session ID,服务端通过该Session ID来查找该用户相关的数据

Session 的优势

  • 相比 JWT,最大的优势就在于可以主动清楚session了
  • session 保存在服务器端,相对较为安全
  • 结合 cookie 使用,较为灵活,兼容性较好(客户端服务端都可以清除,也可以加密)

Session 的劣势

  • cookie+session 在跨域场景表现并不好(不可跨域,domain变量,需要复杂处理跨域)
  • 如果是分布式部署,需要做多机共享 Session 机制(成本增加)
  • 基于 cookie 的机制很容易被 CSRF
  • 查询 Session 信息可能会有数据库查询操作

Session 相关的概念介绍

  • session::主要存放在服务器,相对安全
  • cookie:主要存放在客户端,并且不是很安全
  • sessionStorage:仅在当前会话下有效,关闭页面或浏览器后被清除
  • localstorage:除非被清除,否则永久保存

Lesson-2 JWT 简介

什么是 JWT?

  • JSON Web Token 是一个开放标准(RFC[请求意见稿] 7519)
  • 定义了一种紧凑且独立的方式,可以将各方面之间的信息作为 JSON 对象进行安全传输
  • 该信息可以验证和信任,因为是经过数字签名的

JWT 的构成

  • 头部(Header)
  • 有效载荷(Payload)
  • 签名(Signature)

JWT 的例子

不同颜色,. 号结束,红色代表Header,紫色代表Payload,蓝色代表Signature


JWT 例子

Header

Header,本质是JSON,使用 Base64 编码,因此更加紧凑。Header 包含下面两个字段:

  • typ:token的类型,这里固定为 JWT
  • alg:使用 hash 算法,例如 HMAC SHA256 或者 RSA

Header 编码前后

编码前:{"alg": "HS256", "typ": "JWT"}
编码后:'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9'

Payload

  • 存储需要传递的信息,如用户ID、用户名等
  • 还包含元数据,如过期时间、发布人等
  • 与 Header 不同,Payload 可以加密

Payload 编码前后

编码前:{"user_id": "zhangsan"}
编码后: 'eyJ12VylkIjoiemhhbmdzYW4ifQ=='
由于base64会忽略最后的等号,所以结果为: 'eyJ12VylkIjoiemhhbmdzYW4ifQ'

Signature

  • 对 Header 和 Payload 部分进行签名
  • 保证 Token 在传输的过程中没有被篡改或者损坏

Signature 算法

Signature = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret),生成完之后依然需要进行一次 base64 编码

JWT 原理

以登录为例,浏览器端通过 post 请求将用户名和密码发送给服务端,服务端接受完进行核对,核对成功后将用户 ID 和其他信息作为有效载荷(Payload),将其与头部进行 base64 编码之后,形成一个 JWT。服务端将该 JWT 作为登录成功的返回结果,返回给浏览器端,浏览器端将其保存在 localstorage 或 sessionStorage 中。接下来的每次请求,都将带上该 JWT (请求头中,Authorization: Bearer*** JWT ***),服务端接收后都将核对(身份,令牌是否过期等),并返回相关的用户信息


Lesson-3 JWT vs Session

  • 可拓展性(水平拓展:加服务器,垂直拓展:增加硬盘容量,内存等,JWT优胜)
  • 安全性(两者均有缺陷)
    • XSS攻击(跨站脚本攻击):JS均能篡改;防范:签名/加密,敏感信息不要放其中;
    • CSRF(跨站请求伪造):两者都能被篡改
    • 重犯攻击:过期时间尽量短
    • 中间人攻击:HTTPS来解决
  • RESTful API,JWT优胜,因为RESTful API提倡无状态,JWT符合要求
  • 性能(各有利弊,因为JWT信息较强,所以体积也较大。不过Session每次都需要服务器查找,JWT信息都保存好了,不需要再去查询数据库)
  • 时效性,Session能直接从服务端销毁,JWT只能等到时效性到了才会销毁(修改密码也无法阻止篡夺者的使用)

Lesson-4 在 Nodejs 中使用 JWT

操作步骤

  • 安装 jsonwebtoken
  • 签名
  • 验证
    这一节是直接在 git 中使用命令行来操作代码片段的

安装 jsonwebtoken

执行 npm i jsonwebtoken 进行安装插件

签名

执行 node,进入 node 环境
执行 jwt = require('jsonwebtoken'); 引入jwt
执行 token = jwt.sign({name: 'yose'}, 'secret'); 生成token,secret 则代表密钥,后面用于验证使用的
执行 jwt.decode(token); 直接解码,但是并不会验证,所以并不会用
执行 jwt.verify(token, 'secret'); 验证密钥并解码,可以看到返回 { name: 'yose', iat: 1565110602 } iat代表的是签名时的时间,单位毫秒

验证

执行 jwt.verify(token, 'secret1'); 密钥被篡改,返回 JsonWebTokenError: invalid signature,密钥校验失败
执行 jwt.verify(token.replace('e', 'a'), 'secret'); 签名篡改,返回 JsonWebTokenError: invalid token,令牌校验失败


Lesson-5 实现用户注册

操作步骤

  • 设计用户 Schema
  • 编写保证唯一性的逻辑

设计用户 Schema

userSchema 新增 password 字段,来表示用户注册的密码

// model/users.js
const userSchema = new Schema({
    name: { type: String, required: true },
    password: { type: String, required: true }, 
});

调用postman,但是这个时候会把密码也给返回出来,这是不合理的,所以我们需要把密码设置为不返回

密码被一并返回

这里有两种做法
一种是通过 mongoose 中 Query.prototype.select() 方法,由于是 Query对象,所以不是所有方法都能直接链式调用该方法来屏蔽关键字段,也就是说前面类似 create 中 save() 方法是不能用的。

Query对象文档说明

这里简化学习直接用find()方法演示

// controllers/users.js
async find (ctx) {
    ctx.body = await User.find().select("-password");
}
password被屏蔽

第二种方法,通过 Schma 属性 属性来过滤(提倡,因为你不用去关注所使用的方法返回的究竟是不是 Query对象)

image.png
// models/users.js
const userSchema = new Schema({
    __v: { type: Number, select: false },
    name: { type: String, required: true },
    password: { type: String, required: true, select: false },
});

结果依然如上面 postman 截图所示,拿到的用户列表并不会返回 password 字段

编写保证唯一性的逻辑

真实场景中,我们经常会先对用户名、手机、邮箱等等进行唯一性的验证,如果已经被人注册过了,那么就不能继续使用这些数据进行注册,否则用户可以正常注册
对到修改用户,因为真实场景中用户可能只是更改用户名,但其他信息不更换,那么根据 RESTful API 规范,我们需要将 put(全部更改) 方法改成 patch(局部更改)

// routes/users.js
// 修改特定用户
router.patch('/:id', update); // 将put 改为 patch

create方法新增去重,并对密码进行校验(实际是为了报错信息友好),而更新则需要将校验全部改为非必传(因为可以进行局部更改)

async create (ctx) {
    ctx.verifyParams({
        name: { type: 'string', required: true },
        password: { type: 'string', required: true }
    });

    // 查重
    const { name } = ctx.request.body;
    const requesteUser = await user.findOne({ name });

    if(requesteUser) ctx.throw(409, '用户已经存在');

    // save方法,保存到数据库。并根据 RESTful API最佳实践,返回增加的内容
    const user = await new User(ctx.request.body).save();
    ctx.body = user;
}

async update (ctx) {
    ctx.verifyParams({
        name: { type: 'string', required: false },
        password: { type: 'string', required: false }
    });

    // findByIdAndUpdate,第一个参数为要修改的数据id,第二个参数为修改的内容
    const user = await User.findByIdAndUpdate(ctx.params.id, ctx.request.body);
    if(!user) ctx.throw(404, '用户不存在');
    ctx.body = user;
}

Lesson-6 实现登陆并获取Token

操作步骤

  • 登陆接口设计
  • 用 jsonwebtoken 生成 token

登陆接口设计

根据 github 接口设计,由于登陆并不属于增删改查,所以我们按照github上这种教科书级别的接口设计规范来设计,采用 post+动词形式来定义,路由新增login,控制器里添加login方法

// router/users.js
const { find, findById, create, update, delete: del, login } = require('../controllers/users');
// 登陆
router.post('/login', login);

用 jsonwebtoken 生成 token

设计思路:login 方法先对数据进行参数校验,如果必传字段都存在,再去数据库中查询是否有符合请求体中的用户名及密码。全部验证通过了,再生成token。这里的密钥是直接写在 config.js 中的,但正常情况下其实密钥是必须通过环境变量来获取的,否则这个密钥被人家一扒就拿到了

// config.js
module.exports = {
    secret: 'zhihu-jwt-secret', // 正常需要通过环境变量获取
}
// controllers/users.js
const jsonwebtoken = require('jsonwebtoken');
const { secret } = require('../config');

async login (ctx) {
    ctx.verifyParams({
        name: { type: 'string', required: true },
        password: { type: 'string', required: true }
    });

    const user = await User.findOne(ctx.request.body);
    if(!user) ctx.throw(401, '用户名或密码不正确');

    const { _id, name } = user;
    const token = jsonwebtoken.sign({ _id, name }, secret, { expiresIn: '1d' });
    ctx.body = { token };
}

加个小插曲,新建请求,全都编写好后,发现一直不通过,总提示两个字段都为空,检查后才发现这里默认新生成的是text类型,注意改回JSON格式,由于习惯复制粘贴,这里一直没设置,容易忘记设置这个地方

image.png

Lesson-7 自己编写 Koa 中间件实现用户认证与授权

操作步骤

  • 认证:验证 token,并获取用户信息
  • 授权:使用中间件保护接口

认证:验证 token,并获取用户信息

前面已经实现了用户登录并返回token,接下来便是在 postman 中使用自动化脚本来获取token,否则每次都去填,这是不可行的
postman中是可以在请求头中自动加入验证信息的,这里我们用的是Bearer Token,将登陆后的 token 填入即可。但也如上面所说,每次都去填是不可行的(更换密钥/过期/用户更改登陆信息等)。截图中写了 {{token}},其实代表的就是自动化脚本中的全局变量

请求头附带token
自动化脚本
var jsonData = pm.response.json();
pm.globals.set("token", jsonData.token);

以上便完成了自动化脚本获取 token 的操作,现在我们不用再去复制 token 来手动加到请求头里了。

授权:使用中间件保护接口

根据一开始的 洋葱模型,其实我们所要做的验证token,实际就是编写一个中间件,加进需要验证的控制器中

postman中自动加入的请求头token
// router/users.js
const jsonwebtoken = require('jsonwebtoken');
const { secret} = require('../config');

// 认证中间件
const auth = async (ctx, next) => {
    const { authorization = '' } = ctx.request.header; // 容错,没token得用户默认为空字符串,否则报语法错误
    const token = authorization.replace('Bearer ', ''); // 根据上面截图,可以看到需要对value进行处理

    try {
        const user = jsonwebtoken.verify(token, secret); // 不记得的往前看第四节
        ctx.state.user = user; // 约定俗成,一般就是放这里,也是没有为什么
    } catch (err) {
        ctx.throw(401, err.message);
    }

    await next();
}

用户操作中,关于修改与删除(实际并不存在删除,所谓删除其实只会把数据库中的这一条数值设置为非激活状态,现实当中是不会去删除数据库数据的),是必须要验证用户权限的,否则当前用户能修改别人的信息是不合理的,所以需要对用户的权限进行校验,思路也很简单,检查一下用户的id跟要修改的用户数据id是否一致即可

// controllers/user.js
async checkOwner (ctx, next) {
    if (ctx.params.id !== ctx.state.user._id) ctx.throw('403', '没有权限');
    await next();
}
// routes/users.js
// 修改特定用户
router.patch('/:id', auth, checkOwner, update);

// 删除用户
router.delete('/:id', auth, checkOwner, del);

注意一点,自动化脚本只能写在登陆上,其他接口不能写,否则其他接口也会去获取写入全局token,这样会导致token为空~

注意点

Lesson-8 用 koa-jwt 中间件实现用户认证与授权

上一节自己编写的只是用来了解原理,实际操作肯定还是用人家造好的轮子的。就像一开始字段的验证一样,尽量使用社区中优秀的中间件

操作步骤

  • 安装 koa-jwt
  • 使用中间件保护接口
  • 使用中间件获取用户信息

安装 koa-jwt

执行命令 npm i koa-jwt --save

使用中间件保护接口

// routes/users.js
const jwt = require('koa-jwt');
// 认证中间件
const auth = jwt({ secret }); // 原来的auth整段代码变成一句,简洁

使用中间件获取用户信息

由于 koa-jwt 自带 jsonwebtoken ,所以并不需要我们额外再去引入 jsonwebtoken 来解析 token 获取用户资料,所以没代码。


不需要再次编写,默认自带
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,496评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,407评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,632评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,180评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,198评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,165评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,052评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,910评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,324评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,542评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,711评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,424评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,017评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,668评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,823评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,722评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,611评论 2 353

推荐阅读更多精彩内容