基本所有网站都有登录功能,登录之后再次请求依然是登录状态。
但 http 是无状态的,也就是说上一次请求和下一次请求之间没有任何关联。
那如何实现的这种登录状态的保存呢?
这个问题的解决有两种方案:
但这两种方式也都有各自的缺点。
给 http 添加状态,那就给每个请求打上个标记,然后在服务端存储这个标记对应的数据。这样每个被标记的请求都可以找到对应的数据,自然可以做到登录、权限等状态的存储。
这个标记应该是自动带上的,所以 http 设计了 cookie 的机制,在里面存储的数据是每次请求都会带上的。
然后根据 cookie 里的标记去查找的服务端对应的数据叫做 session,这个标记就是 session 的 id。
因为请求自动带上 cookie,那两次请求就都可以找到 id 为 1 对应的 session,自然就知道当前登录的用户是谁,也可以存储其他的状态数据。这就是 session + cookie 的给 http 添加状态的方案。
因为 cookie 会在请求时自动带上,那你在一个网站登录了,再访问别的网站,万一里面有个按钮会请求之前那个网站的,那 cookie 依然能带上。而这时候就不用再登录了。
这样万一点了这个按钮之后做了一些危险的操作呢?一般这种利用 CSRF 漏洞的网站都会伪装的很好,让你很难看出破绽来,这种网站叫做钓鱼网站。
为了解决这个问题,一般会验证 referer,就是请求是哪个网站发起的,如果发起请求的网站不对,那就阻止掉。但这样依然不能完全解决问题,万一你用的浏览器也是有问题的,能伪造 referer 呢?
所以一般会用随机值来解决,每次随机生成一个值返回,后面再发起的请求需要包含这个值才行,否则就认为是非法的。这个随机值叫做 token,可以放在参数中,也可以放在 header 中,因为钓鱼网站拿不到这个随机值,就算带了 cookie 也没法通过服务端的验证。
这是 session + cookie 这种方案的一个缺点,但是是有解决方案的。
session 是把状态数据保存在服务端,那么问题来了,如果有多台服务器呢?
当并发量上去了,单台服务器根本承受不了,自然需要做集群,也就需要多台服务器来提供服务。而且现在后端还会把不同的功能拆分到不同的服务中,也就是微服务架构,自然也需要多台服务器。
那不同服务器之间的 session 怎么同步?登录之后 session 是保存在某一台服务器的,之后可能会访问到别的服务器,这时候那台服务器是没有对应的 session 的,就没法完成对应的功能。
这个问题的解决有两种方案:
一种是 session 复制,也就是通过一种机制在各台机器自动复制 session,并且每次修改都同步下。这个有对应的框架来做,比如 java 的 spring-session。各台服务器都做了 session 复制了,那你访问任何一台都能找到对应的 session。
还有一种方案是把 session 保存在 redis,这样每台服务器都去那里查,只要一台服务器登录了,其他的服务器也就能查到 session,这样就不需要复制了。分布式会话的场景,redis + session 的方案更常用一点。
cookie 为了安全,是做了 domain 的限制的,设置 cookie 的时候会指定一个 domain,只有这个 domain 的请求才会带上这个 cookie。而且还可以设置过期时间、路径等。
那万一是不同 domain 的请求呢?也就是跨域的时候,怎么带 cookie 呢?
a.xxx.com 和 b.xxx.com 这种还好,只要把 domain 设置为顶级域名 xxx.com 就可以了,那二三级域名不同也能自动带上。
但如果顶级域名也不同就没办法了,这种只能在服务端做下中转,把这俩个域名统一成同一个。
上面说的不是 ajax 请求,ajax 请求有额外的机制:
ajax 请求跨域的时候是不会挟带 cookie 的,除非手动设置 withCredentials 为 true 才可以。而且也要求后端代码设置了对应的 header:
Access-Control-Allow-Origin: "当前域名";
Access-Control-Allow-Credentials: true这里的 allow origin 设置 * 都不行,必须指定具体的域名才能接收跨域 cookie。
session + cookie 的给 http 添加状态的方案是服务端保存 session 数据,然后把 id 放入 cookie 返回,cookie 是自动携带的,每个请求可以通过 cookie 里的 id 查找到对应的 session,从而实现请求的标识。这种方案能实现需求,但是有 CSRF、分布式 session、跨域等问题,不过都是有解决方案的。
session + cookie 的方案是把状态数据保存在服务端,再把 id 保存在 cookie 里来实现的。
既然这样的方案有那么多的问题,那我反其道而行之,不把状态保存在服务端了,直接全部放在请求里,也不放在 cookie 里了,而是放在 header 里,这样是不是就能解决那一堆问题了呢?
token 的方案常用 json 格式来保存,叫做 json web token,简称 JWT。
JWT 是保存在 request header 里的一段字符串(比如用 header 名可以叫 authorization),它分为三部分:header、payload、verify signature。
header 部分保存当前的加密算法,payload 部分是具体存储的数据,verify signature 部分是对 header 和 payload 使用 secret 密钥做签名之后生成的。(secret 是服务端持有的一段字符串,不随 token 传输,服务端用它验证签名)
这三部分会分别做 Base64,然后连在一起就是 JWT token,放到某个 header 比如 authorization 中:
authorization: Bearer xxxxx.xxxxx.xxxx请求的时候把这个 header 带上,服务端就可以解析出对应的 header、payload、verify signature 这三部分,然后根据 header 里的算法也对 header、payload 用 secret 做一次签名,如果得出的结果和 verify signature 一样,就接受这个 token。把状态数据都保存在 payload 部分,这样就实现了有状态的 http。
这种方式的优势可以在三个方面体现出来:
CSRF:因为不是通过自动带的 cookie 来关联服务端的 session 保存的状态,所以没有 CSRF 问题。
分布式 session:因为状态不是保存在服务端,所以无论访问哪台服务器都行,只要能从 token 里解析出状态数据就行。
跨域:因为不是 cookie 那一套,自然也没有跨域的限制,只要手动带上 JWT 的 header 就行。
因为 JWT 把数据直接 Base64 之后就放在了 header 里,那别人就可以轻易从中拿到状态数据,比如用户名等敏感信息,也能根据这个 JWT 去伪造请求。所以 JWT 要搭配 https 来用,让别人拿不到 header。
JWT 把状态数据都保存在了 header 里,每次请求都会带上,比起只保存个 id 的 cookie 来说,请求的内容变多了,性能也会差一些。所以 JWT 里也不要保存太多数据。
session 因为是存在服务端的,那就可以随时让它失效,而 JWT 不是,因为是保存在客户端,那是没法手动让他失效的。比如踢人、退出登录、改完密码下线这种功能就没法实现。但也可以配合 redis 来解决,记录下每个 token 对应的生效状态,每次先去 redis 查下 jwt 是否是可用的,这样就可以让 jwt 失效。
JWT 的方案是把状态数据保存在 header 里,每次请求需要手动携带,没有 session + cookie 方案的 CSRF、分布式、跨域的问题,但是也有安全性、性能、没法控制等问题。
http 是无状态的,也就是请求和请求之间没有关联,但很多功能的实现是需要保存状态的。给 http 添加状态有两种方式:
session + cookie:把状态数据保存到服务端,session id 放到 cookie 里返回,这样每次请求会带上 cookie,通过 id 来查找到对应的 session。这种方案有 CSRF、分布式 session、跨域的问题。
jwt:把状态保存在 json 格式的 token 里,放到 header 中,需要手动带上,没有 cookie + session 的那些问题,但是也有安全性、性能、没法手动控制失效的问题。
上面这两种方案都不是完美的,但那些问题也都有解决方案。常用的方案基本是 session + redis、jwt + redis 这种。
软件领域很多情况下都是这样的,某种方案都解决了一些问题,但也相应的带来了一些新的问题。没有银弹,还是要熟悉它们的特点,根据不同的需求灵活选用。
首先用 @nestjs/cli 快速创建一个 Nest.js 项目:
nest new jwt-and-session -p npm先实现 session + cookie 的方式。Nest 里实现 session 还是用的 express 的中间件 express-session。
安装 express-session 和它的 ts 类型定义:
npm install express-session @types/express-session然后在入口模块里启用它:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as session from 'express-session';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(session({
secret: 'guang',
resave: false,
saveUninitialized: false
}));
await app.listen(3000);
}
bootstrap();
使用 express-session 中间件,指定加密的密钥 secret。
resave 为 true 是每次访问都会更新 session,不管有没有修改 session 的内容,而 false 是只有 session 内容变了才会去更新 session。
saveUninitialized 设置为 true 是不管是否设置 session,都会初始化一个空的 session 对象。比如你没有登录的时候,也会初始化一个 session 对象,这个设置为 false 就好。
然后在 controller 里就可以注入 session 对象:
@Get('sss')
sss(@Session() session) {
console.log(session)
session.count = session.count ? session.count + 1 : 1;
return session.count;
}我在 session 里放了个 count 的变量,每次访问加一,然后返回这个 count。这样就可以判断 http 请求是否有了状态。
把它跑起来:
nest start --watch然后用 postman 测试下:每次请求返回的数据都不同,而且返回了一个 cookie 是 connect.sid,这个就是对应 session 的 id。
因为 cookie 在请求的时候会自动带上,就可以实现请求的标识,给 http 请求加上状态。
jwt 需要引入 @nestjs/jwt 这个包:
npm install @nestjs/jwt然后在 AppModule 里引入 JwtModule:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [
JwtModule.register({
secret: 'guang',
signOptions: {
expiresIn: '7d'
}
})
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}JwtModule 是一个动态模块,通过 register 传入 option。或者是 registerAsync,然后通过 useFactory 异步拿到 option 传入(动态模块的知识)。
指定 secret,也就是加密 jwt 的密钥,还有 token 过期时间 expiresIn,设置 7 天。
然后在 controller 里注入 JwtModule 里的 JwtService,添加一个 handler:
@Get('ttt')
ttt(@Res({ passthrough: true}) response: Response) {
const newToken = this.jwtService.sign({
count: 1
});
response.setHeader('token', newToken);
return 'hello';
}这里使用 jwtService.sign 来生成一个 jwt token,放到 response header 里。
因为注入 response 对象之后,默认不会把返回值作为 body 了,需要设置 passthrough 为 true 才可以。
访问后,返回的响应确实带上了这个 header。后面的请求需要带上这个 token,在服务端取出来,然后 +1 之后再放回去,改造一下刚才的 handler:
@Get('ttt')
ttt(@Headers('authorization') authorization: string, @Res({ passthrough: true}) response: Response) {
if(authorization) {
try {
const token = authorization.split(' ')[1];
const data = this.jwtService.verify(token);
const newToken = this.jwtService.sign({
count: data.count + 1
});
response.setHeader('token', newToken);
return data.count + 1
} catch(e) {
console.log(e);
throw new UnauthorizedException();
}
} else {
const newToken = this.jwtService.sign({
count: 1
});
response.setHeader('token', newToken);
return 1;
}
}通过 @Headers 装饰器取出 authorization 的 header,然后通过 jwtService.verify 对它做验证。
如果验证失败,那就抛出 UnauthorizedException 异常,让 Nest 内置的 Exception Filter 来处理。验证成功就重新生成 jwt 放到 header 里返回。如果没有 authorization 的 header,那就生成一个 jwt 放到 header 里返回。
测试下:第一次访问会返回 jwt token,把它放到请求的 header 里,响应就变成 2,并且返回一个新的 token,放到 header 里再次请求,返回的就是 3 了。这就是通过 jwt 保存状态的方式。如果带一个错误的 token,jwtService.verify 方法就会抛异常,然后返回 401 错误。
携带 jwt 需要放在 authorization 的 header 里,以 Bearer xxx 的格式,但是返回 jwt 可以放在任何地方,header、cookie 或者 body 里都可以。
分别在 nest 里实现了 session、jwt 两种给 http 添加状态的方式。
session 使用的是 express 的 express-session 中间件,通过 @Session 装饰器取出来传入 controller 里。
jwt 需要引入 @nestjs/jwt 包的 JwtModule,注入其中的 JwtService,然后通过 jwtService.sign 生成 token,通过 jwtService.verify 验证 token。token 放在 authorization 的 header 里。
session 或者 jwt 都是非常常用的给 http 添加状态的方式,下一节用这两种方式实现下登录注册功能。