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:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user