diff --git a/dio/README-ZH.md b/dio/README-ZH.md index a48ec6cbb..5b6e3337e 100644 --- a/dio/README-ZH.md +++ b/dio/README-ZH.md @@ -520,7 +520,7 @@ print(response.data); // 'fake data' `csrfToken` 都为 null,所以它们都需要去请求 `csrfToken`,这会导致 `csrfToken` 被请求多次。 为了避免不必要的重复请求,可以使用 `QueuedInterceptor`, 这样只需要第一个请求处理一次即可。 -完整的示例代码请点击 [这里](../example/lib/queued_interceptor_crsftoken.dart). +完整的示例代码请点击 [这里](../example_dart/lib/queued_interceptor_crsftoken.dart). #### 日志拦截器 diff --git a/dio/README.md b/dio/README.md index 8cb01fdf2..b14bdd118 100644 --- a/dio/README.md +++ b/dio/README.md @@ -528,7 +528,7 @@ we need to request a csrfToken first, and then perform the network request, because the request csrfToken progress is asynchronous, so we need to execute this async request in request interceptor. -For the complete code see [here](../example/lib/queued_interceptor_crsftoken.dart). +For the complete code see [here](../example_dart/lib/queued_interceptor_crsftoken.dart). #### LogInterceptor diff --git a/example_dart/lib/queued_interceptor_crsftoken.dart b/example_dart/lib/queued_interceptor_crsftoken.dart index 1e60ba86c..e1cfd8175 100644 --- a/example_dart/lib/queued_interceptor_crsftoken.dart +++ b/example_dart/lib/queued_interceptor_crsftoken.dart @@ -1,164 +1,172 @@ -// ignore: dangling_library_doc_comments -/// CSRF Token Example -/// -/// Add interceptors to handle CSRF token. -/// - token update -/// - retry policy -/// -/// Scenario: -/// 1. Client access to the Server by using `GET` method. -/// 2. Server generates CSRF token and sends it to the client. -/// 3. Client make a request to the Server by using `POST` method with the CSRF token. -/// 4. If the CSRF token is invalid, the Server returns 401 status code. -/// 5. Client requests a new CSRF token and retries the request. -import 'dart:developer'; +import 'dart:convert'; +import 'dart:math'; import 'package:dio/dio.dart'; void main() async { - /// HTML example: - /// ``` html - /// - /// ``` - const String cookieKey = 'XSRF_TOKEN'; - - /// Header key for CSRF token - const String headerKey = 'X-Csrf-Token'; - - String? cachedCSRFToken; - - void printLog( - int index, - String path, - ) => - log( - ''' -#$index -- Path: '$path' -- CSRF Token: $cachedCSRFToken -''', - name: 'queued_interceptor_csrftoken.dart', - ); - - final dio = Dio() - ..options.baseUrl = 'https://httpbun.com/' - ..interceptors.addAll( - [ - /// Handles CSRF token - QueuedInterceptorsWrapper( - /// Adds CSRF token to headers, if it exists - onRequest: (requestOptions, handler) { - if (cachedCSRFToken != null) { - requestOptions.headers[headerKey] = cachedCSRFToken; - requestOptions.headers['Set-Cookie'] = - '$cookieKey=$cachedCSRFToken'; - } - return handler.next(requestOptions); - }, - - /// Update CSRF token from [response] headers, if it exists - onResponse: (response, handler) { - final token = response.headers.value(headerKey); - - if (token != null) { - cachedCSRFToken = token; - } - return handler.resolve(response); - }, - - onError: (error, handler) async { - if (error.response == null) { - return handler.next(error); - } - - /// When request fails with 401 status code, request new CSRF token - if (error.response?.statusCode == 401) { - try { - final tokenDio = Dio( - BaseOptions(baseUrl: error.requestOptions.baseUrl), - ); - - /// Generate CSRF token - /// - /// This is a MOCK REQUEST to generate a CSRF token. - /// In a real-world scenario, this should be generated by the server. - final result = await tokenDio.post( - '/response-headers', - queryParameters: { - headerKey: '94d6d1ca-fa06-468f-a25c-2f769d04c26c', - }, - ); - - if (result.statusCode == null || - result.statusCode! ~/ 100 != 2) { - throw DioException(requestOptions: result.requestOptions); - } - - final updatedToken = result.headers.value(headerKey); - if (updatedToken == null) { - throw ArgumentError.notNull(headerKey); - } - - cachedCSRFToken = updatedToken; - - return handler.next(error); - } on DioException catch (e) { - return handler.reject(e); - } - } - }, - ), - - /// Retry the request when 401 occurred - QueuedInterceptorsWrapper( - onError: (error, handler) async { - if (error.response != null && error.response!.statusCode == 401) { - final retryDio = Dio( - BaseOptions(baseUrl: error.requestOptions.baseUrl), - ); - - if (error.requestOptions.headers.containsKey(headerKey) && - error.requestOptions.headers[headerKey] != cachedCSRFToken) { - error.requestOptions.headers[headerKey] = cachedCSRFToken; - } - - /// In real-world scenario, - /// the request should be requested with [error.requestOptions] - /// using [fetch] method. - /// ``` dart - /// final result = await retryDio.fetch(error.requestOptions); - /// ``` - final result = await retryDio.get('/mix/s=200'); - - return handler.resolve(result); - } - }, - ), - ], - ); - - /// Make Requests - printLog(0, 'initial'); - - /// #1 Access to the Server - final accessResult = await dio.get( - '/response-headers', + final tokenManager = TokenManager(); - /// Pretend the Server has generated CSRF token - /// and passed it to the client. - queryParameters: { - headerKey: 'fbf07f2b-b957-4555-88a2-3d3e30e5fa64', - }, + final dio = Dio( + BaseOptions( + baseUrl: 'https://httpbun.com', + ), ); - printLog(1, accessResult.realUri.path); - - /// #2 Make a request(POST) to the Server - /// - /// Pretend the token has expired. - /// - /// Then the interceptor will request a new CSRF token - final createResult = await dio.post( - '/mix/s=401/', + + dio.interceptors.add( + QueuedInterceptorsWrapper( + onRequest: (requestOptions, handler) { + print( + ''' +[onRequest] ${requestOptions.hashCode} / time: ${DateTime.now().toIso8601String()} +\tPath: ${requestOptions.path} +\tHeaders: ${requestOptions.headers} + ''', + ); + + // In case, you have 'refresh_token' and needs to refresh your 'access_token', + // request a new 'access_token' and update from here. + + if (tokenManager.accessToken != null) { + requestOptions.headers['Authorization'] = + 'Bearer ${tokenManager.accessToken}'; + } + + return handler.next(requestOptions); + }, + onResponse: (response, handler) { + print(''' +[onResponse] ${response.requestOptions.hashCode} / time: ${DateTime.now().toIso8601String()} +\tStatus: ${response.statusCode} +\tData: ${response.data} + '''); + + return handler.resolve(response); + }, + onError: (error, handler) async { + final statusCode = error.response?.statusCode; + print( + ''' +[onError] ${error.requestOptions.hashCode} / time: ${DateTime.now().toIso8601String()} +\tStatus: $statusCode + ''', + ); + + // This example only handles the '401' status code, + // The more complex scenario should handle more status codes e.g. '403', '404', etc. + if (statusCode != 401) { + return handler.resolve(error.response!); + } + + // To prevent repeated requests to the 'Authentication Server' + // to update our 'access_token' with parallel requests, + // we need to compare with the previously requested 'access_token'. + final requestedAccessToken = + error.requestOptions.headers['Authorization']; + if (requestedAccessToken == tokenManager.accessToken) { + final tokenRefreshDio = Dio() + ..options.baseUrl = 'https://httpbun.com'; + + final response = await tokenRefreshDio.post( + '/mix/s=201/b64=${base64.encode( + jsonEncode(AuthenticationServer.generate()).codeUnits, + )}', + ); + tokenRefreshDio.close(); + + // Treat codes other than 2XX as rejected. + if (response.statusCode == null || response.statusCode! ~/ 100 != 2) { + return handler.reject(error); + } + + final body = jsonDecode(response.data) as Map; + if (!body.containsKey('access_token')) { + return handler.reject(error); + } + + final token = body['access_token'] as String; + tokenManager.setAccessToken(token, error.requestOptions.hashCode); + } + + /// The authorization has been resolved so and try again with the request. + final retried = await dio.fetch( + error.requestOptions + ..path = '/mix/s=200' + ..headers = { + 'Authorization': 'Bearer ${tokenManager.accessToken}', + }, + ); + + // Treat codes other than 2XX as rejected. + if (retried.statusCode == null || retried.statusCode! ~/ 100 != 2) { + return handler.reject(error); + } + + return handler.resolve(error.response!); + }, + ), ); - printLog(2, createResult.realUri.path); + + await Future.wait([ + dio.post('/mix/s=401'), + dio.post('/mix/s=401'), + dio.post('/mix/s=200'), + ]); + + tokenManager.printHistory(); + + dio.close(); +} + +typedef TokenHistory = ({ + String? previous, + String? current, + DateTime updatedAt, + int updatedBy, +}); + +/// Pretend as 'Authentication Server' that generates access token and refresh token +class AuthenticationServer { + static Map generate() => { + 'access_token': _generateUuid(), + 'refresh_token': _generateUuid(), + }; + + static String _generateUuid() { + final random = Random.secure(); + final bytes = List.generate(8, (_) => random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } +} + +class TokenManager { + static String? _accessToken; + + static final List _history = []; + + String? get accessToken => _accessToken; + + void printHistory() { + print('=== Token History ==='); + for (int i = 0; i < _history.length; i++) { + final entry = _history[i]; + print(''' +[$i]\tupdated token: ${entry.previous} → ${entry.current} +\tupdated at: ${entry.updatedAt.toIso8601String()} +\tupdated by: ${entry.updatedBy} + '''); + } + } + + void setAccessToken(String? token, int instanceId) { + final previous = _accessToken; + _accessToken = token; + _history.add( + ( + previous: previous, + current: _accessToken, + updatedAt: DateTime.now(), + updatedBy: instanceId, + ), + ); + } }