Session

为什么用 session + cookie 实现登录保存

HTTP 协议是无状态的,这表示当前请求和下一次请求对于 HTTP 来说是没有任何关联的。

思考这样一种场景:

  1. 你登录了 xxx 商城
  2. 你将 xxx 商品加入购物车
  3. 你的账户的购物车中出现了该商品

这种最常见的方式最少涉及两个请求:

  • 登录请求
  • 加购请求

但是 HTTP 并不会记住是“你”登录了该网站,也不知道是“你”将商品加入了购物车。

这就需要一种机制,这种机制的目的是保存你的登录信息,然后告诉服务器加购物车的请求是你发出的。

http 提供了这种机制的扩展,也就是 cookie。

cookie 是通过 http 在浏览器和服务器之间传递的标记,浏览器发出请求时,会自动把 cookie 带上让服务器验证。

cookie 是哪里来的呢?服务器需要知道你是谁,当然要由服务端生成。

服务器生成怎样的 cookie 以便识别用户身份呢?

由于 cookie 会被浏览器保存,我们自然不能把用户的账号密码等重要信息当做 cookie 发送给浏览器。

我们可以做这样一种设计:

  1. 当用户登录时,在服务端生成 session 数据。session 数据表示用户开始跟服务器建立了一次会话,并在 session 里存放用户的信息(用户名、账号密码等)。
  2. 服务端将该 session 的 id 当做 cookie 传递给浏览器。
  3. 浏览器在发送请求时将 cookie 带给服务端。
  4. 服务端验证。

session + cookie 的流程大抵如此。

session 的问题

这样的做法让 HTTP 有了状态,确实解决了登录状态的问题,但是该方案并不完美:

  1. 请求有可能会被伪造,即 CSRF 跨站请求伪造

    cookie 是浏览器发给服务端请求时自动带过去的,很有可能你的某个请求会在你不知情的情况下发出去。

    比如你登录了 xxx (里面有你的虚拟道具),然后你又点击了某个钓鱼网站的某个链接。

    该钓鱼网站会发送请求给 xxx (假设是扣除你的虚拟道具的请求),此时浏览器会把这个 cookie 带上,结果是 xxx 会验证通过,你的虚拟道具没了。

    要解决这个问题其实也简单,一种方法是服务端校验 HTTP 的 referer,referer 记录了请求是从哪个网站发出的,如果并非白名单的网站请求,一律过滤即可。

  2. 如果公司是服务器集群,就要求 session 数据共享,每台服务器都能够同步读取 session。

    举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?

    要解决这个问题,我们就得把 session 独立出来,放到某个数据层中。不同服务器都从该数据层中同步 session 数据。

    但是缺点也一目了然,数据层挂了,就会单点失败,而且工程量比较大。

    有一种还不错的方案是将 session 数据放到 redis 中,只要有一个服务器单点登陆成功,其他服务器也能查到 session。

  3. 如果公司使用以跨域的服务导向架构怎么办?

    在跨域请求时,浏览器默认情况下不会发送跨域请求的 Cookie。这是因为浏览器实施了同源策略,为了保护用户隐私和安全,不允许在跨域请求中携带敏感信息,比如 Cookie。

    举例来说就是 aaa.com 发送请求给 bbb.com 时,不会带上 bbb.com 需要的 cookie。

    如果仅是 ajax 相关的请求,还是有办法解决的(CORS)。

    • 在 bbb.com 服务端设置允许跨域请求的头信息,包括 Access-Control-Allow-OriginAccess-Control-Allow-Credentials

      例如:

      typescript
      1const 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 请求中设置 withCredentialstrue

      typescript
      1fetch('http://your-api-domain.com/data', {
      2  method: 'GET',
      3  credentials: 'include', // 使用 'include' 来启用跨域请求携带 Cookie
      4})

在实际应用时,使用 session 面临的问题还是蛮多的。为了解决这些问题,我们需要采取不同的方案去弥补。

在 Nest 中实现分布式 session

分布式 session 就是在多台服务器都可以访问到同一个 session。

我们可以在 redis 里存储它:

image-20231209210448929

用户第一次请求的时候,生成一个随机 id,以它作为 key,存储的对象作为 value 放到 redis 里。

后携带 cookie 的时候,根据其中的 sid 来取 redis 中的值,注入 handler。

修改 session 之后再设置到 redis 里。

这样就完成了 session 的创建、保存、修改。

先创建 redis 模块和 redis service。

bash
1pnpm install redis
2nest generate module redis
3nest generate service redis --no-spec

标记 redis module 为 global。

typescript
1import { 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 的方法:

typescript
1import { 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 中。

bash
1nest generate service session --no-spec

使用 generate 创建 service 会自动在 appModule 上引入该 service。

typescript
1...
2providers: [AppService, SessionService]

SessionService 设置 setSession 和 getSession 方法

typescript
1import { 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 包。

bash
1pnpm install cookie-parser
2pnpm i --save-dev @types/cookie-parser

在 bootstrap 时使用:

typescript
1async function bootstrap() {
2  const app = await NestFactory.create(AppModule)
3  app.use(cookieParser())
4  await app.listen(3000)
5}

这里的 controller 主要实现统计用户的请求次数:

typescript
1import { 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。

image-20231213211534592

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

image-20231213211913408

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

image-20231213212153428

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

image-20240128181156750

现在我们已经完成了 使用 redis 实现分布式 Session 的基本功能。

在真正的项目里,我们会将用户的 id、name 等等信息存到 redis 的 session hash 表中。

如果从 cookie 中获取的 sid 能够从 redis 中查出 user 信息,那么我们就解决了“服务端需要知道用户是谁”的问题。

示例代码