diff --git a/lib/core/network/auth_interceptor.dart b/lib/core/network/auth_interceptor.dart index aaf0cf4..6bfd63b 100644 --- a/lib/core/network/auth_interceptor.dart +++ b/lib/core/network/auth_interceptor.dart @@ -89,6 +89,7 @@ class AuthInterceptor extends Interceptor { final refreshToken = await _refreshTokenProvider(); if (refreshToken == null) { + await _onSessionExpired(); return handler.next(err); } diff --git a/lib/feature/auth/data/flooding_authed_client.dart b/lib/feature/auth/data/flooding_authed_client.dart index b145379..9e27fd1 100644 --- a/lib/feature/auth/data/flooding_authed_client.dart +++ b/lib/feature/auth/data/flooding_authed_client.dart @@ -4,38 +4,37 @@ import '../../../core/network/auth_interceptor.dart' show SessionInvalidator; import '../../../core/network/flooding_api_client.dart'; import 'datasources/token_storage.dart'; import 'flooding_auth_service.dart'; -import 'models/oauth_token.dart'; +import 'shared_token_refresher.dart'; /// 인증이 필요한 Flooding 백엔드 호출용 [Dio] 팩토리. /// /// 저장된 access token 을 주입하고, 401 시 `/auth/reissue` 로 토큰을 갱신해 /// 1회 재시도한다. 갱신 실패 시 [onSessionExpired](기본: 토큰 삭제)로 세션을 종료한다. +/// +/// 갱신은 기본적으로 [defaultSharedTokenRefresher] 를 통해 앱 전역에서 +/// single-flight 로 조율된다 — 호출부마다 별도의 [Dio] 인스턴스를 만들더라도 +/// 동시에 여러 요청이 401 을 받았을 때 갱신이 중복 수행되지 않는다. class FloodingAuthedClient { FloodingAuthedClient._(); static Dio create({ TokenStorage? tokenStorage, FloodingAuthService? authService, + SharedTokenRefresher? refresher, SessionInvalidator? onSessionExpired, Dio? dio, }) { final storage = tokenStorage ?? TokenStorage(); - final auth = authService ?? FloodingAuthService(); + final tokenRefresher = + refresher ?? + ((tokenStorage != null || authService != null) + ? SharedTokenRefresher(tokenStorage: storage, authService: authService) + : defaultSharedTokenRefresher); return FloodingApiClient.create( accessToken: storage.readAccessToken, refreshToken: storage.readRefreshToken, - onRefresh: (refreshToken) async { - final tokens = await auth.reissue(refreshToken: refreshToken); - // reissue 시 refresh token 도 회전 발급되므로 둘 다 저장한다. - await storage.save( - OAuthToken( - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - ), - ); - return tokens.accessToken; - }, + onRefresh: tokenRefresher.refresh, onSessionExpired: onSessionExpired ?? storage.clear, dio: dio, ); diff --git a/lib/feature/auth/data/shared_token_refresher.dart b/lib/feature/auth/data/shared_token_refresher.dart new file mode 100644 index 0000000..3dd8f7f --- /dev/null +++ b/lib/feature/auth/data/shared_token_refresher.dart @@ -0,0 +1,44 @@ +import 'datasources/token_storage.dart'; +import 'flooding_auth_service.dart'; +import 'models/oauth_token.dart'; + +/// 앱 전역에서 refresh token 갱신을 single-flight 로 조율한다. +/// +/// [FloodingAuthedClient.create] 는 저장소·서비스별로 별도의 [Dio] 인스턴스를 +/// 만들며, 각 인스턴스의 [AuthInterceptor]는 자기 자신의 갱신 작업만 안다. +/// 이 클래스를 모든 인스턴스가 공유하면, 여러 API 호출이 동시에 401 을 받아도 +/// 갱신은 앱 전체에서 1회만 수행되어 회전(rotation)된 refresh token 을 +/// 옛 토큰으로 중복 갱신해 실패하는 경쟁 상태를 막는다. +class SharedTokenRefresher { + SharedTokenRefresher({TokenStorage? tokenStorage, FloodingAuthService? authService}) + : _storage = tokenStorage ?? TokenStorage(), + _auth = authService ?? FloodingAuthService(); + + final TokenStorage _storage; + final FloodingAuthService _auth; + + Future? _refreshing; + + Future refresh(String refreshToken) { + return _refreshing ??= _performRefresh( + refreshToken, + ).whenComplete(() => _refreshing = null); + } + + Future _performRefresh(String refreshToken) async { + final tokens = await _auth.reissue(refreshToken: refreshToken); + await _storage.save( + OAuthToken( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + ), + ); + return tokens.accessToken; + } +} + +/// 앱 전역에서 공유하는 기본 인스턴스. +/// +/// [FloodingAuthedClient.create] 의 모든 호출부가 별도 인자 없이 이 인스턴스를 +/// 공유해, 별도 배선 없이도 갱신이 앱 전체에서 조율되게 한다. +final defaultSharedTokenRefresher = SharedTokenRefresher(); diff --git a/test/core/network/auth_interceptor_test.dart b/test/core/network/auth_interceptor_test.dart new file mode 100644 index 0000000..44c8d04 --- /dev/null +++ b/test/core/network/auth_interceptor_test.dart @@ -0,0 +1,161 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flooding_v2/core/network/auth_interceptor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeAdapter implements HttpClientAdapter { + _FakeAdapter(this.handler); + + final ResponseBody Function(RequestOptions options) handler; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async => handler(options); + + @override + void close({bool force = false}) {} +} + +ResponseBody _body(int statusCode) { + return ResponseBody.fromString( + '{}', + statusCode, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); +} + +void main() { + group('AuthInterceptor', () { + late Dio dio; + late List events; + + setUp(() { + events = []; + }); + + test('onRequest 는 access token 을 Authorization 헤더로 붙인다', () async { + String? sentAuth; + dio = Dio() + ..httpClientAdapter = _FakeAdapter((options) { + sentAuth = options.headers['Authorization'] as String?; + return _body(200); + }); + dio.interceptors.add( + AuthInterceptor( + accessTokenProvider: () async => 'valid-token', + refreshTokenProvider: () async => 'refresh-token', + onRefresh: (_) async => 'new-token', + onSessionExpired: () async => events.add('expired'), + retryClient: dio, + ), + ); + + await dio.get('/ping'); + + expect(sentAuth, 'Bearer valid-token'); + }); + + test('401 + refresh 성공 시 새 토큰으로 재시도해 성공한다', () async { + var currentAccessToken = 'expired-token'; + dio = Dio() + ..httpClientAdapter = _FakeAdapter((options) { + final auth = options.headers['Authorization']; + return _body(auth == 'Bearer new-token' ? 200 : 401); + }); + dio.interceptors.add( + AuthInterceptor( + accessTokenProvider: () async => currentAccessToken, + refreshTokenProvider: () async => 'refresh-token', + onRefresh: (token) async { + events.add('refresh:$token'); + currentAccessToken = 'new-token'; + return currentAccessToken; + }, + onSessionExpired: () async => events.add('expired'), + retryClient: dio, + ), + ); + + final response = await dio.get('/ping'); + + expect(response.statusCode, 200); + expect(events, ['refresh:refresh-token']); + }); + + test('401 인데 refresh token 이 없으면 세션을 종료한다', () async { + dio = Dio()..httpClientAdapter = _FakeAdapter((options) => _body(401)); + dio.interceptors.add( + AuthInterceptor( + accessTokenProvider: () async => 'expired-token', + refreshTokenProvider: () async => null, + onRefresh: (_) async { + fail('refresh token 이 없는데 갱신이 호출되면 안 된다'); + }, + onSessionExpired: () async => events.add('expired'), + retryClient: dio, + ), + ); + + await expectLater(dio.get('/ping'), throwsA(isA())); + + expect(events, ['expired']); + }); + + test('401 + refresh 실패 시 세션을 종료한다', () async { + dio = Dio()..httpClientAdapter = _FakeAdapter((options) => _body(401)); + dio.interceptors.add( + AuthInterceptor( + accessTokenProvider: () async => 'expired-token', + refreshTokenProvider: () async => 'refresh-token', + onRefresh: (_) async => throw Exception('refresh 실패'), + onSessionExpired: () async => events.add('expired'), + retryClient: dio, + ), + ); + + await expectLater(dio.get('/ping'), throwsA(isA())); + + expect(events, ['expired']); + }); + + test('동시에 여러 요청이 401 을 받아도 갱신은 1회만 수행한다(single-flight)', () async { + var currentAccessToken = 'expired-token'; + var refreshCalls = 0; + dio = Dio() + ..httpClientAdapter = _FakeAdapter((options) { + final auth = options.headers['Authorization']; + return _body(auth == 'Bearer new-token' ? 200 : 401); + }); + dio.interceptors.add( + AuthInterceptor( + accessTokenProvider: () async => currentAccessToken, + refreshTokenProvider: () async => 'refresh-token', + onRefresh: (_) async { + refreshCalls++; + await Future.delayed(const Duration(milliseconds: 20)); + currentAccessToken = 'new-token'; + return currentAccessToken; + }, + onSessionExpired: () async => events.add('expired'), + retryClient: dio, + ), + ); + + final results = await Future.wait([ + dio.get('/a'), + dio.get('/b'), + dio.get('/c'), + ]); + + expect(refreshCalls, 1); + expect(results.map((r) => r.statusCode), everyElement(200)); + }); + }); +}