Flutter中基于Dio实现OAuth票据刷新

Flutter中基于Dio实现OAuth票据刷新

1. 背景介绍

目前项目在采用Flutter开发一款App,该工程中采用Dio框架作为网络请求框架,用户登录方面采用 OAuth2 协议。众所周知, OAuth2 协议中是用户初次登录时获取 access_token,之后当 access_token 过期后采用 refresh_token 再获取新的 access_token 。现在问题的难点是用户在使用过程中如果出现票据过期了,服务就不能正常返回数据,此时需要自动刷新票据,且刷新过程需要对用户透明。

说到这里,有几种解决思路:

  1. 简单的方式:在用户打开App时计算过期时间,始终保证票据过期前更新票据。

这种方式存在的问题是如果账号在其它终端登录了会使当前终端的票据提前失效(看服务实现,一般来说会限制为一个账号只允许一个终端登录)。

  1. 正规的方式:用户在App端访问服务时服务端对access_token校验,当票据失效后给出对应状态码提示。 App端对返回状态码解析,根据状态码提示执行刷新票据的请求,当获取到新的票据后再将用户当前访问请求重发。

下面介绍的就是第2种方式,直接刷新Token的方式。

2. Token刷新流程

Token刷新流程如下所示:

Alt text

  1. 用户通过App查询服务
  2. App端调用App服务端接口进行查询
  3. App服务端调用OAuth服务端接口校验access_token是否失效
  4. OAuth服务端校验出access_token已经失效,返回错误提示
  5. App服务端将OAuth服务端错误提示返回给App端
  6. App端收到票据过期提示后,调用OAuth服务端接口刷新access_token
  7. OAuth服务端返回新的access_token
  8. App端将新的access_token保存到本地cache
  9. App端携带新的access_token并重发查询服务请求到App服务端
  10. App服务端校验access_token通过后并将服务查询结构返回给App端
  11. App端将服务查询结构展示给用户

从图中可以看出,关键部分在于第6步刷新Token和第9步重发请求,下面逐一介绍这2处的实现。

3. 刷新Token

由于不确定票据失效时是哪个请求触发的,所以可以全局拦截请求,对请求的响应信息进行解析判断。 Dio 中有拦截器,可以达到拦截请求的目的。

1. 添加拦截器 TokenInterceptor

Dio添加拦截器

Dio dio = new Dio(); // with default Options
// Set default configs
dio.options.baseUrl = baseURL ?? ApiPath.baseURL;
dio.options.connectTimeout = 100000; // 100s
dio.options.receiveTimeout = 100000; // 100s

dio.interceptors.clear();

// 添加拦截器TokenInterceptor
dio.interceptors.add(TokenInterceptor(dio));
复制代码

TIP: 拦截器只对单个 Dio 实例生效,所以不同的 Dio 实例之间的 Interceptor 是不共享的。所以需要确保我们使用工程中的所有请求都是走的同一个 Dio 实例。

TokenInterceptor类

class TokenInterceptor extends Interceptor {
  Dio _dio;
  bool isReLogin = false;
  Queue queue = new Queue();

  TokenInterceptor(this._dio);

  @override
  Future onRequest(RequestOptions options) async {
    return options;
  }

  @override
  Future onResponse(Response response) async {
    bool needRefreshToken = _checkIfNeedRefreshToken(response);
    if (!needRefreshToken) {
      return super.onResponse(response);
    }

	// TODO 发送刷新Token请求

    return super.onResponse(response);
  }

  /// 判断是否需要刷新Token
  bool _checkIfNeedRefreshToken(Response<dynamic> response) {
    if (response.data == null || response.data.isEmpty) {
      return false;
    }

    var responseMap =
        response.data is String ? jsonDecode(response.data) : response.data;
    var head = responseMap['head'];
    if (head == null) {
      return false;
    }

    var statusCode = head['code'];
    if (statusCode != 99999 || "未发现登录用户" != responseMap['data']) {
      return false;
    }

    return true;
  }
}
复制代码

TIP: 由于我们的服务端实现时将Token校验失败的错误放在正常的response json中,所以这里在 onResponse 中进行解析处理的。一般情况下是在 onError 根据状态码进行判断处理的。

2. 发送刷新Token请求

// 先上锁,防止刷新票据时其它请求执行
dio.interceptor.request.lock();
dio.interceptors.responseLock.lock();
dio.interceptors.errorLock.lock();

/// 在这里执行刷新Token逻辑

// 释放锁
dio.interceptors.errorLock.unlock();
dio.interceptor.response.lock();
dio.interceptors.responseLock.unlock();
复制代码

网上大多数是上面给出的代码来实现刷新 Token 的,但是这种实现方式在多请求并发执行时会出现重复执行刷新 Token ,甚至一直阻塞住的问题。

当一个页面上同时发送多个请求时,采用上述方式刷新 Token 的流程如下图所示:

Alt text

显然,通过这种加锁的方式并不能应对这种多请求并发执行的场景。

3. 采用队列执行刷新Token

参考链接:github.com/flutterchin…

queue 这个包支持将多个 Future 按顺序逐一执行。目前这种情况,只需要让请求逐一进入刷新票据方式,并控制后只有一个请求能真实地刷新票据即可。具体流程如下图所示:

Alt text

onResponse

  Future onResponse(Response response) async {
    bool needRefreshToken = _checkIfNeedRefreshToken(response);
    if (!needRefreshToken) {
      return super.onResponse(response);
    }

    // 参考 https://github.com/flutterchina/dio/issues/590
    // Check for if the token were successfully refreshed
    bool success = await queue.add(() async {
      var requestToken = response.request.data['head']['token'];
      var globalToken = UserUtil.token.access_token;

      // token一致表示需要更新token,不一致则表示token已经在其它请求中更新了,这里可以防止重复更新Token
      if (requestToken == globalToken) {
	    // 注意:刷新Token用单独的 Dio 实例(避免被TokenInterceptor重复拦截),不要与其它请求共用 Dio实例。
        return await locator<UserService>()
            .refreshToken(UserUtil.user.user_name, UserUtil.user.user_sfzh);
      }
      return true;
    });

    // token refresh succeed
    if (success) {
	  // 重试请求
      return _retry(response);
    }

    // token refresh failed
    showTipDialog(
        '错误提示', '刷新用户票据失败,请重启应用!', NavigatorUtils.navigatorKey.currentContext);
    return super.onResponse(response);
  }
复制代码

refreshToken

  Future<bool> refreshToken(String userName, String idCardNo) async {
    try {
      Token token = await _doLogin(userName, idCardNo);
      bool succeed = token != null && token.access_token != null;
      if (succeed) {
        UserUtil.token.access_token = token.accessToken;
      }
      return succeed;
    } on FetchDataException catch (e, stackTrace) {
      Log.e("Refresh user token failed. userName: $userName, idCardNo: $idCardNo", e,
          stackTrace);
    }
    return false;
  }
复制代码

4. 重发请求

上面完成 Token 刷新后,只需要携带上新的 access_token 并重新发送请求就可以了。

/// 重发请求
Future<Response<dynamic>> _retry(Response<dynamic> response) {
  // 从response中读取到当前请求的相关信息
  RequestOptions options = response.request;

  // update token
  var data = options.data;
  data['head']['token'] = UserUtil.token.access_token;

  // 重发请求
  return _dio.post(options.path,
      options: options, data: data, queryParameters: options.queryParameters);
}
复制代码

5. 完整示例代码

TokenInterceptor

import 'dart:convert';

import 'package:dio/dio.dart';
import 'package:hbgaydsj/src/common/utils/navigator_utils.dart';
import 'package:hbgaydsj/src/common/utils/user_util.dart';
import 'package:hbgaydsj/src/locator.dart';
import 'package:hbgaydsj/src/modules/user/service/user_service.dart';
import 'package:hbgaydsj/src/ui/widgets/tip/tip_dialog.dart';
import 'package:queue/queue.dart';

class TokenInterceptor extends Interceptor {
  Dio _dio;
  bool isReLogin = false;
  Queue queue = new Queue();

  TokenInterceptor(this._dio);

  @override
  Future onRequest(RequestOptions options) async {
    return options;
  }

  @override
  Future onResponse(Response response) async {
    bool needRefreshToken = _checkIfNeedRefreshToken(response);
    if (!needRefreshToken) {
      return super.onResponse(response);
    }

    // 参考 https://github.com/flutterchina/dio/issues/590
    // Check for if the token were successfully refreshed
    bool success = await queue.add(() async {
      // refreshTokens returns true when it has successfully retrieved the new tokens.
      // When the Authorization header of the original request differs from the current Authorization header of the Dio instance,
      // it means the tokens where refreshed by the first request in the queue and the refreshTokens call does not have to be made.
      var requestToken = response.request.data['head']['token'];
      var globalToken = UserUtil.token.access_token;

      // token一致表示需要更新token,不一致则表示token已经在其它请求中更新了
      if (requestToken == globalToken) {
        return await locator<UserService>()
            .refreshToken(UserUtil.user.user_name, UserUtil.user.user_sfzh);
      }
      return true;
    });

    // token refresh succeed
    if (success) {
      return _retry(response);
    }

    // token refresh failed
    showTipDialog(
        '错误提示', '刷新用户票据失败,请重启应用!', NavigatorUtils.navigatorKey.currentContext);
    return super.onResponse(response);
  }

  /// 重发请求
  Future<Response<dynamic>> _retry(Response<dynamic> response) {
    RequestOptions options = response.request;

    // update token
    var data = options.data;
    data['head']['token'] = UserUtil.token.access_token;

    return _dio.post(options.path,
        options: options, data: data, queryParameters: options.queryParameters);
  }

  /// 判断是否需要刷新Token
  bool _checkIfNeedRefreshToken(Response<dynamic> response) {
    if (response.data == null || response.data.isEmpty) {
      return false;
    }

    var responseMap =
        response.data is String ? jsonDecode(response.data) : response.data;
    var head = responseMap['head'];
    if (head == null) {
      return false;
    }

    var statusCode = head['code'];
    if (statusCode != 99999 || "未发现登录用户" != responseMap['data']) {
      return false;
    }

    return true;
  }
}
复制代码

6. 总结

在做票据刷新时,网上大多数方案都是基于 dio 锁的方式 实现的,但是经常实际测试发现并不能满足要求,在这个地方花费了太多时间。

其实理清思路后,采用同步队列一样能快速解决问题,不必纠结于大多数的 dio锁 的解决方案。

7. 关于作者

作者是一个热爱学习、开源、分享,传播正能量,头发还很多的程序员-。- 热烈欢迎大家关注、点赞、评论交流!

猜你喜欢

转载自juejin.im/post/7033608244098662437