Flutter使用Dio实现无感刷新token

3,231 阅读3分钟

什么是无感刷新token?

无感刷新token是指,在token过期之前,系统自动使用RefreshToken获取新的AccessToken,从而实现Token的无感知刷新,不用跳转到登录页面重新登录,用户可以无缝继续使用。

在实现无感知刷新token的过过程中,需要考虑以下几个问题:

  • 如何判断Token是否过期?通常Token过期,请求接口会返回401的状态码。
  • 如何在Token过期时自动使用RefreshToken获取新的AccessToken?
  • 获取到新的token后如何继续执行上一次的请求?

本文是基于Flutter的Dio进行实现的。在实现过程中使用了两个Dio,一个负责正常的网络请求,一个负责刷新token。

运行环境

Flutter 3.24.3 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 2663184aa7 (9 weeks ago) • 2024-09-11 16:27:48 -0500
Engine • revision 36335019a8
Tools • Dart 3.5.3 • DevTools 2.37.3
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter

  dio: ^5.7.0

实现步骤

步骤一:获取AccessToken和RefreshToken

用户登录成功之后,服务器接口会返回用户登录信息,并将AccessToken和RefreshToken一起返回在用户信息之中,客户端将用户信息保存在一个全局变量中,并缓存在本地。AccessToken用于访问受保护的接口,RefreshToken用于获取新的AccessToken。我们采用JWT来实现认证。

class Global {
  static late User? loginUser;
}

步骤二:设置请求拦截,在请求中携带AccessToken

在这里呢,我们就需要对Dio进行二次封装,然后设置请求拦截器,在请求拦截器中给每个需要认证的接口请求头中携带AccessToken。AccessToken可以在Global.loginUser中拿到(记得将登陆用户存到本地缓存中,每次启动App的时候自动从本地缓存读取上次登录的用户)。

ApiClient

定义一个ApiClient,里面有两个Dio,分别是_httpClient_tokenDio

  • _httpClient负责正常的API接口请求。
  • _tokenDio负责无感刷新AccessToken。
  • refreshTokenSuccessToken刷新成功的回调,在此处可进行Token刷新后的其他业务逻辑处理。

代码如下所示:

class ApiClient {
  static final BaseOptions _defaultOptions = BaseOptions(
      baseUrl: 'https://yourserveraddress',
      headers: {'tenant-id': 1});

  late final Dio _httpClient;
  late final Dio _tokenDio;
  late final Function(Map)? refreshTokenSuccess;

  /// Creates an [ApiClient] with default options.
  ApiClient({this.refreshTokenSuccess}) {
    _httpClient = Dio(_defaultOptions);
    _tokenDio = Dio(_defaultOptions);
    _tokenDio.interceptors.add(TokenInterceptors());
    _tokenDio.interceptors.add(LogInterceptor());
    _httpClient.interceptors.add(LogInterceptor());
    _httpClient.interceptors.add(AuthInterceptor(tokenDio: _tokenDio, refreshTokenSuccess: refreshTokenSuccess!));
  }

  @override
  String toString() {
    return "ApiClient(_httpClient.options.headers['Authorization']: ${_httpClient.options.headers['Authorization']})";
  }

}

TokenInterceptors

TokenInterceptors定义一个Token拦截器,在onRequest中将Token设置给每个请求的接口,为下面的AuthInterceptor做准备。

class TokenInterceptors extends Interceptor {

  TokenInterceptors();

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    User? user = Global.loginUser;
    // set request header
    if (user != null) {
      options.headers['Authorization'] = 'Bearer ${user.accessToken}';
      options.headers['shop-id'] = user.shopId;
      options.headers['staff-type'] = user.type;
    }
    handler.next(options);
  }
}

AuthInterceptor

AuthInterceptor正常访问接口的拦截器,在onRequest中依将给每个需要认证的接口请求头中携带AccessToken。

onResponse中拦截返回的接口数据。

  • 如果发现接口返回Token过期的状态码_tokenExpiredCode(通常为401,具体看服务器返回),这时通过调用refreshToken方法,使用ApiClient中的_tokenDio来刷新服务器Token。
  • Token刷新成功之后,重新设置Global.loginUser后,再调用_retry(response.requestOptions)将上一个Token过期的请求重新发送。
  • 如果Token没有过期直接调用handler.next(response)返回正确的数据。
class AuthInterceptor extends QueuedInterceptorsWrapper {
  final Dio tokenDio;
  final int _tokenExpiredCode = 401;
  final Function(Map)? refreshTokenSuccess;

  AuthInterceptor({required this.tokenDio, this.refreshTokenSuccess});

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    User? user = Global.loginUser;
    // set request header
    if (user != null) {
      options.headers['Authorization'] = 'Bearer ${user.accessToken}';
      options.headers['shop-id'] = user.shopId;
      options.headers['staff-type'] = user.type;
    }
    handler.next(options);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) async {
    if (response.data['code'] == _tokenExpiredCode) {
      debugPrint('${response.requestOptions.path} not have auth need refresh token');
      bool isAuth = await refreshToken();

      debugPrint('refresh token success!');
      if (isAuth) {
        Response retryResponse = await _retry(response.requestOptions);
        debugPrint('retry path: ${response.requestOptions.path}');
        debugPrint('retry response: ${retryResponse.data.toString()}');
        handler.resolve(retryResponse);
      }
    } else {
      handler.next(response);
    }
  }

  Future<bool> refreshToken() async {
    User? user = Global.loginUser;
    if (user == null) return false;
    debugPrint("refresh token start");
    Map<String, dynamic> params = {"refreshToken": user?.refreshToken};
    Response response = await tokenDio.get('/refresh-token-v2', queryParameters: params);
    if (response.data['code'] == 0) {
      // update local token
      if (refreshTokenSuccess != null) {
        refreshTokenSuccess!(response.data);
      }
      return true;
    } else {
      return false;
    }
  }

  Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
    return tokenDio.request<dynamic>(requestOptions.path);
  }
}