G
Q
Q
and
M
E

双Token实现无感刷新登录状态

基于access_token和refresh_token实现无感刷新登录状态

双token原理

 
这是登录认证的流程:

验证通过之后,将用户信息放到jwt中。

 
访问接口的时候带上jwt,在Guard里取出来判断是否有效,jwt有效的话才能继续访问:

这种方式有个问题:
jwt是有有效期的,我们设置的是7天,实际上为了安全考虑会设置的很短,比如30分钟。
可能用户正在访问某个界面的时候,jwt突然失效了,必须重新登录。
体验比较差。

 
为了解决这个问题,服务端一般返回两个token:access_tokenrefresh_token

access_token是用来认证身份的,之前我们返回的就是这个token

refresh_token是用来刷新token的

 
服务端会返回新的 access_token和refresh_token,也就是这样的流程:

登录成功后,返回两个token:

access_token用来做登录权限:

refresh_token用来刷新,拿到新的token:

 
 
access_token设置为30分钟过期,而refresh_token设置7天过期。

这样7天内,如果access_token过期了,那就可以用refresh_token来刷新下,拿到新的token
只要不超过七天内未访问系统,那就可以一直是登录状态,可以无限续签,不需要登录。
如果超过七天内未访问系统,那么refresh_token也就过期了,这时候需要重新登录了。

 
这也是一般App采用的双token验证。

 

nest.js中的双token实现

创建一个nest项目:

nest new access_token_and_refresh_token -p npm

创建一个user模块:

nest g resource user --no-spec

安装tpyeOrm的依赖:

npm install --save @nestjs/typeorm typeorm mysql2

然后再mySql中建立对应的数据库:

CREATE DATABASE refresh_token_test DEFAULT CHARACTER SET utf8mb4;

然后建立User的entity:

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 50 })
  username: string;

  @Column({ length: 50 })
  password: string;
}

然后在appModule.ts中的entitys添加User:

 
把服务器跑起来:

npm run start:dev

user表会在mySQL中生成;

 
 

然后在UserController添加post类型的login接口:

  @Post('/login')
  login(@Body() LoginUser: LoginUserDto) {
    console.log(LoginUser);

    return 'success';
  }

创建login-user.dto.ts:

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

访问测试下:

然后实现登录逻辑
在UserService里添加login方法:

  async login(loginUserDto: LoginUserDto) {
    const user = await this.entityManyager.findOne(User, {
      where: {
        username: loginUserDto.username,
      },
    });

    if (!user) {
      throw new HttpException('用户不存在', HttpStatus.OK);
    }
    if (user.password !== loginUserDto.password) {
      throw new HttpException('密码错误', HttpStatus.OK);
    }

    return user;
  }

然后登陆成功之后我们要返回两个token;

我们引入jwt的包:

npm install --save @nestjs/jwt

然后在AppModule中引入JwtModule,设置为全局模块,指定默认的过期时间和密钥:

JwtModule.register({
  global: true,
  signOptions: {
    expiresIn: '30m'
  },
  secret: 'guang'
})

然后在UserContrller中生成两个token返回:

@Inject(JwtService)
private jwtService: JwtService;

@Post('login')
async login(@Body() loginUser: LoginUserDto) {
    const user = await this.userService.login(loginUser);

    const access_token = this.jwtService.sign({
      userId: user.id,
      username: user.username,
    }, {
      expiresIn: '30m'
    });

    const refresh_token = this.jwtService.sign({
      userId: user.id
    }, {
      expiresIn: '7d'
    });

    return {
      access_token,
      refresh_token
    }
}

access_token 里存放 userId、username,refresh_token 里只存放 userId 就好了。
过期时间一个 30 分钟,一个 7 天。

访问下user/login接口试试:

可以看到两个token都正确的返回了。

 

接下来再实现LoginGuard来做登录鉴权:

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

登录逻辑和之前文章写过的一样:

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

@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,这个就是access_token,对他做校验。
jwt有效就可以继续访问,否则就返回token失效,请重新登录。

然后再AppController添加接口并加上登录鉴权:

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

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

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

在user表中添加一条记录:

INSERT INTO `refresh_token_test`.`user` ( `username`, `password`)
  VALUES ( 'guang', '123456');

我们来测试下:


鉴权逻辑生效了!

然后我们登陆下:

把access_token复制下来,加到header里再访问一下bbb:

可以成功访问bbb;

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

 
 
这样显然体验不好,接下来实现用refresh_token来刷新的逻辑:

  @Get('refresh')
  async refresh(@Query('refresh_token') refreshToken: string) {
    try {
      const data = this.jwtService.verify(refreshToken);

      const user = await this.userService.findUserById(data.userId);

      const access_token = this.jwtService.sign(
        { userId: user.id, username: user.username },
        { expiresIn: '30m' },
      );

      const refresh_token = this.jwtService.sign(
        { userId: user.id },
        { expiresIn: '7d' },
      );

      return {
        access_token,
        refresh_token,
      };
    } catch (error) {
      throw new UnauthorizedException('token已失效,请重新登录');
    }
  }

取出refresh_token里的userId,从数据库中把user信息查出来,然后生成新的access_token和refresh_token返回。

如果jwt校验失效,就返回token已失效,请重新登录。

在UserService中实现下这个findUserById的方法:

  async findUserById(userId: number) {
    return await this.entityManyager.findOne(User, {
      where: { id: userId },
    });
  }

测试下:
带上有效的refresh_token,能够拿到新的access_token和refresh_token:

refresh_token失效或者错误时,会返回401的响应码,提示需要重新登录:

这样就实现了双token的登录鉴权机制;
只要 7 天内带上 refresh_token 来拿到新的 token,就可以一直保持登录状态。

那前端代码里访问接口的时候怎么用这俩 token 呢?

我们新建个 react 项目试一下:

 yarn create vite refresh_token_test --template vue

安装axios:

npm install --save axios

在App.tsx里访问下/aaa、/bbb接口:

import axios from 'axios';
import { useEffect, useState } from 'react';

function App() {
  const [aaa, setAaa] = useState();
  const [bbb, setBbb] = useState();

  async function query() {
    const { data: aaaData } = await axios.get('http://localhost:3000/aaa');
    const { data: bbbData } = await axios.get('http://localhost:3000/bbb');

    setAaa(aaaData);
    setBbb(bbbData);
  }
  useEffect(() => {
    query();
  }, [])
  

  return (
    <div>
      <p>{aaa}</p>
      <p>{bbb}</p>
    </div>
  );
}

export default App;

在服务端开启一下跨域支持:

把前端项目跑起来:

我们先的登录一下,拿到access_token,然后再请求的时候带上:

import axios from "axios";
import { useEffect, useState } from "react";

function App() {
  const [aaa, setAaa] = useState();
  const [bbb, setBbb] = useState();

  async function login() {
    const res = await axios.post("http://localhost:3000/user/login", {
      username: "guang",
      password: "123456",
    });

    localStorage.setItem("access_token", res.data.access_token);
    localStorage.setItem("refresh_token", res.data.refresh_token);
  }

  async function query() {
    await login();

    const { data: aaaData } = await axios.get("http://localhost:3000/aaa");
    const { data: bbbData } = await axios.get("http://localhost:3000/bbb",{
      headers:{
        Authorization:'Bearer '+localStorage.getItem('access_token')
      }
    });

    setAaa(aaaData);
    setBbb(bbbData);
  }
  useEffect(() => {
    query();
  }, []);

  return (
    <div>
      <p>{aaa}</p>
      <p>{bbb}</p>
    </div>
  );
}

export default App;

刷新下,可以看到可以请求bbb接口了:

如果很多接口都需要添加这个header,可以放到interceptors中:

测试下:

也可以正常访问;

 
当token失效的时候,要自动刷新,这个也在interceptors里做:

async function refreshToken() {
  const res = await axios.get("http://localhost:3000/user/refresh", {
    params: { refresh_token: localStorage.getItem("refresh_token") },
  });

  localStorage.setItem("access_token", res.data.access_token || "");
  localStorage.setItem("refresh_token", res.data.refresh_token || "");

  return res;
}

axios.interceptors.response.use(
  (response) => response,
  async (err) => {
    let { data, config } = err.response;

    if (data.statusCode === 401 && config.url.includes("/user/refresh")) {
      const res = await refreshToken();

      if (res.status === 200) {
        return axios(config);
      } else {
        alert("登录过期,请重新登录");
        return Promise.reject(res.data);
      }
    } else {
      return err.response;
    }
  }
);

如果返回的错误是 401 就刷新 token,这里要排除掉刷新的 url,刷新失败不继续刷新。

如果刷新接口返回的是 200,就用新 token 调用之前的接口

如果返回的是 401,那就返回这个错误。

判断下如果没有 access_token 才登录:

然后手动修改一下access_token的值,让他失效:

可以看到请求bbb失败时候,重新刷新了token,之后再次访问bbb

这样,我们就实现了 access_token 的无感刷新。

posted @ 2023-12-08 15:14  sy0313  阅读(1175)  评论(0编辑  收藏  举报