session ID认证流程
- 用户向服务器发送用户名和密码。
- 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等。
- 服务器向用户返回一个session_id,写入用户的Cookie。
- 用户随后的每一次请求,都会通过Cookie将session_id传回服务器。
- 服务器收到session_id,找到前期保存的数据,由此得到用户的身份。
session ID认证的缺陷
如果是服务器集群,或者是跨域的服务导向架构,就要求session数据共享,每台服务器都能够读取session。
- 一种解决方案是session数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,值久层万一挂了,就会单点失败。
- 另一种方案是服务器索性不保存session数据了,所有数据都保存在客户端,每次请求都发回服务端。JWT就是这种方案的一个代表。
Node + koa实现Session ID认证
- 安装koa-session
js
pnpm install koa-session
- 配置koa-session中间件
- 使用koa-session中间件, 登录成功后向session添加内容
设置session后,在这里我通过await ctx.session.manuallyCommit();
手动提交。在官方文档中,将到配置文件中设置autoCommit:false
时才需要使用手动提交,但没有使用await ctx.session.manuallyCommit();
时,http接口响应头中没有set-cookie:koa.sess=...
,具体缘由不详。
此时,koa-session会自动设置添加cookies,具体内容如下:
4. 配置下一次请求,携带上cookies
我的前端和后端的域名都为localhost,但前后端端口不一致,所以客户端Axios需要配置withCredentials:true
, 同时服务端也需要配置Access-Control-Allow-Credentials:true
。
具体配置内容如下:
服务端,使用koa-cors中间件,cors配置项如下: 此时,再次发起请求浏览器就会自动携带cookies。
- 模拟不同用户,请求携带不同的session_id(即koa.sess),查看返回的的用户信息
调试koa-session源码,查看获取ctx.session的逻辑
如上图,此时opts.key
为koa.sess
,从cookies中获取key为koa.sess
的cookie。
上图中,body的值如下:body中除了用户信息外,还存有过期时间。
js
'{"userInfo":{"id":"1716945823362292464170","user_name":"lily","is_admin":true},"_expire":1717032223363,"_maxAge":86400000}'
通过 Buffer.from(string, 'base64')
将base64编码的字符串转换为Buffer对象。 toString('utf8')
将Buffer对象转换为utf8编码的字符串。
由上面的源码解析可以的值,koa-session实现session_id的逻辑是将存储用户信息和过期时间的对象转化为对象字符串,再进行base64编码。
如上图,获取到用户信息和过期时间后,接着判断是否过期。
最后,将用户信息赋值给ctx.session。
由此可见,koa-session,默认是将用户信息base64编码后存储在cookie中。
session存放在cookie中的问题
- base64编码后的内容会随着原内容的增加而变长,而cookie的长度是有限制的。
- 如果cookie被攻击者盗取,通过base64解码,很容易获取到用户的信息。
koa-session实现session存储在内存
- 安装koa-session-memory
js
pnpm install koa-session-memory
- 使用koa-session-memory
- 查看请求头中的cookie,此时koa-sess的值短了很多了。
4. 此时,内存中就有一个对象用来存储所有session。该对象就是cookie中key为koa-sess的值。也就是常说的session_id。
Token验证
JWT
可用来生成验证token
JWT三个组成部分:
- header 头部
- playload 载荷
- signatrue 签名,保证令牌的安全性
Header
Header部分是一个JSON对象,描述JWT的元数据,通常是下面这个样子。
js
{
"alg": "HS256",
"typ": "JWT"
}
上面代码中,alg属性表示签名的算法,默认是HMAC SHA256(写成HS256);typ属性表示这个令牌(token)的类型,JWT令牌统一写为JWT。
最后,将上面的JSON对象使用Base64URL算法(转成字符串)。
Payload
Payload部分也是一个JSON对象,用来存放实际需要传递的数据。JWT规定了7个官方字段,供选用。
- iss (issuer): 签发人
- exp (expiration time): 过期时间
- sub (subject): 主题
- aud (audience): 受众
- ndf (Not Before): 生效时间
- iat (Issued At): 签发时间
- jti (JWT ID): 编号
除了官方字段,你可以在这个部分定义私有字段,下面就是一个例子。
js
{
"id": "7832713263",
"name": "lily",
"admin": true
}
注意,JWT默认是不加密的,任何人都可以读到,所以不要把密码信息放在这个部分。
这个JSON对象也要使用Base64URL算法转成字符串。
Signature
Signature部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄漏给用户。然后,使用Header里面指定的签名算法,按照下面的公式产生签名。
js
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
算出签名以后,把Header、Payload、Signature三个部分拼成一个字符串,每个部分之间用"点"(.
)分割,就可以返回给用户。
JWT的使用方式
客户端收到服务端返回的JWT,可以储存在Cookie里面,也可以存储在localStorage。
此后,客户端每次和服务器通信,都会带上这个JWT。你可以放在Cookie里面自动发送,但是这样不能跨域,所以更好的做法是放在HTTP请求的头信息Authorization字段里面。
js
Authorization: Bearer <token>
另外一种做法是,跨域的时候,JWT就放在POST请求的数据体里面。
参考文档: www.ruanyifeng.com/blog/2018/0...
Token的缺点
- token太长了 token是header、payload编码后的样式,所以一般要比sessionId长很多,很有可能超出cookie的大小限制(cookie一般有大小限制,如4kb)。
- 不太安全 比如使用JWT实现的token,payload部分是用户信息,只是进行base64Url编码,默认并没有加密。所以token被攻击者窃取后,很容易解析出用户信息。
上面koa-session默认方式和token认证很相似,只是较JWT没有签名部分。
Node + Koa实现Token认证
- 安装jsonwebtoken
js
pnpm install jsonwebtoken
- 用户登录后,服务器通过jsonwebtoken生成token,并返回给客户端
js
const jwt = require('jsonwebtoken')
const info = {
id: uuid,
user_name: user_name,
is_admin: true
}
jwt.sign(info, JWT_SECRET, {
expiresIn: '1d' //过期时间失败
})
- 客户端再次请求时,将token放在请求头中,通常是将token放在一个名为Authorization的请求头中。
token前面的Bearer是什么?
developer.mozilla.org/zh-CN/docs/...
4. 服务端接受新的接口,会从请求头中拿到token,验证该token是否有效,是否过期。
js
const jwt = require('jsonwebtoken')
const { tokenExpiredError, invalidToken } = require('../constant/err.type')
const auth = async (ctx, next) => {
const { authorization} = ctx.request.header
const token = authorization.replace('Bearer ', '')
const JWT_SECRET = 'abcde' //密钥
try {
// user中包含payload信息(ID, user_name, is_admin)
const user = jwt.verify(token, JWT_SECRET)
} catch (error) {
// token过期
// token无效
console.error(11, error.name)
switch(error.name) {
case 'TokenExpiredError':
console.error('token 已过期', error)
return ctx.app.emit('error', tokenExpiredError, ctx)
case 'JsonWebTokenError':
console.log('无效的token', error)
return ctx.app.emit('error', invalidToken, ctx)
}
}
await next()
}