JSON Web Token

HTTP 是一种无状态的通信协议,所谓无状态,就是服务器不能通过它判断两次发请求过来的是不是同一个用户。好在它可以扩展,通过扩展,可以分辨出是同一个用户在访问网站。

比如:

用户需要登录后才能在淘宝上购买商品,这样服务器才能知道购买商品的人是谁,地址是哪里。

当用户将某商品加入购物车时,也会将请求发送给服务器,而服务器则需要通过上次用户登录的记忆知道发请求的人是谁,这样才能将信息推到对应人的购物车上。

那么,这里的记忆在以前是指 cookie。

它的流程是这样的:

  • 用户向服务器发送账号和密码。
  • 服务器验证通过后,在当前对话(session)里面保存相关的数据,比如用户角色,登录时间等。
  • 服务器向用户返回一个 session_id,写入用户的 Cookie。
  • 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
  • 服务器收到 session_id,得知用户的身份,就可以知道这是同一个用户。

这种模式的缺点在于扩展性不足。比如当用户登录同一家公司的不同网站都需要用户信息时,session 数据 就需要共享,让每台服务器都能读取 session。

共享的方法是让 session 数据持久化,写入数据库或者别的持久层。虽然这种方法架构清晰,但也工程量巨大,而且持久层的稳定性要求高。

由于上面的方法缺点明显,所以就需要另一种方法:

直接放弃 session 这种方式,而是利用新的方案替代原来的登录体系,JWT 就是流行的方案。

JWT 方案

JWT 全称 JSON Web Token,翻译过来就是 JSON 格式的 网络令牌。它会直接将用户的信息生成一个 JSON对象,发还给用户。

比如当用户登录 后,服务器经过认证,发现他的信息是这样的

json
1{
2  "username": "qiuyanxi",
3  "role": "admin",
4  "time": "2021-07-06"
5}

服务器会把这条数据返回给用户,当用户每次请求时,都会带上这条数据,服务器就可以通过这条数据上的内容判定用户身份。

这样的话服务器跟客户端的通信又会变成无状态的了。

不过由于直接这样传会不安全,所以 JWT 会将整个信息经过加密处理,转化成字符串的形式。

JWT 长什么样

整个 JWT 大概是这样的

img

它是一串很长的字符串,中间用.分割成三个部分:

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

img

Header 部分是 JSON 对象,描述 JWT 的元数据,通常长这样

json
1{
2  "alg": "HS256",
3  "typ": "JWT"
4}

alg 属性表示签名的算法,typ 属性表示令牌的类型。

上面的 JSON 对象会通过 Base64URL 算法转化成字符串。

Payload

Payload 部分也是 JSON 对象,用来存放实际需要传递的数据,JWT 预设了以下官方字段:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

除此之外,还可以定义私有字段,比如这样

json
1{
2  "sub": "1234567890",
3  "name": "John Doe",
4  "admin": true
5}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

这个 JSON 对象也要使用 Base64URL 算法转成字符串。

Signature

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥,这个密钥只有服务器知道。可以通过以下命令生成一段随机的密钥:

bash
1openssl rand -base64 32

然后通过 Header 里面的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

javascript
1HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret)

计算出签名后,将 Header、Payload、Signature 三个部分拼接成字符串,每个部分用.隔开,返回给用户。

Base64URL

Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+/=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-/替换成_ 。这就是 Base64URL 算法。

Nodejs 实现 JWT

下面我们使用 koa 实现 JWT,理解整个过程。首先我会在登录界面发送请求,并附上账号密码。

然后使用 koa 实现一个简易的 web 服务端,来接收这个请求,并返回 JWT。

javascript
1//server.js
2import Koa from 'koa'
3const Router = require('koa-router')
4let bodyparser = require('koa-bodyparser')
5const jwt = require('jsonwebtoken') //通过这个库来生成 jwt
6const app = new Koa()
7const router = new Router()
8app.use(bodyparser())
9
10//登录验证
11router.post('/login', async (ctx, next) => {
12  const { username, password } = ctx.request.body
13  if (username === 'admin' && password === 'admin') {
14    //生成 jwt签名,ssh 是密钥
15    const token = jwt.sign({ username, exp: Math.floor(Date.now() / 1000) + 60 * 60 }, 'ssh')
16    ctx.body = {
17      code: 200,
18      success: true,
19      data: true,
20      username,
21      token,
22    }
23  } else {
24    ctx.body = { code: 403 }
25  }
26})
27// 验证是否有权限
28router.get('/validate', async (ctx) => {
29  let Authorization = ctx.get('authorization')
30  let [, token] = Authorization.split(' ')
31  if (token) {
32    try {
33      let r = jwt.verify(token, 'ssh') //核实 token
34      console.log(r)
35      ctx.body = {
36        code: 200,
37        username: r.username,
38        token,
39      }
40    } catch (e) {
41      ctx.status = 401
42      ctx.body = {
43        code: 401,
44        data: '没有登陆',
45      }
46    }
47  } else {
48    ctx.status = 403
49    ctx.body = { message: '你无此权限' }
50  }
51})
52
53app.use(router.routes()).use(router.allowedMethods())
54app.listen(3000) //监听3000端口

上面的代码中使用了 jsonwebtoken来实现令牌的生成

javascript
1const token = jwt.sign({ username, exp: Math.floor(Date.now() / 1000) + 60 * 60 }, 'ssh')

生成后返回给客户端的数据是这样子的

javascript
1code: 200
2data: true
3success: true
4token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNjI1NzUzODEzLCJpYXQiOjE2MjU3NTAyMTN9.RCCTU-KwuhmW7exexsx9WXtp2i0ec-ARSBCGIOdn_r0"
5username: "admin"

当用户登录时,还会进行一层权限校验,这时候客户端需要发送给服务器获取到的 token。

一般来说,token 会存到某个 store 中,由前端将其加入到请求头的Authorization

typescript
1/** 获取当前的用户 GET /api/currentUser */
2export async function currentUser(options?: { [key: string]: any }) {
3  return request<API.CurrentUser>('/api/validate', {
4    method: 'GET',
5    headers: {
6      Authorization: `Bear ${localStorage.getItem('access-token')}`,
7    },
8    ...(options || {}),
9  })
10}

当服务器收到后,会经过密钥解码,取出其中的数据

javascript
1      let r = jwt.verify(token, "ssh"); //核实 token
2      console.log(r);//{ username: 'admin', exp: 1625753813, iat: 1625750213 }

exp 是过期时间,而 iat 是签发时间,username 是在生成令牌时传入的用户信息。

整个过程大致就是如此。

Nest.js 实现 JWT

在 Nest 中使用 jwt 需要引入 @nestjs/jwt 这个包

bash
1pnpm install @nestjs/jwt

然后在 AppModule 里引入 JwtModule:

typescript
1import { Module } from '@nestjs/common'
2import { JwtModule } from '@nestjs/jwt'
3import { AppController } from './app.controller'
4import { AppService } from './app.service'
5
6@Module({
7  imports: [
8    JwtModule.register({
9      secret: 'secret',
10      signOptions: {
11        expiresIn: '7d',
12      },
13    }),
14  ],
15  controllers: [AppController],
16  providers: [AppService],
17})
18export class AppModule {}

JwtModule 是一个动态模块,通过 register 传入 option。

然后在 controller 里注入 JwtModule 里的 JwtService:

typescript
1import { Controller, Get, Inject } from '@nestjs/common'
2import { AppService } from './app.service'
3import { JwtService } from '@nestjs/jwt'
4
5@Controller()
6export class AppController {
7  constructor(
8    private readonly appService: AppService,
9    @Inject(JwtService)
10    private readonly jwtService: JwtService,
11  ) {}
12
13  @Get('/token')
14  async getToken() {
15    const newToken = await this.jwtService.sign({ name: 'John' })
16
17    return { accessToken: newToken }
18  }
19
20  @Get('/verify')
21  async verifyToken(@Headers('authorization') authorization: string) {
22    const token = authorization.replace('Bearer ', '')
23    try {
24      const result = this.jwtService.verify(token)
25      console.log('——————🚀🚀🚀🚀🚀 —— verifyToken —— result:', result)
26      return result
27    } catch (error) {
28      throw new UnauthorizedException()
29    }
30  }
31}

现在访问http://localhost:3000/token,就可以拿到 token 了。

image-20240128204506836

返回 token 时可以将它放到任意地方,bodyheader等都是可以的。

前端拿到 token 后,将其以Bearer <token>的形式放到Authorization字段中通过请求回传给服务端做验证。

我们用 postman 测试一下:

image-20240128205941721验证成功,拿到用户的信息了。

如果传一个错的 token 会校验失败,并且返回错误信息:

image-20240128210101669

到现在为止,就基本实现了在 Nestjs 中使用 JWT 做身份验证 (Authentication )。

是不是比 session+redis 的方案更简单?

代码示例

无感刷新登录状态

JWT 是有有效时间的,为了安全性考虑,往往会设置一个很短的时间,比如 30 分钟。

使用这种方式仅仅是让盗取信息者更不容易获取 token 而已。

这样做会导致一个问题:用户登录后才 30 分钟,就需要重新登录了,体验太差。

为了解决这个问题,服务端会返回两个 token:access_token 和 refresh_token。

access_token 是用来认证用户身份的,就是之前使用的 token。

refresh_token 是用来刷新 access_token 的。

流程是这样:

  1. 用户登录成功后,返回两个 token。
  2. 前端发送请求时,先检查 access_token 有效期,如果有效期过了,则使用 refresh_token 获取一个新的 access_token 。
  3. 用 access_token 发送请求。
  4. 如果 refresh_token 也过期了,则让用户需要重新登录。

下面基于 how-to-use-jwt 创建一个新的分支,并且创建一个 resource:

bash
1nest generate resource user --no-spec

UserService 中实现一个 login 方法,当查询到用户存在时,就返回两个 token:

typescript
1import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'
2import { JwtService } from '@nestjs/jwt'
3
4@Injectable()
5export class UserService {
6  constructor(
7    @Inject(JwtService)
8    private readonly jwtService: JwtService,
9  ) {}
10
11  // mock 数据
12  db = [{ id: '123', name: 'qyx', password: '123456' }]
13
14  async login(name: string, password: string) {
15    const user = this.db.find((user) => user.name === name)
16
17    if (!user || user.password !== password) {
18      throw new HttpException('用户名或密码错误', HttpStatus.FORBIDDEN)
19    }
20
21    const accessToken = this.jwtService.sign({ name, id: user.id }, { expiresIn: '30m' })
22
23    const refreshToken = this.jwtService.sign({ name, id: user.id }, { expiresIn: '7d' })
24    return { accessToken, refreshToken }
25  }
26}

Controller 中的代码是传递 body 参数,并调用 Service 的代码:

typescript
1import { Body, Controller, Post } from '@nestjs/common'
2import { UserService } from './user.service'
3import { LoginUserDto } from './LoginUserDto'
4
5@Controller('user')
6export class UserController {
7  constructor(private readonly userService: UserService) {}
8  @Post('login')
9  async login(@Body() body: LoginUserDto) {
10    return this.userService.login(body.name, body.password)
11  }
12}

用 postman 请求获得两个 token:

image-20240216165747137

下面实现登录鉴权,这里使用路由守卫来对请求做用户鉴权:

bash
1nest g guard login --flat --no-spec
typescript
1import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common'
2import { JwtService } from '@nestjs/jwt'
3import { Observable } from 'rxjs'
4
5@Injectable()
6export class LoginGuard implements CanActivate {
7  @Inject(JwtService)
8  private jwtService: JwtService
9
10  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
11    const request: Request = context.switchToHttp().getRequest()
12
13    const authorization = request.headers['authorization']
14
15    if (!authorization) {
16      throw new UnauthorizedException('用户未登录')
17    }
18
19    try {
20      const token = authorization.split(' ')[1]
21      this.jwtService.verify(token)
22
23      return true
24    } catch (e) {
25      throw new UnauthorizedException('token 失效,请重新登录')
26    }
27  }
28}

上面的代码主要是取出 authorization header 中的 jwt token,这个就是 access_token,对它做校验。

jwt 有效就可以继续访问,否则返回 token 失效,请重新登录。

然后在 AppController 加上登录鉴权:

typescript
1import { Controller, Get, Inject, UseGuards } from '@nestjs/common'
2import { AppService } from './app.service'
3import { JwtService } from '@nestjs/jwt'
4import { LoginGuard } from './login.guard'
5
6@Controller()
7@UseGuards(LoginGuard)
8export class AppController {
9  constructor(
10    private readonly appService: AppService,
11    @Inject(JwtService)
12    private readonly jwtService: JwtService,
13  ) {}
14
15  @Get()
16  getHello(): string {
17    return this.appService.getHello()
18  }
19}

现在访问 localhost:3000会提示用户未登录,因为并没有在 header 上加上 token:

image-20240216171041951

用 postman 加上 token 就可以获取到接口内容了:

image-20240216171226275

现在的 access_token 是 30 分钟后过期,30 分钟后就需要重新登录了。

接着实现用 refresh_token 刷新 access_token 的逻辑:

typescript
1  async refreshToken(refreshToken: string) {
2    try {
3      const data = this.jwtService.verify(refreshToken);
4      const user = this.db.find((user) => user.id === data.id);
5      const accessToken = this.jwtService.sign(
6        { name: user.name, id: user.id },
7        { expiresIn: '30m' },
8      );
9      return { accessToken };
10    } catch (error) {
11      throw new UnauthorizedException('token 失效,请重新登录');
12    }
13  }

这里的逻辑主要是取出 token 中的 user 信息,然后生成新的 access_token 返回。

在 Controller 中传递一下 refresh_token 参数:

typescript
1  @Get('refreshToken')
2  async refreshToken(@Query('refresh_token') refreshToken: string) {
3    return this.userService.refreshToken(refreshToken);
4  }

接着测试一下:

image-20240216173639369

带上有效的 refresh_token,能够拿到最新的 access_token 了。

这样在后端就已经实现了双 token 登录鉴权机制了。

只要 7 天内带上 refresh_token 就可以一直获得最新的 access_token 并保持登录状态。

示例代码

现在还差最后一步,前端需要在 access_token 验证失败时调用 refresh_token 接口,获取并更新 access_token ,将其设置到 header 上以通过 authorization。

前端的请求库非常多,这里仅提供一下实现思路:

  • 如果是基于 axios 请求,可以通过 axios 的 interceptors 实现请求和响应的拦截。在请求时设置 统一的 header,在响应时如果遇到 error 则查看是否为 token 过期,如果是则调用 refresh_token 接口更新最新的 access_token 并且重新请求。
  • 如果是基于 Apollo-client 的 graphql 请求,可以通过apollo-link-token-refresh处理刷新 token 的逻辑。

总结

  • JWT 方案可以让服务器重新回到无状态的情况。
  • JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
  • 最好不要将非常重要的信息写入 token 中。
  • 为了安全性考虑,JWT 往往会设置一个较短的时效。考虑到用户体验,往往会基于 refresh_token 和 access_token 实现无感刷新登录状态的效果。

参考

enjoy!!