generated from ttipo/project-base
fix(flutter): 모양이 틀린 로그인 응답의 토큰 · 다시 확인 재사용 창 · 기다리던 다시 맞추기를 고친다
앱 기반 3차 확인에서 나온 낮음 넷이다. - 토큰은 실렸는데 모양이 틀린 로그인 응답은 응답을 뺀 실패로 던지고, 읽을 수 있는 토큰으로 조용히 logout 한다(비밀 요청의 실패 예외는 응답 본문도 비운다) - 확인하지 못한(unknown) 다시 확인 결과는 처음 물은 때부터 5초만 다시 쓴다 - 로그아웃 · 다른 계정 · 세션 끝 때 기다리던 다시 맞추기에 stale 로 답한다 — 새 로그인의 오류가 앞 세션의 me 를 기다리지 않게 - postAppLogout 주석을 사실대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user