diff --git a/flutter/lib/core/api/auth_interceptor.dart b/flutter/lib/core/api/auth_interceptor.dart index 58864ab..a48c994 100644 --- a/flutter/lib/core/api/auth_interceptor.dart +++ b/flutter/lib/core/api/auth_interceptor.dart @@ -207,13 +207,16 @@ class AuthInterceptor extends QueuedInterceptor { return _SendAgain(_retryOptions(options, kRestrictionRetriedExtraKey)); } - final RestrictionCheck check = - _recentCheck(sentGeneration) ?? await _events.requestRecheck(); - _lastCheck = ( - generation: sentGeneration ?? _store.generation, - check: check, - at: _clock(), - ); + // 다시 쓴 결과는 시각을 새로 찍지 않는다 — 새로 물었을 때만 적는다(재사용 창이 끝없이 늘지 않게) + RestrictionCheck? check = _recentCheck(sentGeneration); + if (check == null) { + check = await _events.requestRecheck(); + _lastCheck = ( + generation: sentGeneration ?? _store.generation, + check: check, + at: _clock(), + ); + } return switch (check) { RestrictionCheck.renewed => _SendAgain( _retryOptions(options, kRestrictionRetriedExtraKey), diff --git a/flutter/lib/core/api/request_flags.dart b/flutter/lib/core/api/request_flags.dart index 3f933b9..fcfa6bb 100644 --- a/flutter/lib/core/api/request_flags.dart +++ b/flutter/lib/core/api/request_flags.dart @@ -61,15 +61,20 @@ bool isSilentFor(RequestOptions options, String code) { return false; } -/// 실패 예외에서 비밀을 지운다 — 액세스 토큰(`Authorization` 머리)과 비밀 본문([kSecretBodyExtraKey]). +/// 실패 예외에서 비밀을 지운다 — 액세스 토큰(`Authorization` 머리)과 비밀 요청([kSecretBodyExtraKey])의 요청 본문 · 응답 본문. /// /// 인증 인터셉터가 실패를 내보내기 직전에 부른다(응답의 RequestOptions 도). 같은 예외를 돌려준다. DioException withoutSecrets(DioException error) { _scrub(error.requestOptions); - final RequestOptions? responseOptions = error.response?.requestOptions; - if (responseOptions != null && - !identical(responseOptions, error.requestOptions)) { - _scrub(responseOptions); + final Response? response = error.response; + if (response != null) { + if (!identical(response.requestOptions, error.requestOptions)) { + _scrub(response.requestOptions); + } + // 비밀 요청의 응답은 토큰을 실을 수 있다(로그인 · 갱신) — 코드는 이미 `ApiException` 에 있다 + if (error.requestOptions.extra[kSecretBodyExtraKey] == true) { + response.data = null; + } } return error; } diff --git a/flutter/lib/core/api/token_refresher.dart b/flutter/lib/core/api/token_refresher.dart index 7d7e678..904d376 100644 --- a/flutter/lib/core/api/token_refresher.dart +++ b/flutter/lib/core/api/token_refresher.dart @@ -52,7 +52,8 @@ final class RefreshDiscarded extends RefreshOutcome { /// 앱 logout 요청 — 리프레시 토큰이 있으면 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다 /// (계약 1절 "앱 로그인": 서버는 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다 — 둘 다 없으면 빈 본문 `{}`). -/// 알림은 띄우지 않는다. 실패는 `DioException`(본문을 비운 것)으로 던진다. +/// 알림은 띄우지 않는다. 실패는 `DioException` 으로 던진다 — 기본 Dio(인증 인터셉터가 있는 것)로 부르면 본문 · Bearer 를 지운 채 오고, +/// 안쪽 Dio 로 부르는 [TokenRefresher.logoutQuietly] 는 예외를 밖으로 내지 않는다. Future postAppLogout( Dio dio, { required String? refreshToken, diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart index 73f20f8..bc16563 100644 --- a/flutter/lib/features/auth/data/repositories/auth_repository.dart +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; import 'package:arasystem_app/core/api/server_time.dart'; import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/storage/auth_tokens.dart'; import 'package:arasystem_app/features/auth/data/models/app_login_result.dart'; import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; import 'package:dio/dio.dart'; @@ -54,10 +57,22 @@ class AuthRepository { serverTime: serverTimeOf(response), ); } on FormatException { - // 아래에서 같은 실패로 던진다 + // 모양이 틀린 응답 — 토큰은 실려 왔으면 그 서버 세션을 조용히 끝낸다(쓰지 않을 세션이 남지 않게) + final AuthTokens? tokens = AuthTokens.tryParse(body['tokens']); + if (tokens != null) { + unawaited( + _refresher.logoutQuietly( + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + ), + ); + } } } - throw _malformed(response); + // 응답(토큰이 들었을 수 있다)을 뺀 실패로 던진다 — 요청 본문 · Bearer 도 지운다 + throw withoutSecrets( + apiFailure(response.requestOptions, kMalformedResponseCode), + ); } /// 지금 세션 — 직원 · 제한 · 유예. [silent] 면 실패를 알리지 않는다(세션 되살리기) diff --git a/flutter/lib/features/auth/presentation/providers/session_provider.dart b/flutter/lib/features/auth/presentation/providers/session_provider.dart index 9661534..a13f072 100644 --- a/flutter/lib/features/auth/presentation/providers/session_provider.dart +++ b/flutter/lib/features/auth/presentation/providers/session_provider.dart @@ -51,6 +51,9 @@ class SessionNotifier extends AsyncNotifier { Future? _rechecking; int _epoch = 0; + /// 답을 기다리는 제한 다시 맞추기 요청 — 세션이 바뀌면(로그아웃 · 세션 끝) stale 로 답해 인터셉터를 놓아준다 + final List _pendingRechecks = []; + LoginSecret get _secret => _secretHolder ??= LoginSecret(clock: ref.read(clockProvider)); @@ -71,6 +74,7 @@ class SessionNotifier extends AsyncNotifier { ref.onDispose(() { unawaited(subscription.cancel()); _secretHolder?.clear(); + _advanceEpoch(); }); final TokenStore store = ref.watch(tokenStoreProvider); @@ -141,7 +145,7 @@ class SessionNotifier extends AsyncNotifier { await signOut(reason: SignOutReason.switchAccount); return; } - _epoch++; + _advanceEpoch(); _secret.clear(); state = const AsyncData( SignedOut(reason: SignOutReason.switchAccount), @@ -151,7 +155,7 @@ class SessionNotifier extends AsyncNotifier { /// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다. /// 서버에 닿지 못해도 이 기기는 로그아웃 상태다. Future signOut({SignOutReason reason = SignOutReason.none}) async { - _epoch++; + _advanceEpoch(); _secret.clear(); // 메모리의 토큰은 이 부름 안에서 곧바로 지워진다(보안 저장소 읽기 · 지우기는 뒤에 — 실패해도 던지지 않는다) final Future clearing = _store.signOut(); @@ -177,7 +181,28 @@ class SessionNotifier extends AsyncNotifier { /// - me 가 401(액세스 토큰이 끝남)이면 갱신해 본다(같은 규칙) — 갱신이 거절되면 세션 끝이 로그인 화면으로 옮긴다 → [RestrictionCheck.stale] /// - 그사이 세션이 바뀌었으면 결과를 쓰지 않는다 → [RestrictionCheck.stale], 확인하지 못했으면 [RestrictionCheck.unknown] Future recheck() { - return _rechecking ??= _recheck().whenComplete(() => _rechecking = null); + final Future? running = _rechecking; + if (running != null) return running; + final Future started = _recheck(); + _rechecking = started; + started.whenComplete(() { + if (identical(_rechecking, started)) _rechecking = null; + }).ignore(); + return started; + } + + /// 시도 세대를 올린다 — 앞 세션의 진행 중인 다시 맞추기는 버리고(새 세션이 그것을 함께 기다리지 않게), + /// 답을 기다리는 요청에는 stale 로 답한다(다른 계정의 새 로그인 오류가 앞 세션의 me 뒤에서 기다리지 않게) + void _advanceEpoch() { + _epoch++; + _rechecking = null; + final List waiting = List.of( + _pendingRechecks, + ); + _pendingRechecks.clear(); + for (final SessionRecheckNeeded event in waiting) { + event.reply(RestrictionCheck.stale); + } } Future _recheck() async { @@ -334,16 +359,22 @@ class SessionNotifier extends AsyncNotifier { event.reply(RestrictionCheck.stale); return; } + _pendingRechecks.add(event); + void answer(RestrictionCheck check) { + _pendingRechecks.remove(event); + event.reply(check); + } + unawaited( recheck().then( - event.reply, + answer, onError: (Object _, StackTrace _) => - event.reply(RestrictionCheck.unknown), + answer(RestrictionCheck.unknown), ), ); case SessionEnded(): if (current is! SignedIn) return; - _epoch++; + _advanceEpoch(); _secret.clear(); state = const AsyncData( SignedOut(reason: SignOutReason.expired), diff --git a/flutter/test/core/api/auth_interceptor_test.dart b/flutter/test/core/api/auth_interceptor_test.dart index 4357da9..2b46a69 100644 --- a/flutter/test/core/api/auth_interceptor_test.dart +++ b/flutter/test/core/api/auth_interceptor_test.dart @@ -971,4 +971,31 @@ void main() { expect(api.server.received('GET', _mailState), hasLength(1)); }); }); + + group('3차 — 다시 쓰는 결과의 창', () { + test( + '확인하지 못한(unknown) 결과는 새로 물은 때부터 5초만 다시 쓴다(다시 쓴 결과로 창이 늘지 않는다)', + () async { + await api.signIn(); + api.recheckAnswer = RestrictionCheck.unknown; + api.server.on( + 'GET', + _mailState, + (_) => FakeResponse.fail(403, 'AUTH_010'), + ); + + await errorOf(api.dio.get(_mailState)); + expect(api.recheckCount, 1); + + api.clock.advance(const Duration(seconds: 3)); + await errorOf(api.dio.get(_mailState)); + expect(api.recheckCount, 1); + + // 처음 물은 때부터 6초 — 3초에 다시 쓴 것으로 창이 늘었다면 여기서도 묻지 않았을 것이다 + api.clock.advance(const Duration(seconds: 3)); + await errorOf(api.dio.get(_mailState)); + expect(api.recheckCount, 2); + }, + ); + }); } diff --git a/flutter/test/features/auth/session_provider_test.dart b/flutter/test/features/auth/session_provider_test.dart index 5394d26..9f0a25f 100644 --- a/flutter/test/features/auth/session_provider_test.dart +++ b/flutter/test/features/auth/session_provider_test.dart @@ -660,4 +660,90 @@ void main() { expect(h.store.hasSession, isFalse); }); }); + + group('3차 — 남은 비밀 · 기다림', () { + test( + 'signedIn 에 토큰은 실렸는데 모양이 틀리면 응답을 뺀 실패로 던지고 그 서버 세션을 조용히 끝낸다', + () async { + await start(); + await settled(); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.ok({ + 'status': 'signedIn', + 'email': 'ara.kim@arasystem.it.kr', + 'tokens': tokensJson(1), + }), + ); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + + final Object error = await errorOf( + notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'), + ); + + final DioException failure = error as DioException; + expect(apiErrorOf(failure).code, 'SYS_001'); + expect(failure.response, isNull); + expect(failure.requestOptions.data, isNull); + expect(h.store.hasSession, isFalse); + await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃'); + expect(logouts().single.data, { + 'refreshToken': 'refresh-1', + }); + expect(bearerTokenOf(logouts().single), 'access-1'); + }, + ); + + test( + '다시 맞추기를 기다리는 동안 로그아웃하면 stale 로 답해 막힌 요청을 놓아주고, 새 로그인 오류가 그 뒤에서 기다리지 않는다', + () async { + await start(refreshToken: 'refresh-1'); + refreshGives(2); + final Completer slowMe = Completer(); + int meCalls = 0; + h.server.on('GET', ApiPaths.me, (_) async { + meCalls++; + if (meCalls > 1) await slowMe.future; + return FakeResponse.ok(sessionJson()); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + h.server.on( + 'GET', + '/mailboxes', + (_) => FakeResponse.fail(403, 'AUTH_010'), + ); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(401, 'AUTH_004'), + ); + expect(await settled(), isA()); + + final Future blocked = errorOf( + container.read(dioProvider).get('/mailboxes'), + ); + await pumpEventsUntil( + () => meCalls == 2, + reason: '다시 맞추기의 me 가 서버에 닿기', + ); + + await notifier().switchAccount(); + + // 앞 세션의 me 는 아직 끝나지 않았지만 막힌 요청은 조용히 끝난다 + expect(apiErrorOf(await blocked).code, 'AUTH_010'); + expect(h.notifier.shownCodes, isEmpty); + // 다른 계정의 새 로그인 오류도 기다리지 않고 온다 + final Object loginError = await errorOf( + notifier().signIn(email: 'other@arasystem.it.kr', password: 'wrong'), + ); + expect(apiErrorOf(loginError).code, 'AUTH_004'); + expect(slowMe.isCompleted, isFalse); + + slowMe.complete(); + await pumpEventsUntil(() => true); + expect(current(), const SignedOut(reason: SignOutReason.switchAccount)); + }, + ); + }); }