Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/core/network/auth_interceptor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class AuthInterceptor extends Interceptor {

final refreshToken = await _refreshTokenProvider();
if (refreshToken == null) {
await _onSessionExpired();
return handler.next(err);
}

Expand Down
25 changes: 12 additions & 13 deletions lib/feature/auth/data/flooding_authed_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
44 changes: 44 additions & 0 deletions lib/feature/auth/data/shared_token_refresher.dart
Original file line number Diff line number Diff line change
@@ -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<String>? _refreshing;

Future<String> refresh(String refreshToken) {
return _refreshing ??= _performRefresh(
refreshToken,
).whenComplete(() => _refreshing = null);
}

Future<String> _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();
161 changes: 161 additions & 0 deletions test/core/network/auth_interceptor_test.dart
Original file line number Diff line number Diff line change
@@ -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<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? 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<String> 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<dynamic>('/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<dynamic>('/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<dynamic>('/ping'), throwsA(isA<DioException>()));

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<dynamic>('/ping'), throwsA(isA<DioException>()));

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<void>.delayed(const Duration(milliseconds: 20));
currentAccessToken = 'new-token';
return currentAccessToken;
},
onSessionExpired: () async => events.add('expired'),
retryClient: dio,
),
);

final results = await Future.wait([
dio.get<dynamic>('/a'),
dio.get<dynamic>('/b'),
dio.get<dynamic>('/c'),
]);

expect(refreshCalls, 1);
expect(results.map((r) => r.statusCode), everyElement(200));
});
});
}
Loading