双Token实现无感刷新登录状态
基于access_token和refresh_token实现无感刷新登录状态
双token原理
这是登录认证的流程:
验证通过之后,将用户信息放到jwt
中。
访问接口的时候带上jwt
,在Guard里取出来判断是否有效,jwt
有效的话才能继续访问:
这种方式有个问题:
jwt
是有有效期的,我们设置的是7天,实际上为了安全考虑会设置的很短,比如30分钟。
可能用户正在访问某个界面的时候,jwt
突然失效了,必须重新登录。
体验比较差。
为了解决这个问题,服务端一般返回两个token:access_token
和refresh_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 的无感刷新。