Skip to content

上节我们基于双 token 实现了登录状态的无感刷新。

这当然是能实现功能的,很多公司也这样用。

但是双 token 实现起来还是挺麻烦的。

所以实际上单 token 自动续期的方式用的也非常多。

单 token 的原理也很简单,就是登录后返回 jwt,每次请求接口带上这个 jwt,然后每次访问接口返回新的 jwt,然后前端更新下本地的 jwt token

比如这个 token 是 7 天过期,那只要 7 天内访问一次系统,就会刷新 token。

7 天内不访问系统,token 过期,就需要重新登录了。

这种方案也能实现无感刷新,而且代码简单的多。

我们来写一下:

nest new single-token-refresh

进入项目,添加一个 user 模块:

nest g resource user --no-spec

加一个 login 的路由:

javascript
import { Body, Controller, Post } from '@nestjs/common';
import { UserService } from './user.service';
import { LoginUserDto } from './dto/login-user.dto';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post('login')
  async login(@Body() loginDto: LoginUserDto) {
    console.log(loginDto)
  }
}

对应的 user/dto/login-user.dto.ts

javascript
export class LoginUserDto {
    username: string;
    password: string;
}

把服务跑起来:

npm run start:dev

postman 访问下:

登录成功返回 jwt,安装下用到的包:

npm install --save @nestjs/jwt

在 AppModule 引入:

javascript
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [UserModule, 
    JwtModule.register({
      global: true,
      secret: 'guang'
    })
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

然后 login 的时候返回 jwt

javascript
import { BadRequestException, Body, Controller, Inject, Post } from '@nestjs/common';
import { UserService } from './user.service';
import { LoginUserDto } from './dto/login-user.dto';
import { JwtService } from '@nestjs/jwt';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Inject(JwtService)
  jwtService: JwtService;

  @Post('login')
  async login(@Body() loginDto: LoginUserDto) {
    if(loginDto.username !== 'guang' || loginDto.password !== '123456') {
      throw new BadRequestException('用户名或密码错误');
    }
    const jwt = this.jwtService.sign({
      username: loginDto.username
    }, {
      secret: 'guang',
      expiresIn: '7d'
    });
    return jwt;
  }
}

登录后返回 jwt,过期时间是 7 天。

访问下:

可以看到,登录后返回了 jwt。

然后加一个 Guard 来解析 jwt:

nest g guard login --flat --no-spec

登录鉴权逻辑和之前一样:

javascript
import { JwtService } from '@nestjs/jwt';
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';

@Injectable()
export class LoginGuard implements CanActivate {

  @Inject(JwtService)
  private jwtService: JwtService;

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {

    const request: Request = context.switchToHttp().getRequest();

    const authorization = request.headers.authorization;

    if(!authorization) {
      throw new UnauthorizedException('用户未登录');
    }

    try{
      const token = authorization.split(' ')[1];
      const data = this.jwtService.verify(token);

      return true;
    } catch(e) {
      throw new UnauthorizedException('token 失效,请重新登录');
    }
  }
}

取出 authorization header 中的 jwt token

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

然后在 AppController 添加个接口加上登录鉴权:

javascript
@Get('aaa')
aaa() {
    return 'aaa';
}

@Get('bbb')
@UseGuards(LoginGuard)
bbb() {
    return 'bbb';
}

aaa 接口可以直接访问,bbb 接口需要登录后才能访问。

访问 aaa 访问 bbb

登录拿到 token:

带上 token 访问 bbb:

带上 token 就可以访问需要登录的接口了。

这样就完成了登录和鉴权。

但这个 token 是有过期时间的,过期了就要重新登录了,所以要刷新 token。

上节实现了双 token 的无感刷新,今天实现单 token 刷新。

方式很简单,就是访问接口后返回新 token:

javascript
import { JwtService } from '@nestjs/jwt';
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable } from 'rxjs';

@Injectable()
export class LoginGuard implements CanActivate {

  @Inject(JwtService)
  private jwtService: JwtService;

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {

    const request: Request = context.switchToHttp().getRequest();
    const response: Response = context.switchToHttp().getResponse();

    const authorization = request.headers.authorization;

    if(!authorization) {
      throw new UnauthorizedException('用户未登录');
    }

    try{
      const token = authorization.split(' ')[1];
      const data = this.jwtService.verify(token);

      response.setHeader('token', this.jwtService.sign({
        username: data.username
      }, {
        expiresIn: '7d'
      }));

      return true;
    } catch(e) {
      throw new UnauthorizedException('token 失效,请重新登录');
    }
  }
}

试一下:

这样每次返回新 token,不就永不过期了?

而且实现还特别简单。

所以单 token 自动续期的方案用的挺多的。

我们写下前端代码:

npx create-vite single-token-refresh-frontend

进入项目,把服务跑起来:

npm install
npm run dev

浏览器访问下:

项目跑起来后,我们调用下后端接口。

首先后端要开启跨域:

然后前端页面调用下:

改下 App.tsx

javascript
import { useEffect, useState } from 'react'
import './App.css'
import axios from 'axios';

function App() {
  const [content, setContent] = useState('')

  async function query() {
    try {
      const res = await axios.get('http://localhost:3000/bbb');
      setContent(res.data);
    } catch(e: any) {
      console.log(e.response.data.message);
    }
  }

  useEffect(() => {
    query();
  }, []);

  return (
    <div style={{fontSize: '100px'}}>{content}</div>
  )
}

export default App

在页面调用 bbb 接口,把结果显示到页面。

安装 axios:

npm install --save axios

提示未登录(打印两次是 main.tsx 里的 StrictMode 导致的,去掉就好了)

我们登录下:

javascript
import { useEffect, useState } from 'react'
import './App.css'
import axios from 'axios';

function App() {
  const [content, setContent] = useState('')

  async function query() {
    try {
      const res = await axios.post('http://localhost:3000/user/login', {
        username: 'guang',
        password: '123456'
      });
      console.log(res.data);

      const res2 = await axios.get('http://localhost:3000/bbb', {
        headers: {
          Authorization: `Bearer ${res.data}`
        }
      });
      setContent(res2.data);
    } catch(e: any) {
      console.log(e.response.data.message);
    }
  }

  useEffect(() => {
    query();
  }, []);

  return (
    <div style={{fontSize: '100px'}}>{content}</div>
  )
}

export default App

现在接口就请求成功了:

这个 token 我们一般都放到 localstorage 里,每次请求都带上。

这段逻辑我们上节写过:

javascript
axios.interceptors.request.use(function (config) {
  const accessToken = localStorage.getItem('access_token');

  if(accessToken) {
    config.headers.authorization = 'Bearer ' + accessToken;
  }
  return config;
})

那单 token 如何刷新呢?

很简单,拦截器里把 header 里的新 token 更新到 localStorage 就好了:

javascript
axios.interceptors.response.use(
  (response) => {
    const newToken = response.headers['token'];
    if(newToken) {
      localStorage.setItem('token ', newToken);
    }
    return response;
  }
)

但这样有个问题:

打印下 header

没有 token

但我们明明返回了啊:

这也是跨域的问题,默认你能访问的 header 是有限的。

如果想在代码访问别的 header,需要在后端支持下,在 Access-Controll-Expose-Headers 里加上这个 header

现在就可以访问这个 header 了:

这样更新完 localStorage 里的 token,不就实现无感刷新了么?

案例代码在小册仓库:

后端代码

前端代码

总结

这节我们实现了单 token 的无感刷新,它也是在公司里用的非常多的一种方案。

好处就是简单,只要每次请求接口的时候返回新的 token,然后刷新下本地 token 就可以了。

我们在 axios 的 response 拦截器里可以轻松做到这个,比双 token 的无感刷新可简单太多了。

要注意的是在代码里访问其他 header,需要后端配置下 expose headers 才可以。

你们公司里是用双 token 还是单 token 实现登录状态无感刷新呢?

(这节写的有点问题,单 token 应该在快过期的时候返回新 token,后面优化下)