fix(flutter): 모양이 틀린 로그인 응답의 토큰 · 다시 확인 재사용 창 · 기다리던 다시 맞추기를 고친다

앱 기반 3차 확인에서 나온 낮음 넷이다.

- 토큰은 실렸는데 모양이 틀린 로그인 응답은 응답을 뺀 실패로 던지고, 읽을 수 있는 토큰으로
  조용히 logout 한다(비밀 요청의 실패 예외는 응답 본문도 비운다)
- 확인하지 못한(unknown) 다시 확인 결과는 처음 물은 때부터 5초만 다시 쓴다
- 로그아웃 · 다른 계정 · 세션 끝 때 기다리던 다시 맞추기에 stale 로 답한다 — 새 로그인의 오류가
  앞 세션의 me 를 기다리지 않게
- postAppLogout 주석을 사실대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 13:05:46 +09:00
parent 2bb7fa4cdb
commit dd02b9621a
7 changed files with 189 additions and 21 deletions
+10 -7
View File
@@ -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),
+10 -5
View File
@@ -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<Object?>? 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;
}
+2 -1
View File
@@ -52,7 +52,8 @@ final class RefreshDiscarded extends RefreshOutcome {
/// 앱 logout 요청 — 리프레시 토큰이 있으면 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다
/// (계약 1절 "앱 로그인": 서버는 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다 — 둘 다 없으면 빈 본문 `{}`).
/// 알림은 띄우지 않는다. 실패는 `DioException`(본문을 비운 것)으로 던진다.
/// 알림은 띄우지 않는다. 실패는 `DioException` 으로 던진다 — 기본 Dio(인증 인터셉터가 있는 것)로 부르면 본문 · Bearer 를 지운 채 오고,
/// 안쪽 Dio 로 부르는 [TokenRefresher.logoutQuietly] 는 예외를 밖으로 내지 않는다.
Future<void> postAppLogout(
Dio dio, {
required String? refreshToken,
@@ -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] 면 실패를 알리지 않는다(세션 되살리기)
@@ -51,6 +51,9 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
Future<RestrictionCheck>? _rechecking;
int _epoch = 0;
/// 답을 기다리는 제한 다시 맞추기 요청 — 세션이 바뀌면(로그아웃 · 세션 끝) stale 로 답해 인터셉터를 놓아준다
final List<SessionRecheckNeeded> _pendingRechecks = <SessionRecheckNeeded>[];
LoginSecret get _secret =>
_secretHolder ??= LoginSecret(clock: ref.read(clockProvider));
@@ -71,6 +74,7 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
ref.onDispose(() {
unawaited(subscription.cancel());
_secretHolder?.clear();
_advanceEpoch();
});
final TokenStore store = ref.watch(tokenStoreProvider);
@@ -141,7 +145,7 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
await signOut(reason: SignOutReason.switchAccount);
return;
}
_epoch++;
_advanceEpoch();
_secret.clear();
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.switchAccount),
@@ -151,7 +155,7 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
/// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다.
/// 서버에 닿지 못해도 이 기기는 로그아웃 상태다.
Future<void> signOut({SignOutReason reason = SignOutReason.none}) async {
_epoch++;
_advanceEpoch();
_secret.clear();
// 메모리의 토큰은 이 부름 안에서 곧바로 지워진다(보안 저장소 읽기 · 지우기는 뒤에 — 실패해도 던지지 않는다)
final Future<SignOutTokens> clearing = _store.signOut();
@@ -177,7 +181,28 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
/// - me 가 401(액세스 토큰이 끝남)이면 갱신해 본다(같은 규칙) — 갱신이 거절되면 세션 끝이 로그인 화면으로 옮긴다 → [RestrictionCheck.stale]
/// - 그사이 세션이 바뀌었으면 결과를 쓰지 않는다 → [RestrictionCheck.stale], 확인하지 못했으면 [RestrictionCheck.unknown]
Future<RestrictionCheck> recheck() {
return _rechecking ??= _recheck().whenComplete(() => _rechecking = null);
final Future<RestrictionCheck>? running = _rechecking;
if (running != null) return running;
final Future<RestrictionCheck> started = _recheck();
_rechecking = started;
started.whenComplete(() {
if (identical(_rechecking, started)) _rechecking = null;
}).ignore();
return started;
}
/// 시도 세대를 올린다 — 앞 세션의 진행 중인 다시 맞추기는 버리고(새 세션이 그것을 함께 기다리지 않게),
/// 답을 기다리는 요청에는 stale 로 답한다(다른 계정의 새 로그인 오류가 앞 세션의 me 뒤에서 기다리지 않게)
void _advanceEpoch() {
_epoch++;
_rechecking = null;
final List<SessionRecheckNeeded> waiting = List<SessionRecheckNeeded>.of(
_pendingRechecks,
);
_pendingRechecks.clear();
for (final SessionRecheckNeeded event in waiting) {
event.reply(RestrictionCheck.stale);
}
}
Future<RestrictionCheck> _recheck() async {
@@ -334,16 +359,22 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
event.reply(RestrictionCheck.stale);
return;
}
_pendingRechecks.add(event);
void answer(RestrictionCheck check) {
_pendingRechecks.remove(event);
event.reply(check);
}
unawaited(
recheck().then<void>(
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<SessionState>(
SignedOut(reason: SignOutReason.expired),
@@ -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<Object?>(_mailState));
expect(api.recheckCount, 1);
api.clock.advance(const Duration(seconds: 3));
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.recheckCount, 1);
// 처음 물은 때부터 6초 — 3초에 다시 쓴 것으로 창이 늘었다면 여기서도 묻지 않았을 것이다
api.clock.advance(const Duration(seconds: 3));
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.recheckCount, 2);
},
);
});
}
@@ -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(<String, Object?>{
'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, <String, Object?>{
'refreshToken': 'refresh-1',
});
expect(bearerTokenOf(logouts().single), 'access-1');
},
);
test(
'다시 맞추기를 기다리는 동안 로그아웃하면 stale 로 답해 막힌 요청을 놓아주고, 새 로그인 오류가 그 뒤에서 기다리지 않는다',
() async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
final Completer<void> slowMe = Completer<void>();
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<SignedIn>());
final Future<Object> blocked = errorOf(
container.read(dioProvider).get<Object?>('/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));
},
);
});
}