Session
为什么用 session + cookie 实现登录保存
HTTP 协议是无状态的,这表示当前请求和下一次请求对于 HTTP 来说是没有任何关联的。
思考这样一种场景:
- 你登录了 xxx 商城
- 你将 xxx 商品加入购物车
- 你的账户的购物车中出现了该商品
这种最常见的方式最少涉及两个请求:
- 登录请求
- 加购请求
但是 HTTP 并不会记住是“你”登录了该网站,也不知道是“你”将商品加入了购物车。
这就需要一种机制,这种机制的目的是保存你的登录信息,然后告诉服务器加购物车的请求是你发出的。
http 提供了这种机制的扩展,也就是 cookie。
cookie 是通过 http 在浏览器和服务器之间传递的标记,浏览器发出请求时,会自动把 cookie 带上让服务器验证。
cookie 是哪里来的呢?服务器需要知道你是谁,当然要由服务端生成。
服务器生成怎样的 cookie 以便识别用户身份呢?
由于 cookie 会被浏览器保存,我们自然不能把用户的账号密码等重要信息当做 cookie 发送给浏览器。
我们可以做这样一种设计:
- 当用户登录时,在服务端生成 session 数据。session 数据表示用户开始跟服务器建立了一次会话,并在 session 里存放用户的信息(用户名、账号密码等)。
- 服务端将该 session 的 id 当做 cookie 传递给浏览器。
- 浏览器在发送请求时将 cookie 带给服务端。
- 服务端验证。
session + cookie 的流程大抵如此。
session 的问题
这样的做法让 HTTP 有了状态,确实解决了登录状态的问题,但是该方案并不完美:
-
请求有可能会被伪造,即 CSRF 跨站请求伪造。
cookie 是浏览器发给服务端请求时自动带过去的,很有可能你的某个请求会在你不知情的情况下发出去。
比如你登录了 xxx (里面有你的虚拟道具),然后你又点击了某个钓鱼网站的某个链接。
该钓鱼网站会发送请求给 xxx (假设是扣除你的虚拟道具的请求),此时浏览器会把这个 cookie 带上,结果是 xxx 会验证通过,你的虚拟道具没了。
要解决这个问题其实也简单,一种方法是服务端校验 HTTP 的 referer,referer 记录了请求是从哪个网站发出的,如果并非白名单的网站请求,一律过滤即可。
-
如果公司是服务器集群,就要求 session 数据共享,每台服务器都能够同步读取 session。
举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?
要解决这个问题,我们就得把 session 独立出来,放到某个数据层中。不同服务器都从该数据层中同步 session 数据。
但是缺点也一目了然,数据层挂了,就会单点失败,而且工程量比较大。
有一种还不错的方案是将 session 数据放到 redis 中,只要有一个服务器单点登陆成功,其他服务器也能查到 session。
-
如果公司使用以跨域的服务导向架构怎么办?
在跨域请求时,浏览器默认情况下不会发送跨域请求的 Cookie。这是因为浏览器实施了同源策略,为了保护用户隐私和安全,不允许在跨域请求中携带敏感信息,比如 Cookie。
举例来说就是 aaa.com 发送请求给 bbb.com 时,不会带上 bbb.com 需要的 cookie。
如果仅是 ajax 相关的请求,还是有办法解决的(CORS)。
-
在 bbb.com 服务端设置允许跨域请求的头信息,包括
Access-Control-Allow-Origin和Access-Control-Allow-Credentials。例如:
typescript1const express = require('express') 2const app = express() 3 4app.use((req, res, next) => { 5 res.header('Access-Control-Allow-Origin', 'http://your-frontend-domain.com') 6 res.header('Access-Control-Allow-Credentials', true) 7 next() 8}) -
在前端的 XMLHttpRequest 或 Fetch 请求中设置
withCredentials为true。typescript1fetch('http://your-api-domain.com/data', { 2 method: 'GET', 3 credentials: 'include', // 使用 'include' 来启用跨域请求携带 Cookie 4})
-
在实际应用时,使用 session 面临的问题还是蛮多的。为了解决这些问题,我们需要采取不同的方案去弥补。
在 Nest 中实现分布式 session
分布式 session 就是在多台服务器都可以访问到同一个 session。
我们可以在 redis 里存储它:

用户第一次请求的时候,生成一个随机 id,以它作为 key,存储的对象作为 value 放到 redis 里。
后携带 cookie 的时候,根据其中的 sid 来取 redis 中的值,注入 handler。
修改 session 之后再设置到 redis 里。
这样就完成了 session 的创建、保存、修改。
先创建 redis 模块和 redis service。
bash1pnpm install redis 2nest generate module redis 3nest generate service redis --no-spec
标记 redis module 为 global。
typescript1import { Global, Module } from '@nestjs/common' 2import { createClient } from 'redis' 3import { RedisService } from './redis.service' 4 5@Global() 6@Module({ 7 providers: [ 8 { 9 provide: 'REDIS_CLIENT', 10 useFactory: async () => { 11 const client = createClient({ 12 socket: { 13 host: 'localhost', 14 port: 6379, 15 }, 16 }) 17 await client.connect() 18 return client 19 }, 20 }, 21 RedisService, 22 ], 23 exports: ['REDIS_CLIENT', RedisService], 24}) 25export class RedisModule {}
接着在 RedisService 中封装一些 redis 的方法:
typescript1import { Inject, Injectable } from '@nestjs/common' 2import { RedisClientType } from 'redis' 3 4@Injectable() 5export class RedisService { 6 constructor( 7 @Inject('REDIS_CLIENT') 8 private redisClient: RedisClientType, 9 ) {} 10 11 async hashGet(key: string) { 12 return await this.redisClient.hGetAll(key) 13 } 14 15 async hashSet(key: string, data: Record<string, any>, expire?: number) { 16 const keyNames = Object.keys(data) 17 18 for (const keyName of keyNames) { 19 await this.redisClient.hSet(key, keyName, data[keyName]) 20 } 21 22 if (expire) { 23 await this.redisClient.expire(key, expire) 24 } 25 } 26}
我们仅需要 hash 数据结构来保存 session_id 和 用户数据,所以这里封装了对 hash 的 hashGet 和 hashSet 两种方法。
其中 hashSet 还可以用来设置 expire 过期时间。
下面开始正式实现分布式 Session,先说一下总体思路:
- 先从 req 中获取 cookie,如果有 sid 再去查询 session 数据是否存在。
- 如果 session 数据不存在,则设置 session,并在 response 中设置 cookie。
- 如果 session 存在,则 count+1,表示用户又访问了一次。
- 重复的登录请求会刷新 session 过期时间。
将上面的思路的实现代码都封装进 sessionService 中。
bash1nest generate service session --no-spec
使用 generate 创建 service 会自动在 appModule 上引入该 service。
typescript1... 2providers: [AppService, SessionService]
SessionService 设置 setSession 和 getSession 方法
typescript1import { Inject, Injectable } from '@nestjs/common' 2import { RedisService } from 'src/redis/redis.service' 3import { v4 as uuidV4 } from 'uuid' 4 5@Injectable() 6export class SessionService { 7 constructor( 8 @Inject(RedisService) 9 private redisService: RedisService, 10 ) {} 11 12 async setSession(key: string | undefined, value: Record<string, any>, expire?: number) { 13 if (key) { 14 await this.redisService.hashSet(`sid:${key}`, value, expire) 15 return key 16 } else { 17 const sid = uuidV4() 18 await this.redisService.hashSet(`sid:${sid}`, value, expire) 19 return sid 20 } 21 } 22 23 async getSession<T extends Record<string, string>>(sid: string): Promise<T | void> 24 async getSession(sid: string) { 25 const data = await this.redisService.hashGet(`sid:${sid}`) 26 if (JSON.stringify(data) === '{}') { 27 return undefined 28 } 29 return data 30 } 31}
- session 的 id 作为 redis 的 key
- session 的 value 作为 对应 key 的 hash 表
- 如果查询 session 时发现是空对象,则说明 key 不存在,直接返回 undefined
- getSession 方法使用了类型重载,这样外部调用该方法时可以指定 session 值的类型
下面实现 controller 层。
nestjs 要使用 cookie,需要下载 cookie-parser 包。
bash1pnpm install cookie-parser 2pnpm i --save-dev @types/cookie-parser
在 bootstrap 时使用:
typescript1async function bootstrap() { 2 const app = await NestFactory.create(AppModule) 3 app.use(cookieParser()) 4 await app.listen(3000) 5}
这里的 controller 主要实现统计用户的请求次数:
typescript1import { Controller, Get, Inject, Req, Res } from '@nestjs/common' 2import { SessionService } from './session/session.service' 3import { Request, Response } from 'express' 4 5@Controller() 6export class AppController { 7 constructor( 8 @Inject(SessionService) 9 private readonly sessionService: SessionService, 10 ) {} 11 12 @Get('count') 13 async count(@Req() req: Request, @Res() res: Response) { 14 const sid = req.cookies?.sid 15 16 const session = await this.sessionService.getSession<{ count: string }>(sid) 17 18 const count = session ? Number(session.count) + 1 : 1 19 20 const curId = await this.sessionService.setSession(sid, { count }, 60) 21 res.cookie('sid', curId, { 22 maxAge: 1000 * 60, 23 httpOnly: true, 24 }) 25 26 res.status(200).send(`count: ${count}`) 27 } 28}
现在访问:http://localhost:3000/count。
第一次访问时会在 redis 上生成一条数据,并且根据代码的设定,持续时间为 60s。

同时返回后在浏览器上也会有 cookie,它的过期时间也是 60s。

后续在该时间内访问时,浏览器的 request header 上就会把这条 cookie 带上去。

此时 redis 上的该条 session 的 count + 1。

现在我们已经完成了 使用 redis 实现分布式 Session 的基本功能。
在真正的项目里,我们会将用户的 id、name 等等信息存到 redis 的 session hash 表中。
如果从 cookie 中获取的 sid 能够从 redis 中查出 user 信息,那么我们就解决了“服务端需要知道用户是谁”的问题。