diff --git a/flutter/README.md b/flutter/README.md index 16d2584..c4e1389 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -1,17 +1,69 @@ -# tma_app +# ARA SYSTEM 앱 (Flutter) -A new Flutter project. +ARA SYSTEM 그룹웨어의 모바일 앱이다. 식별자 `kr.it.arasystem.app` · 이름 "ARA SYSTEM", 안드로이드 먼저(Play 내부 테스트). -## Getting Started +- 구조 · 구현 규칙: [`.claude/skills/flutter/SKILL.md`](../.claude/skills/flutter/SKILL.md) +- 앱 로그인(앱 전용 토큰 경로 — 서버는 아직 없다): 계약 [`docs/mail/api-contract.md`](../docs/mail/api-contract.md) 1절 "앱 로그인" +- 화면 기준: [`docs/design/app/README.md`](../docs/design/app/README.md) (보드 사본 `docs/design/webmail/App1*.dc.html`) -This project is a starting point for a Flutter application. +이 문서는 개발 PC · 배포에서 알아 둘 사실만 적는다. -A few resources to get you started if this is your first Flutter project: +## 개발 실행 -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +```bash +cd flutter +cp .env.example .env # 없으면 analyze 가 asset 경고를 낸다 +flutter pub get +flutter analyze # 0건이어야 한다 +flutter test +flutter run # 에뮬레이터 · USB 실기기 +``` -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +- **API 주소는 `.env` 의 `API_URL`** 하나다(루트 env 를 따르지 않는다). `.env` 는 앱 안에 그대로 들어가므로 비밀은 넣지 않는다. + - Android 에뮬레이터: `http://10.0.2.2:3000/api` — 에뮬레이터의 10.0.2.2 가 PC 의 127.0.0.1 이다. + - USB 실기기: `adb reverse tcp:3000 tcp:3000` 뒤 `http://localhost:3000/api`. 개발 백엔드는 `127.0.0.1` 에만 열려 있어 같은 와이파이의 휴대폰은 닿지 않는다(docker 스킬 "호스트 포트"). +- 개발 PC 의 안드로이드 도구: Android Studio 의 JDK 21 · SDK 36 · NDK 28.2(Flutter 기본)로 빌드된다. SDK 라이선스 동의(`flutter doctor --android-licenses`)는 사람이 직접 한다. + +## Flutter 판 + +**3.41.9 stable (Dart 3.11.5)** 으로 고정이다 — 개발 PC 와 CI 가 같은 판이어야 한다. 판을 바꿀 때 함께 고치는 곳: + +- `.github/workflows/ci-flutter.yml` 의 `flutter-version` (CI 는 `flutter pub get --enforce-lockfile` — lock 그대로) +- `pubspec.yaml` 의 `environment.sdk`, `intl`(`flutter_localizations` 가 SDK 마다 정확한 판으로 고정한다 — 새 SDK 의 `packages/flutter_localizations/pubspec.yaml` 값으로) +- `.claude/skills/docker/SKILL.md` 이미지 버전 표의 "Flutter SDK" 줄 · flutter 스킬 "Flutter 판" · 이 문서 +- 개발 PC 의 Flutter 는 다른 프로젝트도 함께 쓴다 — 올리기는 사용자가 정한다. + +## 빌드 + +```bash +flutter build apk --debug # 2~3분 · 약 160MB (build/ 는 git 이 무시한다) +flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명 키가 먼저다 +``` + +- release 매니페스트(`android/app/src/main/AndroidManifest.xml`)에 INTERNET 권한이 있어야 네트워크가 된다(flutter create 기본은 debug · profile 에만 둔다). + +## 배포 준비 + +- **release 서명 키는 저장소 밖의 비밀이다.** 지금 `build.gradle.kts` 의 release 는 debug 키로 서명한다 — 배포 전에 업로드 키(keystore)를 만들고 `key.properties` 로 읽게 한다. keystore · `key.properties` 는 커밋하지 않고(`android/.gitignore` 가 무시한다) 비밀번호 관리자에 둔다(잃으면 같은 앱으로 업데이트를 올릴 수 없다 — Play 앱 서명을 켜 두면 업로드 키는 다시 받을 수 있다). +- **안드로이드: Play 내부 테스트** — Play Console 계정(1회 25달러, 법인 계정은 D-U-N-S 번호), 테스터 100명까지 메일 목록으로 · 심사 없이 · 업데이트가 저절로. 식별자(`kr.it.arasystem.app`)는 올린 뒤 바꿀 수 없다. + APK 를 직접 나눠 주는 길도 있지만 Google 의 "인증된 개발자만 설치" 정책(2026-09 부터 나라별)이 한국에 언제 걸리는지 확인이 필요하다. +- **iOS: 맥 · Apple 개발자 계정이 먼저다** — 빌드 · 서명 · 시뮬레이터가 모두 맥(Xcode)에서만 되고, 배포 · 푸시(APNs 키)는 Apple Developer Program(연 99달러, 법인은 D-U-N-S)이 든다. 내부 배포는 TestFlight 내부 테스터(빌드 90일 만료). + 맥에서 처음 빌드할 때 `ios/Runner/Info.plist` 에 `CFBundleLocalizations`(`ko`)가 있는지 확인한다 — 없으면 복사 · 붙여 넣기 같은 시스템 글이 한국어로 나오지 않는다. bundle id 는 `kr.it.arasystem.app` 으로 맞춰 두었다(맥에서 확인 전). + +## 보안 메모 + +- **토큰**: 리프레시 토큰만 `flutter_secure_storage`(Android Keystore · iOS Keychain), 액세스 토큰은 메모리. 비밀번호 · 토큰을 로그에 남기지 않는다(`LogInterceptor` 를 넣지 않는다). +- **Android 백업을 껐다**(`android:allowBackup="false"` · `res/xml/data_extraction_rules.xml`) — 보안 저장소의 값은 이 기기의 Keystore 키로 암호화되어 다른 기기로 복원되면 읽지 못하고, 첫 실행 표시까지 복원되면 남은 토큰 지우기가 돌지 않는다. +- **iOS Keychain 은 앱을 지워도 남는다** — 일반 저장소(`shared_preferences`)의 첫 실행 표시가 없으면 앱을 띄우기 전에 보안 저장소를 모두 지운다(`core/storage/token_store.dart` 의 `prepare`). +- 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각)로 남은 시간을 잰다. + +## 나중에 넣을 때 주의 + +- **build_runner(freezed · json_serializable · riverpod_generator)**: 같은 폴더의 `lib/core/api/error_lexicon.g.dart`(백엔드 레지스트리에서 만든 사전 — 직접 고치지 않는다)를 build_runner 가 자기 출력으로 보고 지울 수 있다. + 넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고, 처음 한 번 돌린 뒤 사전이 그대로인지(`git diff`) 확인한다. +- **첨부 받기(스트림 · 바이트 응답)**: `responseType` 이 `bytes` · `stream` 이면 실패 응답의 본문도 JSON 으로 풀리지 않아 `toApiException` 이 HTTP 상태로만 코드를 정한다(`FILE_007` · `FILE_008` 같은 코드를 잃는다). + 받기를 만들 때 실패 본문(작다)을 끝까지 읽어 UTF-8 · JSON 으로 풀어 표준 실패의 `error.code` 를 꺼낸 뒤 코드를 정한다. 성공 본문은 지금처럼 풀지 않고 그대로 넘긴다(시험이 지킨다). + +## 라이선스 + +글꼴 Pretendard 1.3.9(SIL OFL 1.1 — `assets/licenses/pretendard-ofl.txt`)는 앱의 [설정] → [앱 정보] → [오픈소스 라이선스]에 보인다. diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 413de77..0773065 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,18 @@ + + + + android:icon="@mipmap/ic_launcher" + android:allowBackup="false" + android:fullBackupContent="false" + android:dataExtractionRules="@xml/data_extraction_rules"> + + + + + + + + + + + + + + + + + diff --git a/flutter/assets/fonts/Pretendard-Bold.otf b/flutter/assets/fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/flutter/assets/fonts/Pretendard-Bold.otf differ diff --git a/flutter/assets/fonts/Pretendard-Medium.otf b/flutter/assets/fonts/Pretendard-Medium.otf new file mode 100644 index 0000000..0575069 Binary files /dev/null and b/flutter/assets/fonts/Pretendard-Medium.otf differ diff --git a/flutter/assets/fonts/Pretendard-Regular.otf b/flutter/assets/fonts/Pretendard-Regular.otf new file mode 100644 index 0000000..08bf4cf Binary files /dev/null and b/flutter/assets/fonts/Pretendard-Regular.otf differ diff --git a/flutter/assets/fonts/Pretendard-SemiBold.otf b/flutter/assets/fonts/Pretendard-SemiBold.otf new file mode 100644 index 0000000..e7e36ab Binary files /dev/null and b/flutter/assets/fonts/Pretendard-SemiBold.otf differ diff --git a/flutter/assets/licenses/pretendard-ofl.txt b/flutter/assets/licenses/pretendard-ofl.txt new file mode 100644 index 0000000..497b88f --- /dev/null +++ b/flutter/assets/licenses/pretendard-ofl.txt @@ -0,0 +1,94 @@ +Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), +with Reserved Font Name Pretendard. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/flutter/lib/app.dart b/flutter/lib/app.dart new file mode 100644 index 0000000..48d29a1 --- /dev/null +++ b/flutter/lib/app.dart @@ -0,0 +1,31 @@ +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/router/app_router.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱이 쓰는 말 — 한국어만 (복사 · 붙여 넣기 메뉴 · 날짜 고르기 같은 시스템 글도 한국어) +const Locale kAppLocale = Locale('ko'); + +/// 앱 뿌리 — 테마 · 한국어 · 라우터 · 전역 알림 자리 +class App extends ConsumerWidget { + const App({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MaterialApp.router( + title: kAppName, + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + locale: kAppLocale, + supportedLocales: const [kAppLocale], + // Material · Cupertino · Widgets 셋 — flutter_localizations + localizationsDelegates: GlobalMaterialLocalizations.delegates, + // API 실패 알림이 이 키 하나로 뜬다 (core/api/error_notifier.dart) + scaffoldMessengerKey: ref.watch(scaffoldMessengerKeyProvider), + routerConfig: ref.watch(routerProvider), + ); + } +} diff --git a/flutter/lib/core/api/api_config.dart b/flutter/lib/core/api/api_config.dart new file mode 100644 index 0000000..91b7cb6 --- /dev/null +++ b/flutter/lib/core/api/api_config.dart @@ -0,0 +1,36 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +/// `.env` 의 API 주소 키 (`flutter/.env.example`) +const String kApiUrlEnvKey = 'API_URL'; + +/// `.env` 가 없을 때의 주소 — 백엔드 글로벌 prefix `/api` 를 포함한다. +/// Android 에뮬레이터에서는 `.env` 에 `http://10.0.2.2:3000/api` 를 넣는다. +const String kDefaultApiUrl = 'http://localhost:3000/api'; + +/// 연결 · 받기 한도 +const Duration kApiConnectTimeout = Duration(seconds: 15); +const Duration kApiReceiveTimeout = Duration(seconds: 15); + +/// API 연결 설정 +class ApiConfig { + const ApiConfig({ + required this.baseUrl, + this.connectTimeout = kApiConnectTimeout, + this.receiveTimeout = kApiReceiveTimeout, + }); + + /// `.env` 를 읽어 만든다 (main 에서 dotenv 를 먼저 읽는다 — 없으면 기본 주소) + factory ApiConfig.fromEnv() { + final String? fromEnv = dotenv.isInitialized + ? dotenv.maybeGet(kApiUrlEnvKey) + : null; + final String baseUrl = (fromEnv == null || fromEnv.isEmpty) + ? kDefaultApiUrl + : fromEnv; + return ApiConfig(baseUrl: baseUrl); + } + + final String baseUrl; + final Duration connectTimeout; + final Duration receiveTimeout; +} diff --git a/flutter/lib/core/api/api_error_mapper.dart b/flutter/lib/core/api/api_error_mapper.dart new file mode 100644 index 0000000..3c8a2c5 --- /dev/null +++ b/flutter/lib/core/api/api_error_mapper.dart @@ -0,0 +1,91 @@ +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:dio/dio.dart'; + +/// 실패를 에러 코드가 정해진 [ApiException] 으로 바꾼다 (에러 코드 규칙 "받는 방법"). +/// +/// - 표준 실패 본문(`{ success: false, error: { code, traceId } }`)이 있으면 그 코드. +/// nginx 가 직접 끝낸 실패도 이 모양이다(`message` 는 비어 올 수 있다 — 문구는 사전에서만 온다). +/// - 본문이 없으면 HTTP 상태로 거꾸로 맞춘다. 응답이 아예 없으면(연결 끊김 · 시간 초과) `SYS_001`. +/// - 세션 만료 승격(`AUTH_002`)은 여기서 하지 않는다 — 갱신을 해 본 인증 인터셉터만 정한다. +ApiException toApiException(DioException error) { + final Object? existing = error.error; + if (existing is ApiException) return existing; + + final Response? response = error.response; + if (response == null) return ApiException.fromCode(kFallbackCode); + + final ({String? code, String? traceId}) body = readErrorBody(response.data); + return ApiException.fromCode( + body.code ?? codeForStatus(response.statusCode), + traceId: body.traceId, + statusCode: response.statusCode, + ); +} + +/// 마지막 폴백 코드 — 사전의 폴백과 같다 +const String kFallbackCode = 'SYS_001'; + +/// HTTP 상태 → 에러 코드 (서버가 표준 실패 본문을 주지 못한 경우) +String codeForStatus(int? statusCode) { + switch (statusCode) { + case 401: + return 'AUTH_001'; + case 403: + return 'AUTH_003'; + case 400: + case 422: + return 'VAL_001'; + case 404: + return 'RES_001'; + case 413: + return 'FILE_002'; + case 429: + return 'REQ_001'; + default: + return kFallbackCode; + } +} + +/// 표준 실패 본문에서 코드 · 추적 ID 만 꺼낸다 +({String? code, String? traceId}) readErrorBody(Object? payload) { + if (payload is! Map) return (code: null, traceId: null); + final Object? error = payload['error']; + if (error is! Map) return (code: null, traceId: null); + final Object? code = error['code']; + final Object? traceId = error['traceId']; + return ( + code: code is String && code.isNotEmpty ? code : null, + traceId: traceId is String && traceId.isNotEmpty ? traceId : null, + ); +} + +/// 실패에서 에러 코드만 — 화면 · 노티파이어가 `code` 로 가를 때 +String? apiErrorCodeOf(Object error) { + if (error is ApiException) return error.code; + if (error is DioException) return toApiException(error).code; + return null; +} + +/// 앱이 스스로 정한 실패도 같은 꼴(`DioException` · `error` 에 [ApiException])로 만든다. +/// +/// 저장소 · 세션 노티파이어의 실패는 늘 이 꼴이다 — 응답 모양이 계약과 다르거나(서버 쪽 문제) +/// 받은 토큰을 보안 저장소에 두지 못한 경우도. 화면은 `on DioException` 하나로 받아 `apiErrorCodeOf` 로 가른다. +DioException apiFailure( + RequestOptions requestOptions, + String code, { + Response? response, +}) { + final ({String? code, String? traceId}) body = readErrorBody(response?.data); + return DioException( + requestOptions: requestOptions, + response: response, + type: response == null + ? DioExceptionType.unknown + : DioExceptionType.badResponse, + error: ApiException.fromCode( + code, + traceId: body.traceId, + statusCode: response?.statusCode, + ), + ); +} diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart new file mode 100644 index 0000000..f1ceb8d --- /dev/null +++ b/flutter/lib/core/api/api_paths.dart @@ -0,0 +1,17 @@ +/// API 경로 — baseUrl(`…/api`) 뒤에 붙는다. 경로는 대소문자를 가린다(계약 머리말). +abstract final class ApiPaths { + // ── 앱 로그인 (계약 `docs/mail/api-contract.md` 1절 "앱 로그인" — 서버는 아직 없다) ── + static const String appLogin = '/auth/app/login'; + static const String appRefresh = '/auth/app/refresh'; + static const String appLogout = '/auth/app/logout'; + + /// 세션 확인 — 웹과 같은 경로를 Bearer 로 부른다 + static const String me = '/auth/me'; + + /// 401 에도 갱신하지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일") + static const Set noRefresh = { + appLogin, + appRefresh, + appLogout, + }; +} diff --git a/flutter/lib/core/api/auth_interceptor.dart b/flutter/lib/core/api/auth_interceptor.dart new file mode 100644 index 0000000..58864ab --- /dev/null +++ b/flutter/lib/core/api/auth_interceptor.dart @@ -0,0 +1,371 @@ +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.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/session_events.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:dio/dio.dart'; + +/// 세션 제한 코드 — 제한된 세션으로 허용하지 않은 API 를 불렀다 (계약 6절) +const String kRestrictedSessionCode = 'AUTH_010'; + +/// 확인하지 못한(unknown) 제한 다시 맞추기를 같은 토큰 세대에서 다시 쓰는 시간 — 장애 동안 줄 선 `AUTH_010` 마다 묻지 않게 +const Duration kRestrictionCheckReuse = Duration(seconds: 5); + +/// 인증 인터셉터 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일". +/// +/// - Bearer 를 붙인다. 액세스 토큰이 없거나 곧 끝나면 보내기 전에 갱신한다. +/// - 401 이면 갱신 한 번 뒤 원래 요청을 한 번 다시 보낸다(앱 login · refresh · logout 은 제외). +/// - 403 `AUTH_010` 이면 세션 노티파이어에 제한을 다시 맞춰 달라고 하고, 옛 토큰이었으면(`renewed`) 한 번 다시 보낸다. +/// 제한을 확인했거나 세션이 바뀌었으면 알리지 않고, 확인하지 못했거나 다시 보낸 요청도 막히면 알린다(웹 `recheckRestriction` 과 같은 결). +/// 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다 — 그사이 새 토큰을 받았으면 묻지 않고 다시 보내고, 제한을 확인한 결과는 그 세대 동안 다시 쓴다. +/// - **다시 보낸 요청의 실패도 첫 요청과 같은 규칙을 탄다**(웹은 다시 보낸 요청이 인터셉터를 다시 지난다 — 401 · `AUTH_010` 마다 한 번씩만 다시 보낸다). +/// - **그 요청을 보낸 세션이 끝났으면**(로그아웃 · 세션 끝 — 세션 세대가 다르다) 어떤 실패든 조용히 끝낸다. +/// 다른 직원이 그사이 로그인했어도 그 토큰으로 다시 보내지 않는다. 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. +/// +/// `QueuedInterceptor` 라 요청 · 오류를 차례로 다룬다. 갱신 · 세션 확인 · 다시 보내기는 이 인터셉터가 없는 [_retryDio] 로 한다 — +/// 같은 Dio 로 보내면 그 요청의 오류가 지금 처리 중인 오류 뒤에 줄을 서서 서로 기다리게 된다. +/// 처리기(handler)는 뜻밖의 예외에도 꼭 한 번 부른다 — 부르지 않으면 Dio 의 줄이 영구히 선다. +/// 알림을 건너뛸 실패는 `handler.reject`, 알릴 실패는 `handler.next` 로 끝내고, 내보내기 전에 토큰 · 비밀 본문을 지운다([withoutSecrets]). +class AuthInterceptor extends QueuedInterceptor { + AuthInterceptor({ + required TokenStore store, + required TokenRefresher refresher, + required Dio retryDio, + required SessionEventBus events, + Clock clock = systemClock, + }) : _store = store, + _refresher = refresher, + _retryDio = retryDio, + _events = events, + _clock = clock; + + final TokenStore _store; + final TokenRefresher _refresher; + final Dio _retryDio; + final SessionEventBus _events; + final Clock _clock; + + ({int generation, RestrictionCheck check, DateTime at})? _lastCheck; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + DioException? failure; + bool notify = true; + try { + if (!_isNoRefreshPath(options)) { + // 액세스 토큰이 없거나(앱을 새로 띄움) 곧 끝나면 보내기 전에 갱신한다 — 401 을 한 번 덜 본다 + if (_store.hasSession && !_store.hasUsableAccessToken) { + final RefreshOutcome outcome = await _refresher.refresh( + sentGeneration: _store.generation, + ); + final ApiException? refreshFailure = _failureOf(outcome); + if (refreshFailure != null) { + failure = _rejectedBeforeSend(options, refreshFailure); + // 그사이 로그아웃했으면(Discarded) 알림 인터셉터를 건너뛴다 + notify = outcome is! RefreshDiscarded; + } + } + if (failure == null) _attach(options); + } + } on Object catch (error, stackTrace) { + failure = _unexpected(options, error, stackTrace); + } + + if (failure == null) { + handler.next(options); + } else { + handler.reject(withoutSecrets(failure), notify); + } + } + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + _Outcome outcome; + try { + outcome = await _settle(err); + } on Object catch (error, stackTrace) { + outcome = _Notify(_unexpected(err.requestOptions, error, stackTrace)); + } + + switch (outcome) { + case _Resolve(:final Response response): + handler.resolve(response); + case _Notify(:final DioException error): + handler.next(withoutSecrets(error)); + case _Quiet(:final DioException error): + handler.reject(withoutSecrets(error)); + } + } + + /// 실패를 끝까지 다룬다 — 다시 보낸 요청이 실패하면 그 실패도 같은 규칙으로 다시 본다(401 · AUTH_010 마다 한 번씩만 다시 보내 끝난다) + Future<_Outcome> _settle(DioException first) async { + DioException current = first; + while (true) { + final _Step step = await _step(current); + switch (step) { + case _Finish(:final _Outcome outcome): + return outcome; + case _SendAgain(:final RequestOptions options): + try { + return _Resolve(await _retryDio.fetch(options)); + } on DioException catch (retryError) { + current = retryError; + } + } + } + } + + Future<_Step> _step(DioException err) async { + final RequestOptions options = err.requestOptions; + final ApiException apiError = toApiException(err); + final DioException failed = err.error is ApiException + ? err + : err.copyWith(error: apiError); + final bool unauthorized = err.response?.statusCode == 401; + final bool restricted = apiError.code == kRestrictedSessionCode; + + // 그 요청을 보낸 세션이 끝났다 — 로그아웃 · 세션 끝 뒤에 돌아온 응답은 어떤 것이든 조용히 끝낸다 + final Object? sentSession = options.extra[kSessionGenerationExtraKey]; + if (sentSession is int && sentSession != _store.sessionGeneration) { + return _Finish( + _Quiet(_ofEndedSession(failed, sentSession, unauthorized)), + ); + } + + if (options.extra[kAuthHandledExtraKey] == true || + (!unauthorized && !restricted) || + _isNoRefreshPath(options)) { + return _Finish(_Notify(failed)); + } + + if (!_store.hasSession) { + // 토큰 없이 보낸 요청(로그인 전)의 401 은 `AUTH_001` 그대로 알린다 + if (!_sentWithToken(options)) return _Finish(_Notify(failed)); + // 세션 세대를 싣지 않은 채 세션이 끝났다 — 끝난 세션의 응답처럼 다룬다 + return _Finish( + _Quiet(_ofEndedSession(failed, _store.sessionGeneration, unauthorized)), + ); + } + + return restricted + ? _stepRestricted(failed) + : _stepUnauthorized(failed, sentSession); + } + + /// 401 — 갱신 한 번 뒤 한 번 다시 보낸다. 다시 보낸 요청도 401 이면 세션이 끊겼다 + Future<_Step> _stepUnauthorized( + DioException failed, + Object? sentSession, + ) async { + final RequestOptions options = failed.requestOptions; + final ApiException apiError = failed.error! as ApiException; + if (options.extra[kAuthRetriedExtraKey] == true) { + // 새 토큰도 거절됐다 — 세션이 끊겼다(다른 곳에서 로그아웃 · 비밀번호 바꾸기 등) + final ApiException expired = await _refresher.endSession( + traceId: apiError.traceId, + sessionGeneration: sentSession is int ? sentSession : null, + ); + return _Finish(_Notify(failed.copyWith(error: expired))); + } + + final Object? sentGeneration = options.extra[kAuthGenerationExtraKey]; + final RefreshOutcome outcome = await _refresher.refresh( + sentGeneration: sentGeneration is int ? sentGeneration : null, + ); + final ApiException? refreshFailure = _failureOf(outcome); + if (refreshFailure == null) { + return _SendAgain(_retryOptions(options, kAuthRetriedExtraKey)); + } + final DioException withFailure = failed.copyWith(error: refreshFailure); + return _Finish( + outcome is RefreshDiscarded ? _Quiet(withFailure) : _Notify(withFailure), + ); + } + + /// 403 `AUTH_010` — 제한을 다시 맞춘다. 옛 토큰이었으면 새 토큰으로 한 번만 다시 보낸다. + Future<_Step> _stepRestricted(DioException failed) async { + final RequestOptions options = failed.requestOptions; + if (options.extra[kRestrictionRetriedExtraKey] == true) { + // 다시 보낸 요청도 막혔다 — 다시 맞추지 않고 알린다 + return _Finish(_Notify(failed)); + } + final Object? sent = options.extra[kAuthGenerationExtraKey]; + final int? sentGeneration = sent is int ? sent : null; + if (sentGeneration != null && + sentGeneration != _store.generation && + _store.hasUsableAccessToken) { + // 그사이 다른 요청이 제한을 다시 맞춰 새 토큰을 받았다 — 묻지 않고 다시 보낸다 + return _SendAgain(_retryOptions(options, kRestrictionRetriedExtraKey)); + } + + final RestrictionCheck check = + _recentCheck(sentGeneration) ?? await _events.requestRecheck(); + _lastCheck = ( + generation: sentGeneration ?? _store.generation, + check: check, + at: _clock(), + ); + return switch (check) { + RestrictionCheck.renewed => _SendAgain( + _retryOptions(options, kRestrictionRetriedExtraKey), + ), + // 화면이 그 단계로 간다 · 세션이 바뀌었다 — 알리지 않는다 + RestrictionCheck.restricted || + RestrictionCheck.stale => _Finish(_Quiet(failed)), + RestrictionCheck.unknown => _Finish(_Notify(failed)), + }; + } + + /// 같은 토큰 세대로 방금 맞춘 결과 — 제한을 확인한 것은 그 세대 동안, 확인하지 못한 것은 [kRestrictionCheckReuse] 동안 다시 쓴다 + RestrictionCheck? _recentCheck(int? sentGeneration) { + final ({int generation, RestrictionCheck check, DateTime at})? last = + _lastCheck; + if (last == null || sentGeneration == null) return null; + if (last.generation != sentGeneration) return null; + switch (last.check) { + case RestrictionCheck.restricted: + return last.check; + case RestrictionCheck.unknown: + final Duration since = _clock().difference(last.at); + return !since.isNegative && since < kRestrictionCheckReuse + ? last.check + : null; + case RestrictionCheck.renewed: + case RestrictionCheck.stale: + return null; + } + } + + /// 끝난 세션의 실패 — 401 은 사용자가 끝냈으면 `AUTH_001` 그대로(웹 `endedBySignOut` 과 같다), 세션이 끝났으면 `AUTH_002` + DioException _ofEndedSession( + DioException failed, + int sessionGeneration, + bool unauthorized, + ) { + if (!unauthorized) return failed; + final ApiException apiError = failed.error! as ApiException; + final bool byUser = + _store.endedByUser(sessionGeneration) ?? _store.signedOutByUser; + return failed.copyWith( + error: ApiException.fromCode( + byUser ? 'AUTH_001' : kSessionExpiredCode, + traceId: apiError.traceId, + statusCode: apiError.statusCode, + ), + ); + } + + /// 다시 보낼 요청 — 새 토큰을 붙이고 다시 보냈다는 표시([flag])를 단다 + RequestOptions _retryOptions(RequestOptions options, String flag) { + final RequestOptions retry = options.copyWith(); + retry.extra[flag] = true; + final Object? data = retry.data; + if (data is FormData) { + // 한 번 보낸 FormData 는 다시 쓸 수 없다 + retry.data = data.clone(); + } + _attach(retry); + return retry; + } + + void _attach(RequestOptions options) { + final String? token = _store.accessToken; + if (token != null) { + options.headers[kAuthorizationHeader] = bearerOf(token); + } else { + options.headers.remove(kAuthorizationHeader); + } + options.extra[kAuthGenerationExtraKey] = _store.generation; + options.extra[kSessionGenerationExtraKey] = _store.sessionGeneration; + } + + bool _sentWithToken(RequestOptions options) => + options.headers.containsKey(kAuthorizationHeader); + + bool _isNoRefreshPath(RequestOptions options) => + ApiPaths.noRefresh.contains(options.path); + + DioException _rejectedBeforeSend(RequestOptions options, ApiException error) { + options.extra[kAuthHandledExtraKey] = true; + return DioException( + requestOptions: options, + type: DioExceptionType.unknown, + error: error, + ); + } + + /// 뜻밖의 예외 — 처리기를 꼭 부르려고 `SYS_001` 실패로 바꾼다 + DioException _unexpected( + RequestOptions options, + Object error, + StackTrace stackTrace, + ) { + options.extra[kAuthHandledExtraKey] = true; + return DioException( + requestOptions: options, + type: DioExceptionType.unknown, + error: ApiException.fromCode(kFallbackCode), + stackTrace: stackTrace, + ); + } + + /// 갱신 결과가 실패면 원래 요청에 실을 예외, 성공이면 null + ApiException? _failureOf(RefreshOutcome outcome) { + return switch (outcome) { + RefreshSucceeded() => null, + RefreshRejected(:final ApiException error) => error, + RefreshFailed(:final ApiException error) => error, + // 로그아웃한 뒤라 알리지 않는다 — 부른 쪽은 로그인 전 상태로 본다 + RefreshDiscarded() => ApiException.fromCode('AUTH_001'), + }; + } +} + +/// 실패를 다룬 끝 — 응답으로 바꾸기 · 알리기 · 조용히 끝내기 +sealed class _Outcome { + const _Outcome(); +} + +final class _Resolve extends _Outcome { + const _Resolve(this.response); + + final Response response; +} + +final class _Notify extends _Outcome { + const _Notify(this.error); + + final DioException error; +} + +final class _Quiet extends _Outcome { + const _Quiet(this.error); + + final DioException error; +} + +/// 한 차례 판단 — 끝내거나, 다시 보낼 요청 +sealed class _Step { + const _Step(); +} + +final class _Finish extends _Step { + const _Finish(this.outcome); + + final _Outcome outcome; +} + +final class _SendAgain extends _Step { + const _SendAgain(this.options); + + final RequestOptions options; +} diff --git a/flutter/lib/core/api/dio_client.dart b/flutter/lib/core/api/dio_client.dart index 13141bb..39b1512 100644 --- a/flutter/lib/core/api/dio_client.dart +++ b/flutter/lib/core/api/dio_client.dart @@ -1,148 +1,73 @@ +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/auth_interceptor.dart'; +import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/api/response_interceptor.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; import 'package:dio/dio.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import 'api_exception.dart'; - -/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다. -const String kJwtTokenKey = 'jwt_token'; - -const Duration _kConnectTimeout = Duration(seconds: 15); -const Duration _kReceiveTimeout = Duration(seconds: 15); +/// 앱의 HTTP 클라이언트 — Dio 둘을 같은 설정으로 만든다. +/// +/// - [dio]: 화면 · 저장소가 쓴다. 인터셉터 차례는 +/// [ResponseInterceptor](언래핑 · 코드 정규화) → [AuthInterceptor](Bearer · 갱신 · 다시 보내기 · `AUTH_010`) +/// → [ErrorNotifyInterceptor](알림 — 맨 끝). +/// - 안쪽 Dio(갱신 · 다시 보내기 전용): [ResponseInterceptor] 만 — 인증 인터셉터 줄을 다시 타지 않는다. +/// +/// 로그 인터셉터(`LogInterceptor`)는 넣지 않는다 — 비밀번호 · 토큰이 본문 · 머리글에 실린다(로깅 규칙). class DioClient { - static final DioClient _instance = DioClient._internal(); - late Dio _dio; - final FlutterSecureStorage _storage = const FlutterSecureStorage(); + DioClient._(this.dio, this.refresher); - factory DioClient() { - return _instance; - } + factory DioClient.create({ + required ApiConfig config, + required TokenStore tokenStore, + required SessionEventBus events, + required ErrorNotifier notifier, + required String userAgent, + Clock clock = systemClock, + HttpClientAdapter? httpClientAdapter, + }) { + final BaseOptions options = BaseOptions( + baseUrl: config.baseUrl, + connectTimeout: config.connectTimeout, + receiveTimeout: config.receiveTimeout, + headers: {'User-Agent': userAgent}, + ); - DioClient._internal() { - // 백엔드 글로벌 prefix(/api) 와 일치 - final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api'; + final Dio bare = Dio(options.copyWith()); + final Dio main = Dio(options); + if (httpClientAdapter != null) { + bare.httpClientAdapter = httpClientAdapter; + main.httpClientAdapter = httpClientAdapter; + } - _dio = Dio(BaseOptions( - baseUrl: baseUrl, - connectTimeout: _kConnectTimeout, - receiveTimeout: _kReceiveTimeout, - )); + final TokenRefresher refresher = TokenRefresher( + bareDio: bare, + store: tokenStore, + events: events, + clock: clock, + ); - _dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) async { - // Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착 - final token = await _storage.read(key: kJwtTokenKey); - if (token != null) { - options.headers['Authorization'] = 'Bearer $token'; - } - return handler.next(options); - }, - onResponse: (response, handler) { - final payload = response.data; - - if (payload is Map && - payload.containsKey('success')) { - if (payload['success'] == true) { - // Global Response Wrapper: 데이터 Unwrap - response.data = payload['data']; - return handler.next(response); - } - - // HTTP 200 OK 하에서의 비즈니스 실패 (백엔드는 4xx 로 내리지만 방어적으로 처리) - final error = _readErrorObject(payload); - return handler.reject( - DioException( - requestOptions: response.requestOptions, - error: ApiException.fromCode( - error.code ?? 'BIZ_001', - traceId: error.traceId, - statusCode: response.statusCode, - ), - type: DioExceptionType.badResponse, - ), - ); - } - return handler.next(response); - }, - onError: (DioException e, handler) async { - final apiException = await _toApiException(e); - - // 화면에서 e.error as ApiException 으로 코드/메시지를 함께 사용한다. - return handler.next(e.copyWith(error: apiException)); - }, + bare.interceptors.add(ResponseInterceptor()); + main.interceptors.addAll([ + ResponseInterceptor(), + AuthInterceptor( + store: tokenStore, + refresher: refresher, + retryDio: bare, + events: events, + clock: clock, ), - ); + ErrorNotifyInterceptor(notifier), + ]); + + return DioClient._(main, refresher); } - /// DioException 을 에러 코드가 확정된 ApiException 으로 정규화한다. - Future _toApiException(DioException e) async { - final response = e.response; + /// 화면 · 저장소가 쓰는 Dio + final Dio dio; - if (response == null) { - // 네트워크 단절·타임아웃 등 서버 응답 자체가 없는 경우 - return ApiException.fromCode('SYS_001'); - } - - String? traceId; - String? code; - - if (response.data is Map) { - final error = _readErrorObject(response.data as Map); - code = error.code; - traceId = error.traceId; - } - - code ??= _resolveCodeByStatus(response.statusCode); - - // 저장된 토큰이 있는데 401 이면 "세션 만료"로 간주한다. - // AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다. - if (code == 'AUTH_001' && await _storage.read(key: kJwtTokenKey) != null) { - await _storage.delete(key: kJwtTokenKey); - code = 'AUTH_002'; - } - - return ApiException.fromCode( - code, - traceId: traceId, - statusCode: response.statusCode, - ); - } - - /// HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우) - String _resolveCodeByStatus(int? statusCode) { - switch (statusCode) { - case 401: - return 'AUTH_001'; - case 403: - return 'AUTH_003'; - case 400: - case 422: - return 'VAL_001'; - case 404: - return 'RES_001'; - case 429: - return 'REQ_001'; - default: - return 'SYS_001'; - } - } - - /// 표준 에러 객체({ code, message, traceId })에서 필요한 값만 꺼낸다. - ({String? code, String? traceId}) _readErrorObject( - Map payload, - ) { - final error = payload['error']; - if (error is! Map) return (code: null, traceId: null); - - final code = error['code']; - final traceId = error['traceId']; - return ( - code: code is String ? code : null, - traceId: traceId is String ? traceId : null, - ); - } - - Dio get dio => _dio; + /// 인증 인터셉터 줄 밖의 인증 요청(갱신 · 세션 확인 · 조용한 로그아웃) — 세션 노티파이어도 이것을 지난다(갱신은 한 번에 하나) + final TokenRefresher refresher; } diff --git a/flutter/lib/core/api/dio_provider.dart b/flutter/lib/core/api/dio_provider.dart index 1771e8b..18e68a0 100644 --- a/flutter/lib/core/api/dio_provider.dart +++ b/flutter/lib/core/api/dio_provider.dart @@ -1,9 +1,75 @@ +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/dio_client.dart'; +import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'dio_client.dart'; -// 전역 Dio Provider — 리포지토리/데이터소스에서 ref.watch(dioProvider) 로 주입받는다. -// 인터셉터(토큰 첨부·응답 언래핑·에러 매핑)가 적용된 DioClient.dio 를 노출한다. -final dioProvider = Provider((ref) { - return DioClient().dio; -}); +/// 지금 시각 — 시험은 손으로 돌리는 시계로 덮어쓴다 +final Provider clockProvider = Provider((Ref ref) => systemClock); + +/// API 연결 설정 (`.env` 의 `API_URL`) +final Provider apiConfigProvider = Provider( + (Ref ref) => ApiConfig.fromEnv(), +); + +/// User-Agent — main 이 기기 정보로 만든 값으로 덮어쓴다(`loadAppDeviceInfo`). 덮지 않으면 제품 이름만. +final Provider userAgentProvider = Provider( + (Ref ref) => kUserAgentProduct, +); + +/// 세션 알림 통로 — 인터셉터가 알리고 세션 노티파이어가 듣는다 +final Provider sessionEventsProvider = + Provider((Ref ref) { + final SessionEventBus bus = SessionEventBus(); + ref.onDispose(bus.close); + return bus; + }); + +/// 전역 ScaffoldMessenger 키 — `MaterialApp.router(scaffoldMessengerKey:)` 에 건다 +final Provider> scaffoldMessengerKeyProvider = + Provider>( + (Ref ref) => GlobalKey(), + ); + +/// API 실패 알림 (같은 코드는 3초 안에 다시 띄우지 않는다) +final Provider errorNotifierProvider = Provider( + (Ref ref) => ErrorNotifier( + messengerKey: ref.watch(scaffoldMessengerKeyProvider), + clock: ref.watch(clockProvider), + ), +); + +/// 시험이 가짜 서버(HttpClientAdapter)를 끼우는 자리 — 앱에서는 null(실제 네트워크) +final Provider httpClientAdapterProvider = + Provider((Ref ref) => null); + +/// HTTP 클라이언트 — Dio 와 토큰 갱신을 함께 만든다 +final Provider dioClientProvider = Provider( + (Ref ref) => DioClient.create( + config: ref.watch(apiConfigProvider), + tokenStore: ref.watch(tokenStoreProvider), + events: ref.watch(sessionEventsProvider), + notifier: ref.watch(errorNotifierProvider), + userAgent: ref.watch(userAgentProvider), + clock: ref.watch(clockProvider), + httpClientAdapter: ref.watch(httpClientAdapterProvider), + ), +); + +/// 전역 Dio — 저장소는 `ref.watch(dioProvider)` 로 받는다. +/// 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸려 있다. +final Provider dioProvider = Provider( + (Ref ref) => ref.watch(dioClientProvider).dio, +); + +/// 인증 인터셉터 줄 밖의 인증 요청(갱신은 한 번에 하나) — 인증 저장소 · 세션 노티파이어도 이것을 지난다 +final Provider tokenRefresherProvider = + Provider( + (Ref ref) => ref.watch(dioClientProvider).refresher, + ); diff --git a/flutter/lib/core/api/error_notifier.dart b/flutter/lib/core/api/error_notifier.dart new file mode 100644 index 0000000..f24b587 --- /dev/null +++ b/flutter/lib/core/api/error_notifier.dart @@ -0,0 +1,73 @@ +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; + +/// 같은 코드의 알림을 다시 띄우지 않는 시간 — 웹 `useApi.ts` 의 `DUPLICATE_ALERT_WINDOW_MS` 와 같다 +const Duration kDuplicateAlertWindow = Duration(seconds: 3); + +/// API 실패 알림 — 앱에 하나, 전역 `ScaffoldMessenger` 키 하나로 띄운다(웹 `showErrorUI` 자리). +/// +/// 문구는 에러 코드 사전에서만 온다([ApiException.message]). 다른 알림 모양으로 바꿀 때도 [show] 하나만 바꾼다. +class ErrorNotifier { + ErrorNotifier({ + required GlobalKey messengerKey, + Clock clock = systemClock, + Duration duplicateWindow = kDuplicateAlertWindow, + }) : _messengerKey = messengerKey, + _clock = clock, + _duplicateWindow = duplicateWindow; + + final GlobalKey _messengerKey; + final Clock _clock; + final Duration _duplicateWindow; + + String? _lastCode; + DateTime? _lastShownAt; + + /// 알린다 — 같은 코드를 [kDuplicateAlertWindow] 안에 다시 받으면 띄우지 않는다 + void show(ApiException error) { + final DateTime now = _clock(); + final DateTime? lastShownAt = _lastShownAt; + if (_lastCode == error.code && + lastShownAt != null && + now.difference(lastShownAt) < _duplicateWindow) { + return; + } + _lastCode = error.code; + _lastShownAt = now; + display(error); + } + + /// 실제로 띄운다 — 스낵바 한 줄(앞의 것은 닫는다). 모양을 바꿀 때는 이것만 바꾼다 + @protected + void display(ApiException error) { + final ScaffoldMessengerState? messenger = _messengerKey.currentState; + if (messenger == null) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(error.message))); + } +} + +/// 실패를 알리는 인터셉터 — 인터셉터 줄의 맨 끝에 둔다(갱신 · 다시 보내기가 끝난 뒤의 결과만 알린다). +/// +/// 알리지 않는 것: 요청의 `silent`([kSilentExtraKey]) · 취소한 요청 · 인증 인터셉터가 조용히 끝낸 실패 +/// (제한을 확인한 `AUTH_010` · 사용자가 로그아웃한 뒤의 늦은 401 — 이 인터셉터까지 오지 않는다). +class ErrorNotifyInterceptor extends Interceptor { + ErrorNotifyInterceptor(this._notifier); + + final ErrorNotifier _notifier; + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final Object? error = err.error; + if (err.type != DioExceptionType.cancel && + error is ApiException && + !isSilentFor(err.requestOptions, error.code)) { + _notifier.show(error); + } + handler.next(err); + } +} diff --git a/flutter/lib/core/api/request_flags.dart b/flutter/lib/core/api/request_flags.dart new file mode 100644 index 0000000..3f933b9 --- /dev/null +++ b/flutter/lib/core/api/request_flags.dart @@ -0,0 +1,80 @@ +import 'package:dio/dio.dart'; + +/// 요청마다 알림 끄기 — `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록 +/// (`{kSilentExtraKey: {'AUTH_004', 'AUTH_006'}}`). 웹 `useApi` 의 `silent` 와 같다. +/// +/// 끈 실패도 예외(`DioException.error` 의 `ApiException`)로는 그대로 온다 — 화면이 `code` 로 가른다. +const String kSilentExtraKey = 'silent'; + +/// 비밀(비밀번호 · 코드 · 리프레시 토큰)이 본문에 든 요청 — 실패 예외에서 본문을 비운다([withoutSecrets]). +/// 화면이 그 예외를 `AsyncValue` 로 상태에 두어도 비밀이 남지 않게. 로그인 · 코드 · 비밀번호 단계 · logout 이 단다. +const String kSecretBodyExtraKey = 'secret.body'; + +/// `Authorization` 머리 이름 +const String kAuthorizationHeader = 'Authorization'; + +/// Bearer 머리 값 +String bearerOf(String accessToken) => 'Bearer $accessToken'; + +// ── 인증 인터셉터가 쓰는 내부 표시 (화면 · 저장소는 쓰지 않는다) ── +/// 401 → 갱신 뒤 다시 보낸 요청 — 401 으로는 한 번만 다시 보낸다 +const String kAuthRetriedExtraKey = 'auth.retried'; + +/// `AUTH_010` → 제한 다시 맞추기 뒤 다시 보낸 요청 — `AUTH_010` 으로는 한 번만 다시 보낸다 +const String kRestrictionRetriedExtraKey = 'auth.restrictionRetried'; + +/// 보낼 때의 토큰 세대(TokenStore.generation) — 그사이 다른 요청이 갱신했는지 견준다 +const String kAuthGenerationExtraKey = 'auth.generation'; + +/// 보낼 때의 세션 세대(TokenStore.sessionGeneration) — 그 요청을 보낸 세션이 끝났는지 견준다 +const String kSessionGenerationExtraKey = 'auth.session'; + +/// 인증 인터셉터가 이미 결론 낸 실패 — 다시 갱신하지 않는다 +const String kAuthHandledExtraKey = 'auth.handled'; + +/// 알림 끄기 · 비밀 본문 Options 를 만든다 +abstract final class ApiOptions { + /// 모든 실패의 알림을 끈다 + static Options silent() => + Options(extra: {kSilentExtraKey: true}); + + /// [codes] 의 알림만 끈다 — 화면이 칸 아래에 보이는 실패(예: 로그인의 `AUTH_004`) + static Options silentFor(Set codes) => + Options(extra: {kSilentExtraKey: codes}); + + /// 비밀이 본문에 든 요청 — [silent] 는 `true` 또는 코드 목록(`Set`) + static Options secret({Object? silent, Map? headers}) => + Options( + extra: { + kSecretBodyExtraKey: true, + kSilentExtraKey: ?silent, + }, + headers: headers, + ); +} + +/// 이 요청의 [code] 실패를 알리지 않는가 +bool isSilentFor(RequestOptions options, String code) { + final Object? flag = options.extra[kSilentExtraKey]; + if (flag == true) return true; + if (flag is Iterable) return flag.contains(code); + return false; +} + +/// 실패 예외에서 비밀을 지운다 — 액세스 토큰(`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); + } + return error; +} + +void _scrub(RequestOptions options) { + options.headers.remove(kAuthorizationHeader); + if (options.extra[kSecretBodyExtraKey] == true) options.data = null; +} diff --git a/flutter/lib/core/api/response_interceptor.dart b/flutter/lib/core/api/response_interceptor.dart new file mode 100644 index 0000000..114dcaf --- /dev/null +++ b/flutter/lib/core/api/response_interceptor.dart @@ -0,0 +1,52 @@ +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:dio/dio.dart'; + +/// 표준 응답을 푼다 (응답 포맷 규칙) — 인터셉터 줄의 맨 앞에 둔다. +/// +/// - `{ success: true, data }` → `data` 만 남긴다. 스트림 · 바이트(첨부 받기 · 실시간 알림)는 Map 이 아니라 그대로 둔다. +/// - `{ success: false }` 가 200 으로 오면(외부 게이트웨이 등 — 서버는 4xx 로 내린다) 실패로 바꾼다. +/// - 실패는 모두 `DioException.error` 에 [ApiException] 을 싣는다 — 뒤의 인증 · 알림 인터셉터가 코드로 가른다. +class ResponseInterceptor extends Interceptor { + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + final Object? payload = response.data; + if (payload is! Map || !payload.containsKey('success')) { + handler.next(response); + return; + } + if (payload['success'] == true) { + response.data = payload['data']; + handler.next(response); + return; + } + + final ({String? code, String? traceId}) body = readErrorBody(payload); + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + type: DioExceptionType.badResponse, + error: ApiException.fromCode( + body.code ?? 'BIZ_001', + traceId: body.traceId, + statusCode: response.statusCode, + ), + ), + // 뒤의 오류 인터셉터(인증 · 알림)도 지나게 한다 + true, + ); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + if (err.error is ApiException) { + handler.next(err); + return; + } + handler.next(err.copyWith(error: toApiException(err))); + } +} diff --git a/flutter/lib/core/api/server_time.dart b/flutter/lib/core/api/server_time.dart new file mode 100644 index 0000000..0299f5e --- /dev/null +++ b/flutter/lib/core/api/server_time.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; + +/// 응답의 `Date` 머리(서버 시각) — 없거나 읽지 못하면 null. +/// +/// 토큰의 남은 시간을 서버 시계로 재는 데 쓴다(TokenStore — 기기 시계가 틀려도 만료를 맞게 본다). +DateTime? serverTimeOf(Response response) { + final String? value = response.headers.value(HttpHeaders.dateHeader); + if (value == null || value.isEmpty) return null; + try { + return HttpDate.parse(value).toUtc(); + } on HttpException { + return null; + } on FormatException { + return null; + } +} diff --git a/flutter/lib/core/api/session_events.dart b/flutter/lib/core/api/session_events.dart new file mode 100644 index 0000000..78cc6f3 --- /dev/null +++ b/flutter/lib/core/api/session_events.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +/// 제한(`AUTH_010`)을 다시 맞춘 결과 — 웹 `frontend/src/composables/useApi.ts` 의 `RestrictionCheck` 와 같다. +enum RestrictionCheck { + /// 옛 토큰이었다 — 제한 없이 새 토큰을 받았다. 원래 요청을 한 번 다시 보내도 된다 + renewed, + + /// 제한을 확인했다 — 세션이 그 단계 화면으로 옮긴다. 알리지 않는다 + restricted, + + /// 그사이 세션이 바뀌었다(로그아웃 · 다른 계정 · 세션 끝) — 결과를 쓰지 않는다. 알리지 않는다 + stale, + + /// 확인하지 못했다(장애 · 듣는 쪽 없음) — 원래 실패를 알린다 + unknown, +} + +/// 제한을 다시 맞추는 데 기다리는 최대 시간 — 세션 확인(me)과 갱신이 안쪽 Dio 의 연결 · 받기 한도 +/// (`api_config.dart` — 15초 + 15초)를 다 써도 그 결과가 먼저 오게, 두 요청의 한도 합(60초)에 5초를 더했다. +/// 듣는 쪽이 답하지 않아도 요청이 끝없이 서 있지 않게 하는 안전 한도다. API 한도를 바꾸면 함께 바꾼다(시험이 견준다). +const Duration kRestrictionRecheckTimeout = Duration(seconds: 65); + +/// 네트워크 층이 세션 노티파이어에 알리는 일. +/// +/// 인터셉터는 세션 노티파이어를 모른다(노티파이어가 Dio 를 쓰므로 서로 물리지 않게) — +/// 여기에 알리기만 하고, 세션 노티파이어가 듣고 상태를 바꾼다. +sealed class SessionEvent { + const SessionEvent(); +} + +/// 갱신이 거절됐다(401 · 유예 응답) — 토큰은 이미 지웠다. 로그인 화면으로 간다. +final class SessionEnded extends SessionEvent { + const SessionEnded(); +} + +/// 403 `AUTH_010` — 제한된 세션이다. 알림 없이 세션을 다시 확인해 그 단계로 옮기고 결과를 [reply] 로 돌려준다(계약 6절). +final class SessionRecheckNeeded extends SessionEvent { + SessionRecheckNeeded(); + + final Completer _answer = Completer(); + + /// 다시 맞춘 결과 — 듣는 쪽이 [reply] 로 채운다 + Future get answer => _answer.future; + + /// 결과를 돌려준다 — 처음 한 번만 쓴다 + void reply(RestrictionCheck check) { + if (!_answer.isCompleted) _answer.complete(check); + } +} + +/// 토큰을 갱신했다 — 응답에 실린 세션 칸(제한 · 유예)으로 화면을 맞춘다(계약 6절 — 갱신 응답에도 늘 싣는다). +final class SessionRefreshed extends SessionEvent { + const SessionRefreshed(this.body); + + /// 갱신 응답의 세션 칸만(`email` · `name` · `restriction` · `twoFactorDeadline`) — 토큰은 싣지 않는다 + final Map body; +} + +/// 갱신 응답에서 세션 칸만 남긴다 — 토큰(`tokens`)이 알림 · 결과 객체에 실려 다니지 않게 +Map sessionFieldsOf(Map body) => + { + for (final MapEntry entry in body.entries) + if (kSessionFieldNames.contains(entry.key)) entry.key: entry.value, + }; + +/// 세션 칸 이름 (계약 1 · 6절) +const Set kSessionFieldNames = { + 'email', + 'name', + 'restriction', + 'twoFactorDeadline', +}; + +/// 세션 알림 통로 — 앱에 하나 (dio_provider 의 sessionEventsProvider) +class SessionEventBus { + SessionEventBus({Duration recheckTimeout = kRestrictionRecheckTimeout}) + : _recheckTimeout = recheckTimeout; + + final Duration _recheckTimeout; + final StreamController _controller = + StreamController.broadcast(); + + Stream get stream => _controller.stream; + + void emit(SessionEvent event) { + if (!_controller.isClosed) _controller.add(event); + } + + /// `AUTH_010` 을 받았다 — 세션 노티파이어에 제한을 다시 맞춰 달라고 하고 그 결과를 기다린다. + /// 듣는 쪽이 없거나 [kRestrictionRecheckTimeout] 안에 답이 없으면 [RestrictionCheck.unknown]. + Future requestRecheck() { + if (_controller.isClosed || !_controller.hasListener) { + return Future.value(RestrictionCheck.unknown); + } + final SessionRecheckNeeded event = SessionRecheckNeeded(); + _controller.add(event); + return event.answer.timeout( + _recheckTimeout, + onTimeout: () => RestrictionCheck.unknown, + ); + } + + Future close() => _controller.close(); +} diff --git a/flutter/lib/core/api/token_refresher.dart b/flutter/lib/core/api/token_refresher.dart new file mode 100644 index 0000000..7d7e678 --- /dev/null +++ b/flutter/lib/core/api/token_refresher.dart @@ -0,0 +1,304 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.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/session_events.dart'; +import 'package:arasystem_app/core/storage/auth_tokens.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; + +/// 클라이언트 전용 세션 만료 코드 (에러 코드 규칙 "AUTH_002") +const String kSessionExpiredCode = 'AUTH_002'; + +/// 갱신이 일시 장애로 실패하면 같은 토큰 세대 동안 이만큼은 그 결과를 다시 쓴다 — +/// 연결이 끊긴 동안 줄 선 요청마다 갱신을 보내 그때마다 연결 한도를 기다리지 않게 +const Duration kRefreshFailureReuse = Duration(seconds: 5); + +/// 갱신 결과 +sealed class RefreshOutcome { + const RefreshOutcome(); +} + +/// 새 토큰을 받아 두었다 — [body] 는 갱신 응답의 세션 칸만(토큰 없음). 그사이 다른 요청이 이미 받아 두었으면 null. +final class RefreshSucceeded extends RefreshOutcome { + const RefreshSucceeded([this.body]); + + final Map? body; +} + +/// 세션이 끝났다 — 서버가 401 로 거절했거나 유예 응답이었다. 토큰은 지웠고 [error] 는 `AUTH_002` 다. +final class RefreshRejected extends RefreshOutcome { + const RefreshRejected(this.error); + + final ApiException error; +} + +/// 일시 장애(연결 끊김 · 5xx) — 세션은 그대로 두고 [error] 를 알린다 (계약 1절: 로그아웃으로 다루지 않는다) +final class RefreshFailed extends RefreshOutcome { + const RefreshFailed(this.error); + + final ApiException error; +} + +/// 갱신하는 사이 로그아웃했다 — 받은 토큰은 쓰지 않고 서버 세션도 끝냈다(계약 1절) +final class RefreshDiscarded extends RefreshOutcome { + const RefreshDiscarded(); +} + +/// 앱 logout 요청 — 리프레시 토큰이 있으면 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다 +/// (계약 1절 "앱 로그인": 서버는 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다 — 둘 다 없으면 빈 본문 `{}`). +/// 알림은 띄우지 않는다. 실패는 `DioException`(본문을 비운 것)으로 던진다. +Future postAppLogout( + Dio dio, { + required String? refreshToken, + required String? accessToken, +}) async { + await dio.post( + ApiPaths.appLogout, + data: {'refreshToken': ?refreshToken}, + options: ApiOptions.secret( + silent: true, + headers: { + if (accessToken != null) kAuthorizationHeader: bearerOf(accessToken), + }, + ), + ); +} + +/// 인증 인터셉터 줄 **밖에서** 보내는 인증 요청 — 갱신 · 세션 확인 · 조용한 로그아웃. +/// +/// 갱신은 **한 번에 하나만** 한다. 리프레시 토큰은 1회용이라 두 갱신이 겹치면 늦은 쪽이 "이미 쓴 토큰"이 된다. +/// 인증 인터셉터(요청 전 미리 갱신 · 401 뒤 갱신)와 세션 노티파이어가 모두 이것을 지나 +/// 진행 중인 갱신이 있으면 그 결과를 함께 기다린다. +/// +/// 요청은 인증 인터셉터가 없는 Dio(`bareDio`)로 보낸다 — 인증 인터셉터는 `QueuedInterceptor` 라 +/// 그 오류 처리 안에서 같은 줄로 요청을 보내면 그 요청의 오류가 지금 처리 뒤에 줄을 서서 서로 기다린다. +class TokenRefresher { + TokenRefresher({ + required Dio bareDio, + required TokenStore store, + required SessionEventBus events, + Clock clock = systemClock, + }) : _dio = bareDio, + _store = store, + _events = events, + _clock = clock; + + final Dio _dio; + final TokenStore _store; + final SessionEventBus _events; + final Clock _clock; + + Future? _inFlight; + ({int generation, DateTime at, RefreshFailed outcome})? _lastFailure; + + /// 갱신한다. + /// + /// [sentGeneration] 은 실패한 요청을 보낼 때의 토큰 세대다. 그사이 다른 요청이 이미 새 토큰을 + /// 받아 두었으면 다시 갱신하지 않고 [RefreshSucceeded] 로 답한다. + /// 같은 세대에서 [kRefreshFailureReuse] 안에 일시 장애로 실패했으면 다시 보내지 않고 그 결과를 돌려준다. + Future refresh({int? sentGeneration}) { + final Future? inFlight = _inFlight; + if (inFlight != null) return inFlight; + if (sentGeneration != null && + sentGeneration != _store.generation && + _store.hasUsableAccessToken) { + return Future.value(const RefreshSucceeded()); + } + final RefreshFailed? recent = _recentFailure(); + if (recent != null) return Future.value(recent); + + final Future run = _runGuarded(); + _inFlight = run; + run.whenComplete(() { + if (identical(_inFlight, run)) _inFlight = null; + }).ignore(); + return run; + } + + /// 일시 장애 기억을 지운다 — 사용자가 [다시 시도]를 눌렀을 때 + void forgetFailure() => _lastFailure = null; + + /// 세션을 끝낸다 — 토큰을 지우고 세션 노티파이어에 알린다. 돌려주는 예외는 `AUTH_002` 다. + /// + /// [sessionGeneration] 을 주면 그 세션이 아직일 때만 지운다 — 그사이 로그아웃했으면(다른 직원이 로그인했어도) + /// 그 세션을 건드리지 않는다. + Future endSession({ + String? traceId, + int? sessionGeneration, + }) async { + final ApiException expired = ApiException.fromCode( + kSessionExpiredCode, + traceId: traceId, + statusCode: 401, + ); + if (sessionGeneration != null && + sessionGeneration != _store.sessionGeneration) { + return expired; + } + try { + await _store.clear(); + } on PlatformException { + // 메모리의 토큰은 이미 지웠다 — 남은 값은 다음 실행의 갱신이 거절되면 다시 지운다 + } + _events.emit(const SessionEnded()); + return expired; + } + + /// 지금 세션(`GET /auth/me`)을 인증 인터셉터 줄 밖에서 묻는다 — `AUTH_010` 을 다시 맞출 때(인터셉터가 기다리는 중이라). + /// 응답의 `data` 를 돌려주고, 실패는 `DioException`(`error` 에 `ApiException` · 토큰을 지운 것)으로 던진다. 알림은 띄우지 않는다. + Future> readSession() async { + final String? accessToken = _store.accessToken; + final Response response; + try { + response = await _dio.get( + ApiPaths.me, + options: Options( + extra: {kSilentExtraKey: true}, + headers: { + if (accessToken != null) + kAuthorizationHeader: bearerOf(accessToken), + }, + ), + ); + } on DioException catch (error) { + throw withoutSecrets(error); + } + final Object? data = response.data; + if (data is! Map) { + throw withoutSecrets( + apiFailure(response.requestOptions, kFallbackCode, response: response), + ); + } + return data; + } + + /// 조용한 앱 logout — 버린 토큰의 세션을 서버에서 끝낸다(닿지 못해도 쓰지 않는 세션은 미사용 14일에 끝난다) + Future logoutQuietly({ + required String? refreshToken, + required String? accessToken, + }) async { + if (refreshToken == null && accessToken == null) return; + try { + await postAppLogout( + _dio, + refreshToken: refreshToken, + accessToken: accessToken, + ); + } on DioException { + // 위 설명대로 둔다 + } + } + + RefreshFailed? _recentFailure() { + final ({int generation, DateTime at, RefreshFailed outcome})? last = + _lastFailure; + if (last == null || last.generation != _store.generation) return null; + final Duration since = _clock().difference(last.at); + if (since.isNegative || since >= kRefreshFailureReuse) return null; + return last.outcome; + } + + Future _runGuarded() async { + RefreshOutcome outcome; + try { + outcome = await _run(); + } on PlatformException { + // 보안 저장소를 읽지 못했다 — 세션은 그대로 두고 일시 장애로 알린다 + outcome = RefreshFailed(ApiException.fromCode(kFallbackCode)); + } + _lastFailure = outcome is RefreshFailed + ? (generation: _store.generation, at: _clock(), outcome: outcome) + : null; + return outcome; + } + + Future _run() async { + final int startGeneration = _store.generation; + final int startSession = _store.sessionGeneration; + final String? refreshToken = await _store.readRefreshToken(); + // 읽는 사이 로그아웃했다 — 그 세션을 끝내지 않는다(로그아웃이 이미 끝냈다) + if (_store.generation != startGeneration) return const RefreshDiscarded(); + if (refreshToken == null) { + return RefreshRejected(await endSession(sessionGeneration: startSession)); + } + + final Response response; + try { + response = await _dio.post( + ApiPaths.appRefresh, + data: {'refreshToken': refreshToken}, + options: ApiOptions.secret(silent: true), + ); + } on DioException catch (error) { + if (_store.generation != startGeneration) return const RefreshDiscarded(); + final ApiException apiError = toApiException(error); + if (error.response?.statusCode == 401) { + return RefreshRejected( + await endSession( + traceId: apiError.traceId, + sessionGeneration: startSession, + ), + ); + } + return RefreshFailed(apiError); + } + + final Object? data = response.data; + final Map body = data is Map + ? data + : const {}; + final AuthTokens? tokens = AuthTokens.tryParse( + body['tokens'], + serverTime: serverTimeOf(response), + ); + + if (_store.generation != startGeneration) { + // 갱신하는 사이 로그아웃했다 — 서버가 로그아웃 뒤에 새 토큰을 줄 수 있어 그 세션도 끝낸다 + if (tokens != null) { + unawaited( + logoutQuietly( + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + ), + ); + } + return const RefreshDiscarded(); + } + + if (tokens != null) { + try { + await _store.save(tokens); + } on PlatformException { + // 새 토큰을 보안 저장소에 두지 못했다 — 이번 실행 동안 메모리의 새 토큰으로 이어 가고, + // 다음 저장 · 읽기 때 다시 쓴다(TokenStore). 그 전에 앱이 죽으면 다음 실행의 옛 토큰이 30초 뒤 + // 재사용으로 세션을 끊는다 — 계약 1절 "앱 로그인"의 알려진 한계와 같은 결이다. + } + final Map session = sessionFieldsOf(body); + _events.emit(SessionRefreshed(session)); + return RefreshSucceeded(session); + } + + if (body.containsKey('tokens') && body['tokens'] == null) { + // 30초 유예 응답(새 토큰 없이 200) — 계약 1절 "앱 로그인": 세션 끝으로 다룬다. + // 앱은 갱신을 한 번에 하나만 하므로 앞 갱신의 응답을 잃은 경우이고 이어 쓸 토큰이 없다. + // 메모리의 액세스 토큰이 아직 있으면 그것으로 서버 세션도 끝낸다("로그인된 기기"에 14일 남지 않게 — sid 로). + final String? accessToken = _store.accessToken; + final ApiException expired = await endSession( + sessionGeneration: startSession, + ); + if (accessToken != null) { + unawaited(logoutQuietly(refreshToken: null, accessToken: accessToken)); + } + return RefreshRejected(expired); + } + + // 모양이 틀린 응답 — 서버 쪽 문제로 보고 세션은 그대로 둔다 + return RefreshFailed(ApiException.fromCode(kFallbackCode)); + } +} diff --git a/flutter/lib/core/api/user_agent.dart b/flutter/lib/core/api/user_agent.dart new file mode 100644 index 0000000..daf7481 --- /dev/null +++ b/flutter/lib/core/api/user_agent.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/services.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +/// 기종 · 판 글자의 최대 길이 — 머리글이 지나치게 길어지지 않게 +const int _kUserAgentPartMaxLength = 64; + +/// 서버가 앱 세션을 알아보는 User-Agent — `ARASYSTEM-App/<판> (Android <판>; <기종>)` (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 지금 세션"). +/// +/// 머리글 값은 ASCII 만 보낼 수 있고 `;` `(` `)` 는 꼴을 깨므로, 기기가 준 글자에서 영문 · 숫자 · 공백 · `. _ -` 만 남긴다. +/// 모르는 칸은 뺀다(예: `ARASYSTEM-App/1.0.0 (Android 15)`). +String buildUserAgent({ + required String? appVersion, + required String osName, + String? osVersion, + String? model, +}) { + final String version = _clean(appVersion); + final String product = version.isEmpty + ? kUserAgentProduct + : '$kUserAgentProduct/$version'; + final String os = [ + _clean(osName), + _clean(osVersion), + ].where((String part) => part.isNotEmpty).join(' '); + final List details = [ + os, + _clean(model), + ].where((String part) => part.isNotEmpty).toList(); + return details.isEmpty ? product : '$product (${details.join('; ')})'; +} + +String _clean(String? value) { + if (value == null) return ''; + final String kept = value + .replaceAll(RegExp(r'[^A-Za-z0-9 ._\-]'), '') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return kept.length > _kUserAgentPartMaxLength + ? kept.substring(0, _kUserAgentPartMaxLength).trim() + : kept; +} + +/// 이 기기의 앱 판 · User-Agent — main 에서 한 번. 기기 정보를 읽지 못하면 아는 것만 싣는다. +Future<({String? appVersion, String userAgent})> loadAppDeviceInfo() async { + String? appVersion; + try { + appVersion = (await PackageInfo.fromPlatform()).version; + } on PlatformException { + appVersion = null; + } + return ( + appVersion: appVersion, + userAgent: await _deviceUserAgent(appVersion), + ); +} + +Future _deviceUserAgent(String? appVersion) async { + try { + final DeviceInfoPlugin device = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final AndroidDeviceInfo android = await device.androidInfo; + return buildUserAgent( + appVersion: appVersion, + osName: 'Android', + osVersion: android.version.release, + model: android.model, + ); + } + if (Platform.isIOS) { + final IosDeviceInfo ios = await device.iosInfo; + return buildUserAgent( + appVersion: appVersion, + osName: 'iOS', + osVersion: ios.systemVersion, + model: ios.utsname.machine, + ); + } + } on PlatformException { + // 아래의 아는 것만 싣는다 + } + return buildUserAgent( + appVersion: appVersion, + osName: Platform.operatingSystem, + ); +} diff --git a/flutter/lib/core/constants/app_constants.dart b/flutter/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..fec044b --- /dev/null +++ b/flutter/lib/core/constants/app_constants.dart @@ -0,0 +1,12 @@ +/// 앱 전체에서 쓰는 이름 · 식별 값. +/// +/// 식별자(Android applicationId · iOS bundle id `kr.it.arasystem.app`)는 플랫폼 설정 파일에 있다 — +/// 스토어에 올린 뒤에는 바꿀 수 없다. +library; + +/// 홈 화면 · 작업 전환 화면에 보이는 앱 이름 +const String kAppName = 'ARA SYSTEM'; + +/// 서버가 앱을 알아보는 User-Agent 제품 이름 — `ARASYSTEM-App/<판> (Android <판>; <기종>)` +/// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 지금 세션" — 서버가 "ARA SYSTEM 앱 · Android" 꼴로 보인다) +const String kUserAgentProduct = 'ARASYSTEM-App'; diff --git a/flutter/lib/core/constants/app_info_provider.dart b/flutter/lib/core/constants/app_info_provider.dart new file mode 100644 index 0000000..5900f14 --- /dev/null +++ b/flutter/lib/core/constants/app_info_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱 판(pubspec `version` 의 build-name — 예 `1.0.0`). main 이 기기 정보를 읽어 덮어쓴다. +/// 읽지 못했으면 null — 설정 › 앱 정보 · 라이선스 화면은 판 없이 보인다. +final Provider appVersionProvider = Provider( + (Ref ref) => null, +); diff --git a/flutter/lib/core/constants/screen_titles.dart b/flutter/lib/core/constants/screen_titles.dart new file mode 100644 index 0000000..d65c4b5 --- /dev/null +++ b/flutter/lib/core/constants/screen_titles.dart @@ -0,0 +1,24 @@ +/// 로그인 전 · 제한 단계 화면 이름 — 지금은 자리 화면(이름만 보인다)이 쓴다. +/// 모듈 화면의 이름은 그 모듈 안에 둔다(`features/<모듈>/presentation/<모듈>_texts.dart`). +/// +/// 화면은 캔버스로 확인받은 뒤 만든다. 그때 화면 글은 이 파일 같은 상수에 모으고 +/// 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. +abstract final class ScreenTitles { + /// 세션을 되살리는 동안 + static const String start = '시작하는 중'; + + /// 로그인 — 비밀번호 단계 + static const String login = '로그인'; + + /// 로그인 — 2단계 인증 코드 단계 + static const String loginCode = '인증 코드 입력'; + + /// 제한된 세션 — 새 비밀번호 정하기 (passwordChange) + static const String passwordChange = '새 비밀번호 정하기'; + + /// 제한된 세션 — 2단계 인증 켜기 (twoFactorSetup) + static const String twoFactorSetup = '2단계 인증 켜기'; + + /// 제한된 세션 — 앱이 모르는 제한 (웹과 같이 사전 문구와 [다른 계정으로 로그인]만) + static const String restricted = '로그인 제한'; +} diff --git a/flutter/lib/core/modules/app_modules.dart b/flutter/lib/core/modules/app_modules.dart new file mode 100644 index 0000000..087fd00 --- /dev/null +++ b/flutter/lib/core/modules/app_modules.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +/// 사내 시스템 모듈 목록 — 아래 탭 막대(웹의 전역 앱 레일)는 이 목록 하나만 보고 그린다. +/// +/// **짝: 웹 `frontend/src/shared/constants/app-modules.ts` 의 `APP_MODULES`** — 같은 id · 이름 · 차례다. +/// 한쪽을 바꾸면 둘 다 바꾼다(아이콘은 플랫폼마다 따로 — 웹은 lucide, 앱은 Material). +/// +/// 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭에 보인다([enabledAppModules]). +/// 새 모듈을 붙이는 법: 여기 항목을 켜고 → `features/<모듈>/<모듈>_routes.dart` 에 라우트 묶음을 만들고 → +/// `core/router/module_registry.dart` 의 switch 에 그 묶음을 적는다(빠뜨리면 컴파일 · 시험이 막는다). +enum AppModuleId { + mail, + calendar, + files, + contacts, + settings; + + /// 주소 · 알림의 모듈 이름 — 없는 이름이면 null + static AppModuleId? fromName(String? name) { + for (final AppModuleId id in AppModuleId.values) { + if (id.name == name) return id; + } + return null; + } +} + +/// 모듈 하나 +@immutable +class AppModule { + const AppModule({ + required this.id, + required this.label, + required this.icon, + required this.selectedIcon, + required this.isEnabled, + }); + + final AppModuleId id; + + /// 탭 막대 · 화면 머리에 보이는 이름 (웹 `label` 과 같다) + final String label; + final IconData icon; + + /// 고른 탭의 아이콘 + final IconData selectedIcon; + + /// 첫 판에 켜는가 — 꺼진 모듈은 탭 · 주소 · 알림 어디에도 열리지 않는다 + final bool isEnabled; + + /// 모듈 첫 화면 경로 — 주소는 모듈 이름부터 시작한다(`/mail/...` · `/settings/...`) + String get rootPath => '/${id.name}'; +} + +/// 모듈 목록 — 웹과 같은 차례(설정은 맨 끝) +const List kAppModules = [ + AppModule( + id: AppModuleId.mail, + label: '메일', + icon: Icons.mail_outline, + selectedIcon: Icons.mail, + isEnabled: true, + ), + AppModule( + id: AppModuleId.calendar, + label: '캘린더', + icon: Icons.calendar_today_outlined, + selectedIcon: Icons.calendar_today, + isEnabled: false, + ), + AppModule( + id: AppModuleId.files, + label: '파일', + icon: Icons.folder_outlined, + selectedIcon: Icons.folder, + isEnabled: false, + ), + // 웹은 8단계부터 켜져 있다 — 앱은 첫 판 밖 + AppModule( + id: AppModuleId.contacts, + label: '주소록', + icon: Icons.people_outline, + selectedIcon: Icons.people, + isEnabled: false, + ), + AppModule( + id: AppModuleId.settings, + label: '설정', + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, + isEnabled: true, + ), +]; + +/// 로그인 뒤 첫 모듈 · 모르는 주소가 돌아가는 곳 +const AppModuleId kHomeModuleId = AppModuleId.mail; + +/// 켜진 모듈만 — 목록 차례 그대로 (탭 막대의 차례) +List enabledAppModules([List modules = kAppModules]) => + modules.where((AppModule module) => module.isEnabled).toList(); + +/// id 로 모듈을 찾는다 +AppModule findAppModule( + AppModuleId id, [ + List modules = kAppModules, +]) => modules.firstWhere((AppModule module) => module.id == id); + +/// 경로의 첫 칸으로 켜진 모듈을 찾는다(`/mail/messages/1` → 메일) — 없거나 꺼졌으면 null +AppModule? enabledModuleOfPath( + String path, [ + List modules = kAppModules, +]) { + final List segments = Uri.parse(path).pathSegments; + if (segments.isEmpty) return null; + final AppModuleId? id = AppModuleId.fromName(segments.first); + if (id == null) return null; + final AppModule module = findAppModule(id, modules); + return module.isEnabled ? module : null; +} + +/// 홈(로그인 뒤 첫 화면 · 모르는 주소가 돌아가는 곳)의 경로 — [modules] 에서 홈 모듈이 켜져 있으면 그 첫 화면, +/// 꺼져 있으면 켜진 첫 모듈의 첫 화면 +String homeLocationOf([List modules = kAppModules]) { + final AppModule home = findAppModule(kHomeModuleId, modules); + if (home.isEnabled) return home.rootPath; + final List enabled = enabledAppModules(modules); + return enabled.isEmpty ? home.rootPath : enabled.first.rootPath; +} + +/// 기본 목록의 홈 경로 (`/mail`) +String get homeLocation => homeLocationOf(); diff --git a/flutter/lib/core/modules/module_routes.dart b/flutter/lib/core/modules/module_routes.dart new file mode 100644 index 0000000..79fa7f0 --- /dev/null +++ b/flutter/lib/core/modules/module_routes.dart @@ -0,0 +1,62 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; + +/// 모듈이 라우터에 내놓는 것 — 모듈마다 `features/<모듈>/<모듈>_routes.dart` 에 하나. +/// +/// `core/router` 가 켜진 모듈 목록을 보고 이것들을 모아 탭(StatefulShellBranch)마다 하나씩 붙인다. +/// 모듈끼리는 서로 import 하지 않는다 — 설정 화면에 절을 더할 때도 [settingsSections] 로 내놓기만 한다. +@immutable +class ModuleRoutes { + const ModuleRoutes({ + required this.id, + required this.buildRoutes, + this.locationOfItem, + this.settingsSections = const [], + }); + + final AppModuleId id; + + /// 탭 안의 라우트 — 첫 화면은 모듈 경로(`/<모듈>`)여야 한다. + /// 탭 막대 없이 여는 전체 화면(읽기 · 쓰기)은 `parentNavigatorKey: context.rootNavigatorKey` 로 연다. + final List Function(ModuleRouteContext context) buildRoutes; + + /// 알림 · 깊은 링크의 항목 id → 그 화면 경로. 없으면 모듈 첫 화면으로 간다. + final String Function(String itemId)? locationOfItem; + + /// 설정 탭에 더할 이 모듈의 절 (예: 나중에 캘린더 알림) + final List settingsSections; +} + +/// 라우트를 만들 때 모듈이 받는 것 +@immutable +class ModuleRouteContext { + const ModuleRouteContext({ + required this.rootNavigatorKey, + this.settingsSections = const [], + }); + + /// 탭 막대 위에 덮어 여는 전체 화면의 navigator + final GlobalKey rootNavigatorKey; + + /// 다른 모듈이 내놓은 설정 절 — 설정 모듈만 쓴다 + final List settingsSections; +} + +/// 설정 탭의 절 하나 — 설정 모듈이 자기 라우트 아래(`/settings/`)에 붙인다 +@immutable +class ModuleSettingsSection { + const ModuleSettingsSection({ + required this.title, + required this.path, + required this.builder, + }); + + /// 설정 목록에 보이는 이름 + final String title; + + /// `/settings/` 아래의 칸 — 예: `calendar` + final String path; + + final GoRouterWidgetBuilder builder; +} diff --git a/flutter/lib/core/push/push_registration.dart b/flutter/lib/core/push/push_registration.dart new file mode 100644 index 0000000..a838722 --- /dev/null +++ b/flutter/lib/core/push/push_registration.dart @@ -0,0 +1,35 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 푸시 기기 등록 — 모듈 공통이다(메일 · 나중의 캘린더 · 파일이 같은 기기 토큰을 쓴다). +/// +/// **자리만 둔다.** 서버 경로(`POST` · `DELETE /api/push-devices` — 기기 토큰 · 플랫폼 · 앱 판, 앱 세션에 묶음)와 +/// FCM 이 생기면 구현을 채운다. 세션 노티파이어가 부른다: +/// - 로그인을 마치면(제한 없음) [register] +/// - 로그아웃한 **뒤에**(토큰과 세션 상태를 먼저 지운다) [unregister] — 이 기기의 알림 토큰만 지운다. +/// 짧은 한도 안에서만 기다리고 실패는 삼킨다. 서버의 기기 줄은 앱 logout 이 그 세션과 함께 지운다 +/// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 이 절 다음" — 세션이 서버에서 끝날 때도 같다). +abstract interface class PushRegistrar { + Future register(); + + /// 이 기기의 알림 토큰을 지운다 — 로그아웃 뒤라 서버를 부를 수 없다(인증 토큰이 없다) + Future unregister(); +} + +/// 로그아웃 뒤 알림 토큰 지우기를 기다리는 최대 시간 — 넘으면 그만 기다린다 +const Duration kPushUnregisterTimeout = Duration(seconds: 5); + +/// 지금의 구현 — 아무것도 하지 않는다 +class NoopPushRegistrar implements PushRegistrar { + const NoopPushRegistrar(); + + @override + Future register() async {} + + @override + Future unregister() async {} +} + +/// 푸시 기기 등록 +final Provider pushRegistrarProvider = Provider( + (Ref ref) => const NoopPushRegistrar(), +); diff --git a/flutter/lib/core/push/push_target.dart b/flutter/lib/core/push/push_target.dart new file mode 100644 index 0000000..49d1385 --- /dev/null +++ b/flutter/lib/core/push/push_target.dart @@ -0,0 +1,51 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/modules/module_routes.dart'; + +/// 알림 · 깊은 링크가 가리키는 곳 — **모듈 이름과 id 만** 싣는다. +/// +/// 보낸 사람 · 제목 같은 내용은 알림에 싣지 않는다(Google · Apple 서버를 거치므로 "새 메일 N통"만 — `docs/design/app/README.md` "새 메일 알림"). +/// 알림을 누르면 앱이 이것으로 화면을 고르고, 내용은 앱이 서버에서 받아 와 보인다. +class PushTarget { + const PushTarget({required this.module, this.itemId}); + + /// 알림 데이터(`{ module: 'mail', id: '…' }`)를 읽는다. 모듈 이름을 모르면 null. + static PushTarget? fromData(Map data) { + final Object? name = data[moduleKey]; + final AppModuleId? module = AppModuleId.fromName( + name is String ? name : null, + ); + if (module == null) return null; + final Object? id = data[itemIdKey]; + return PushTarget( + module: module, + itemId: id is String && id.isNotEmpty ? id : null, + ); + } + + /// 알림 데이터의 칸 이름 — 서버(푸시 보내는 쪽)와 짝이다 + static const String moduleKey = 'module'; + static const String itemIdKey = 'id'; + + final AppModuleId module; + + /// 모듈 안의 항목 id (메일 id 등) — 없으면 모듈 첫 화면 + final String? itemId; +} + +/// 알림을 눌렀을 때 갈 경로 — 꺼졌거나 라우트가 없는 모듈이면 홈(메일 첫 화면). +/// +/// [routesOf] 는 모듈 id → 라우트 묶음이다(core/router/module_registry.dart 의 moduleRoutesOf). +String locationForPush( + PushTarget? target, + ModuleRoutes? Function(AppModuleId id) routesOf, { + List modules = kAppModules, +}) { + if (target == null) return homeLocationOf(modules); + final AppModule module = findAppModule(target.module, modules); + final ModuleRoutes? routes = routesOf(target.module); + if (!module.isEnabled || routes == null) return homeLocationOf(modules); + final String? itemId = target.itemId; + final String Function(String itemId)? locationOfItem = routes.locationOfItem; + if (itemId == null || locationOfItem == null) return module.rootPath; + return locationOfItem(itemId); +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart new file mode 100644 index 0000000..23d2d80 --- /dev/null +++ b/flutter/lib/core/router/app_router.dart @@ -0,0 +1,100 @@ +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/modules/module_routes.dart'; +import 'package:arasystem_app/core/router/app_routes.dart'; +import 'package:arasystem_app/core/router/app_shell.dart'; +import 'package:arasystem_app/core/router/module_registry.dart'; +import 'package:arasystem_app/core/router/session_redirect.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 켜는 모듈 목록 — 시험은 다른 목록으로 덮어쓴다 +final Provider> appModulesProvider = Provider>( + (Ref ref) => kAppModules, +); + +/// 앱 라우터 — 세션이 바뀔 때마다 redirect 를 다시 돌린다. +/// +/// - 로그인 전 · 제한 단계 화면(`/start` · `/login/...`)은 탭 막대 밖(root navigator)이다. +/// - 로그인 뒤 화면은 켜진 모듈마다 탭 하나(StatefulShellRoute.indexedStack — 탭마다 이동 기록을 따로 지킨다). +/// 라우트는 모듈이 내놓은 묶음(`features/<모듈>/<모듈>_routes.dart`)을 module_registry 가 모은 것이다. +/// - 모르는 주소 · 꺼진 모듈의 주소는 메일 첫 화면으로 간다(redirect · onException). +/// +/// 로그인 단계 화면은 이름만 보이는 자리 화면이다 — 캔버스(로그인 4단계)대로 만들면 +/// `features/auth/presentation/pages/` 의 화면으로 바꾼다(경로 · redirect 는 그대로 둔다). +final Provider routerProvider = Provider((Ref ref) { + final List modules = ref.watch(appModulesProvider); + final List enabled = enabledAppModules(modules); + final List moduleRoutes = enabledModuleRoutes(modules); + final GlobalKey rootNavigatorKey = + GlobalKey(); + final ModuleRouteContext routeContext = ModuleRouteContext( + rootNavigatorKey: rootNavigatorKey, + settingsSections: moduleSettingsSections(moduleRoutes), + ); + + final _SessionChanges sessionChanges = _SessionChanges(); + ref.listen>( + sessionProvider, + (AsyncValue? previous, AsyncValue next) => + sessionChanges.notify(), + ); + + final GoRouter router = GoRouter( + navigatorKey: rootNavigatorKey, + initialLocation: AppRoutes.start, + refreshListenable: sessionChanges, + redirect: (BuildContext context, GoRouterState state) => + resolveSessionRedirect( + ref.read(sessionProvider), + state.uri.path, + modules: modules, + ), + // 켜진 모듈 안의 없는 주소(`/mail/없는곳`) — 메일 첫 화면으로 + onException: (BuildContext context, GoRouterState state, GoRouter router) => + router.go(homeLocationOf(modules)), + routes: [ + _placeholderRoute(AppRoutes.start, ScreenTitles.start), + _placeholderRoute(AppRoutes.login, ScreenTitles.login), + _placeholderRoute(AppRoutes.loginCode, ScreenTitles.loginCode), + _placeholderRoute(AppRoutes.passwordChange, ScreenTitles.passwordChange), + _placeholderRoute(AppRoutes.twoFactorSetup, ScreenTitles.twoFactorSetup), + _placeholderRoute(AppRoutes.restricted, ScreenTitles.restricted), + StatefulShellRoute.indexedStack( + builder: + ( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) => AppShell(navigationShell: navigationShell, modules: enabled), + branches: [ + for (final ModuleRoutes module in moduleRoutes) + StatefulShellBranch(routes: module.buildRoutes(routeContext)), + ], + ), + ], + ); + + ref.onDispose(() { + router.dispose(); + sessionChanges.dispose(); + }); + return router; +}); + +GoRoute _placeholderRoute(String path, String title) { + return GoRoute( + path: path, + builder: (BuildContext context, GoRouterState state) => + PlaceholderPage(title: title), + ); +} + +/// 세션이 바뀐 것을 라우터에 알린다 (refreshListenable) +class _SessionChanges extends ChangeNotifier { + void notify() => notifyListeners(); +} diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart new file mode 100644 index 0000000..808e4c3 --- /dev/null +++ b/flutter/lib/core/router/app_routes.dart @@ -0,0 +1,23 @@ +/// 로그인 전 · 제한 단계 화면의 경로 — 탭 막대 밖(셸 밖)에 둔다. +/// +/// 로그인 뒤 화면은 모듈 이름부터 시작한다(`/mail/...` · `/settings/...` — core/modules/app_modules.dart, +/// 경로 상수는 모듈마다 `features/<모듈>/<모듈>_routes.dart`). redirect 가 이 구분으로 화면을 고른다(session_redirect.dart). +abstract final class AppRoutes { + /// 세션을 되살리는 동안 · 되살리기가 장애로 실패했을 때 + static const String start = '/start'; + + /// 로그인 — 비밀번호 단계 + static const String login = '/login'; + + /// 로그인 — 2단계 인증 코드 단계 + static const String loginCode = '/login/code'; + + /// 제한된 세션 — 새 비밀번호 정하기 + static const String passwordChange = '/login/password'; + + /// 제한된 세션 — 2단계 인증 켜기 + static const String twoFactorSetup = '/login/two-factor'; + + /// 제한된 세션 — 앱이 모르는 제한 + static const String restricted = '/login/restricted'; +} diff --git a/flutter/lib/core/router/app_shell.dart b/flutter/lib/core/router/app_shell.dart new file mode 100644 index 0000000..69a5e6d --- /dev/null +++ b/flutter/lib/core/router/app_shell.dart @@ -0,0 +1,48 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// 로그인 뒤 화면의 틀 — 아래 탭 막대가 웹의 전역 앱 레일에 해당한다. +/// +/// 탭은 켜진 모듈만, 모듈 목록 차례 그대로다. 탭마다 이동 기록을 따로 지킨다(StatefulShellRoute.indexedStack). +/// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 돌아간다. +/// 모양은 임시다 — 탭 막대 보드가 확정되면 이 위젯만 바꾼다. +class AppShell extends StatelessWidget { + const AppShell({ + required this.navigationShell, + required this.modules, + super.key, + }); + + final StatefulNavigationShell navigationShell; + + /// 켜진 모듈 — 탭 하나씩 (브랜치 차례와 같다) + final List modules; + + /// NavigationBar 는 탭이 둘 이상이어야 한다 + static const int _minTabs = 2; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: navigationShell, + bottomNavigationBar: modules.length < _minTabs + ? null + : NavigationBar( + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: (int index) => navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ), + destinations: [ + for (final AppModule module in modules) + NavigationDestination( + icon: Icon(module.icon), + selectedIcon: Icon(module.selectedIcon), + label: module.label, + ), + ], + ), + ); + } +} diff --git a/flutter/lib/core/router/module_registry.dart b/flutter/lib/core/router/module_registry.dart new file mode 100644 index 0000000..04b44b1 --- /dev/null +++ b/flutter/lib/core/router/module_registry.dart @@ -0,0 +1,36 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/modules/module_routes.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/settings/settings_routes.dart'; + +/// 모듈 id → 그 모듈의 라우트 묶음. 아직 만들지 않은 모듈은 null. +/// +/// 모듈을 켜면(app_modules.dart 의 `isEnabled`) 여기에 묶음을 적는다 — 켜진 모듈에 묶음이 없으면 +/// 라우터를 만들 때 멈춘다([enabledModuleRoutes]). enum 의 모든 값을 다뤄야 해서 새 모듈 id 도 빠뜨릴 수 없다. +ModuleRoutes? moduleRoutesOf(AppModuleId id) { + return switch (id) { + AppModuleId.mail => mailModuleRoutes, + AppModuleId.settings => settingsModuleRoutes, + AppModuleId.calendar => null, + AppModuleId.files => null, + AppModuleId.contacts => null, + }; +} + +/// 켜진 모듈의 라우트 묶음 — 탭 막대 차례 그대로 +List enabledModuleRoutes([ + List modules = kAppModules, +]) { + return [ + for (final AppModule module in enabledAppModules(modules)) + moduleRoutesOf(module.id) ?? + (throw StateError('켜진 모듈 ${module.id.name} 의 라우트 묶음이 없다')), + ]; +} + +/// 모듈들이 내놓은 설정 절 — 설정 모듈이 자기 라우트 아래에 붙인다 +List moduleSettingsSections(List routes) { + return [ + for (final ModuleRoutes module in routes) ...module.settingsSections, + ]; +} diff --git a/flutter/lib/core/router/session_redirect.dart b/flutter/lib/core/router/session_redirect.dart new file mode 100644 index 0000000..f10dfbe --- /dev/null +++ b/flutter/lib/core/router/session_redirect.dart @@ -0,0 +1,58 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/router/app_routes.dart'; +import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 세션 상태로 갈 화면을 고른다 — 라우터의 redirect 하나가 이것만 부른다(웹 `router/index.ts` 가드와 같은 결). +/// +/// 돌려주는 값: 옮길 경로, 지금 화면에 그대로 있으면 null. +/// +/// | 세션 | 화면 | +/// | --- | --- | +/// | 되살리는 중 · 되살리기 실패 | 시작 | +/// | 로그아웃 | 로그인 | +/// | 코드 단계 | 코드 | +/// | 제한 passwordChange · twoFactorSetup · 모르는 제한 | 그 단계 | +/// | 로그인(제한 없음) | 로그인 뒤 화면 — 로그인 전 화면 · 모르거나 꺼진 모듈의 주소면 메일 첫 화면으로 | +String? resolveSessionRedirect( + AsyncValue session, + String location, { + List modules = kAppModules, +}) { + final SessionState? state = session.value; + if (state == null) return _stayOr(location, AppRoutes.start); + + switch (state) { + case SignedOut(): + return _stayOr(location, AppRoutes.login); + case CodeRequired(): + return _stayOr(location, AppRoutes.loginCode); + case SignedIn(:final AuthSessionInfo info): + return switch (info.restriction) { + SessionRestriction.passwordChange => _stayOr( + location, + AppRoutes.passwordChange, + ), + SessionRestriction.twoFactorSetup => _stayOr( + location, + AppRoutes.twoFactorSetup, + ), + SessionRestriction.unknown => _stayOr(location, AppRoutes.restricted), + null => + _isSignedOutArea(location) || + enabledModuleOfPath(location, modules) == null + ? homeLocationOf(modules) + : null, + }; + } +} + +/// 로그인하지 않은 동안만 머무는 화면(시작 · 로그인 · 제한 단계)인가 +bool _isSignedOutArea(String location) => + location == AppRoutes.start || + location == AppRoutes.login || + location.startsWith('${AppRoutes.login}/'); + +String? _stayOr(String location, String target) => + location == target ? null : target; diff --git a/flutter/lib/core/storage/auth_tokens.dart b/flutter/lib/core/storage/auth_tokens.dart new file mode 100644 index 0000000..ef79d6d --- /dev/null +++ b/flutter/lib/core/storage/auth_tokens.dart @@ -0,0 +1,53 @@ +/// 앱 로그인 토큰 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"의 `tokens` 칸 +/// (`{ accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }`, 시각은 ISO 8601 UTC). +/// +/// 토큰 값은 로그 · 예외 문장에 남기지 않는다 — [toString] 도 값을 가린다. +class AuthTokens { + const AuthTokens({ + required this.accessToken, + required this.accessExpiresAt, + required this.refreshToken, + required this.refreshExpiresAt, + this.serverTime, + }); + + final String accessToken; + final DateTime accessExpiresAt; + final String refreshToken; + final DateTime refreshExpiresAt; + + /// 토큰을 실어 온 응답의 `Date` 머리(서버 시각) — 남은 시간을 서버 시계로 센다(기기 시계가 틀려도). + /// 응답에 없으면 null — 그때는 기기 시계와 견준다. + final DateTime? serverTime; + + /// 응답의 `tokens` 칸을 읽는다. 없거나(`null` — 코드 단계 · 갱신 유예) 모양이 틀리면 null. + static AuthTokens? tryParse(Object? json, {DateTime? serverTime}) { + if (json is! Map) return null; + final Object? access = json['accessToken']; + final Object? refresh = json['refreshToken']; + final DateTime? accessExpiresAt = _parseTime(json['accessExpiresAt']); + final DateTime? refreshExpiresAt = _parseTime(json['refreshExpiresAt']); + if (access is! String || access.isEmpty) return null; + if (refresh is! String || refresh.isEmpty) return null; + if (accessExpiresAt == null || refreshExpiresAt == null) return null; + return AuthTokens( + accessToken: access, + accessExpiresAt: accessExpiresAt, + refreshToken: refresh, + refreshExpiresAt: refreshExpiresAt, + serverTime: serverTime?.toUtc(), + ); + } + + /// 액세스 토큰을 쓸 수 있는 시간 — 서버 시계로 센 "받은 때부터 만료까지" (없으면 [fallbackNow] 와 견준다) + Duration accessLifetime(DateTime fallbackNow) => + accessExpiresAt.difference(serverTime ?? fallbackNow); + + static DateTime? _parseTime(Object? value) { + if (value is! String) return null; + return DateTime.tryParse(value)?.toUtc(); + } + + @override + String toString() => 'AuthTokens(***)'; +} diff --git a/flutter/lib/core/storage/first_run_marker.dart b/flutter/lib/core/storage/first_run_marker.dart new file mode 100644 index 0000000..455bf04 --- /dev/null +++ b/flutter/lib/core/storage/first_run_marker.dart @@ -0,0 +1,28 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// 첫 실행 표시 — 앱을 지우면 함께 지워지는 일반 저장소에 둔다. +/// +/// iOS Keychain 은 앱을 지워도 남는다. 다시 깐 앱의 첫 실행에 이 표시가 없으면 +/// 보안 저장소에 남은 토큰을 지운다(TokenStore.prepare — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일"). +/// Android 는 백업을 꺼서(AndroidManifest) 이 표시가 다른 기기로 복원되지 않는다. +abstract interface class FirstRunMarker { + Future isMarked(); + Future mark(); +} + +/// `shared_preferences` 구현 — 값은 참 · 거짓 하나뿐이다(토큰 · 개인 정보는 두지 않는다) +class PrefsFirstRunMarker implements FirstRunMarker { + PrefsFirstRunMarker([SharedPreferencesAsync? prefs]) + : _prefs = prefs ?? SharedPreferencesAsync(); + + /// 저장 키 + static const String key = 'first_run_done'; + + final SharedPreferencesAsync _prefs; + + @override + Future isMarked() async => await _prefs.getBool(key) ?? false; + + @override + Future mark() => _prefs.setBool(key, true); +} diff --git a/flutter/lib/core/storage/secure_store.dart b/flutter/lib/core/storage/secure_store.dart new file mode 100644 index 0000000..3369f4b --- /dev/null +++ b/flutter/lib/core/storage/secure_store.dart @@ -0,0 +1,43 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// 보안 저장소(Android Keystore · iOS Keychain)의 좁은 창구 — 토큰 보관이 쓰는 것만 둔다. +/// +/// 시험에서는 메모리 구현으로 바꿔 끼운다. 토큰은 이것으로만 디스크에 둔다 +/// (`SharedPreferences` 에 토큰 저장 금지 — 보안 규칙). +abstract interface class SecureStore { + Future read(String key); + Future write(String key, String value); + Future delete(String key); + + /// 이 앱이 둔 값을 모두 지운다 — 첫 실행에 남은 값 지우기 + Future deleteAll(); +} + +/// `flutter_secure_storage` 구현 +class FlutterSecureStore implements SecureStore { + const FlutterSecureStore([ + this._storage = const FlutterSecureStorage( + // 기본: RSA OAEP 로 감싼 AES-GCM (Android 6.0+). 생체 인증은 쓰지 않는다 + aOptions: AndroidOptions(), + // 기기를 처음 잠금 해제한 뒤에만 읽는다 · 다른 기기로 옮겨지지 않는다(iCloud 키체인 · 백업 제외) + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), + ), + ]); + + final FlutterSecureStorage _storage; + + @override + Future read(String key) => _storage.read(key: key); + + @override + Future write(String key, String value) => + _storage.write(key: key, value: value); + + @override + Future delete(String key) => _storage.delete(key: key); + + @override + Future deleteAll() => _storage.deleteAll(); +} diff --git a/flutter/lib/core/storage/token_store.dart b/flutter/lib/core/storage/token_store.dart new file mode 100644 index 0000000..8c635b9 --- /dev/null +++ b/flutter/lib/core/storage/token_store.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/storage/auth_tokens.dart'; +import 'package:arasystem_app/core/storage/first_run_marker.dart'; +import 'package:arasystem_app/core/storage/secure_store.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:flutter/services.dart'; + +/// 리프레시 토큰 저장 키 — 앱 전체에서 이 상수만 쓴다 +const String kRefreshTokenKey = 'refresh_token'; + +/// 액세스 토큰 만료 이만큼 전부터는 미리 갱신한다 +const Duration kAccessTokenRefreshMargin = Duration(seconds: 30); + +/// 끝난 세션의 까닭을 기억해 두는 수 — 늦게 돌아온 응답이 어느 세션 것인지 가를 만큼만 +const int _kEndedSessionMemory = 16; + +/// 로그아웃할 때 서버 세션을 끝내는 데 쓸 토큰 (읽지 못했으면 null) +typedef SignOutTokens = ({String? refreshToken, String? accessToken}); + +/// 앱 로그인 토큰 보관 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일". +/// +/// - 리프레시 토큰은 보안 저장소([SecureStore])에만, 액세스 토큰은 메모리에만 둔다. +/// - 새 리프레시 토큰은 받자마자 저장한다 — 서버는 1회용으로 회전하므로, 옛 토큰이 남으면 +/// 다음 실행의 갱신이 "이미 쓴 토큰"으로 세션을 끊는다(회전 직후 앱이 죽는 경우는 알려진 한계). +/// 저장이 실패하면 이번 실행 동안 메모리의 새 토큰으로 이어 가고 다음 저장 · 읽기 때 다시 쓴다 — 그 전에 앱이 죽으면 같은 한계다. +/// - 보안 저장소의 쓰기 · 지우기 · 읽기는 부른 차례대로 하나씩 한다 — 로그인 저장과 로그아웃 지우기가 엇갈려 끝난 세션의 토큰이 남지 않게. +/// - 만료는 기기 시계로 판단하지 않는다 — 받은 응답의 `Date`(서버 시각)로 남은 시간을 재고, 받은 때부터 지난 시간을 센다. +/// 기기 시계가 몇 시간 틀려도 매 요청 갱신하거나 끝난 토큰을 쓰지 않는다(시계를 그사이 바꾸면 401 뒤 갱신이 받쳐 준다). +/// - [generation](토큰 세대)은 토큰이 바뀔 때마다, [sessionGeneration](세션 세대)은 세션이 끝날 때만 오른다 — +/// "다른 요청이 이미 갱신했다"와 "그 요청을 보낸 세션이 끝났다(로그아웃 → 다른 직원 로그인)"를 가른다(core/api/auth_interceptor.dart). +class TokenStore { + TokenStore({ + required SecureStore secure, + required FirstRunMarker firstRun, + Clock clock = systemClock, + }) : _secure = secure, + _firstRun = firstRun, + _clock = clock; + + final SecureStore _secure; + final FirstRunMarker _firstRun; + final Clock _clock; + + String? _accessToken; + Duration? _accessLifetime; + DateTime? _accessReceivedAt; + String? _unsavedRefreshToken; + bool _hasRefreshToken = false; + bool _signedOutByUser = false; + int _generation = 0; + int _sessionGeneration = 0; + final Map _endedByUser = {}; + Future _storage = Future.value(); + + /// 앱을 띄우기 전에 한 번 부른다. + /// + /// 첫 실행 표시가 없으면(새로 깔았거나 다시 깔았다) 보안 저장소에 남은 값을 모두 지운다 — + /// iOS Keychain 은 앱을 지워도 남기 때문이다. 저장소를 읽지 못해도 로그아웃 상태로 시작한다. + Future prepare() { + return _serial(() async { + try { + if (!await _firstRun.isMarked()) { + await _secure.deleteAll(); + await _firstRun.mark(); + _hasRefreshToken = false; + return; + } + _hasRefreshToken = await _secure.read(kRefreshTokenKey) != null; + } on PlatformException { + // 키가 바뀌었거나 저장소가 깨졌다 — 다시 로그인하면 새로 쓴다 + _hasRefreshToken = false; + } + }); + } + + /// 리프레시 토큰을 갖고 있는가 — 로그인한 세션이 있다(서버에서 끝났을 수는 있다) + bool get hasSession => _hasRefreshToken; + + /// 사용자가 스스로 로그아웃했다(다음 로그인까지) + bool get signedOutByUser => _signedOutByUser; + + /// 메모리의 액세스 토큰 (앱을 새로 띄우면 없다) + String? get accessToken => _accessToken; + + /// 액세스 토큰이 있고, 받은 때부터 지난 시간이 쓸 수 있는 시간보다 [kAccessTokenRefreshMargin] 넘게 적은가 + bool get hasUsableAccessToken { + final Duration? lifetime = _accessLifetime; + final DateTime? receivedAt = _accessReceivedAt; + if (_accessToken == null || lifetime == null || receivedAt == null) { + return false; + } + final Duration elapsed = _clock().difference(receivedAt); + // 기기 시계를 뒤로 돌렸다 — 지난 시간을 모르니 미리 갱신한다 + if (elapsed.isNegative) return false; + return elapsed < lifetime - kAccessTokenRefreshMargin; + } + + /// 토큰 세대 — 저장 · 지우기마다 하나씩 오른다 + int get generation => _generation; + + /// 세션 세대 — 세션이 끝날 때(지우기 · 로그아웃)만 오른다. 새 로그인은 올리지 않는다 + int get sessionGeneration => _sessionGeneration; + + /// [sessionGeneration] 의 세션이 끝났다면 사용자가 스스로 끝냈는가 — 끝나지 않았거나 모르면 null + bool? endedByUser(int sessionGeneration) => _endedByUser[sessionGeneration]; + + /// 리프레시 토큰 — 저장하지 못한 새 토큰이 있으면 그것(이번에 다시 써 본다), 아니면 보안 저장소의 것 + Future readRefreshToken() { + return _serial(() async { + final String? unsaved = _unsavedRefreshToken; + if (unsaved != null) { + await _writeUnsaved(unsaved); + return unsaved; + } + return _secure.read(kRefreshTokenKey); + }); + } + + /// 새 토큰을 둔다 — 메모리는 곧바로 바꾸고, 리프레시 토큰은 보안 저장소에 쓴 뒤에 돌아온다. + /// + /// 쓰기가 실패하면 `PlatformException` 을 던지되 메모리의 토큰은 남긴다 — 부른 쪽이 정한다 + /// (로그인은 그 세션을 끝내고, 갱신은 이번 실행 동안 메모리로 이어 간다). + Future save(AuthTokens tokens) { + final DateTime now = _clock(); + _generation++; + _accessToken = tokens.accessToken; + _accessLifetime = tokens.accessLifetime(now); + _accessReceivedAt = now; + _unsavedRefreshToken = tokens.refreshToken; + _hasRefreshToken = true; + _signedOutByUser = false; + final String refreshToken = tokens.refreshToken; + return _serial(() async { + // 그사이 지웠거나 더 새 토큰을 받았으면 이 토큰은 쓰지 않는다 + if (_unsavedRefreshToken != refreshToken) return; + await _secure.write(kRefreshTokenKey, refreshToken); + if (_unsavedRefreshToken == refreshToken) _unsavedRefreshToken = null; + }); + } + + /// 토큰을 모두 지운다 — 세션 끝 · 로그인 저장 실패. 메모리는 곧바로 비운다(보안 저장소를 지우다 실패해도). + Future clear({bool byUser = false}) { + _endSession(byUser: byUser); + return _serial(() => _secure.delete(kRefreshTokenKey)); + } + + /// 로그아웃 — 메모리를 곧바로 비우고(이 기기는 그 순간 로그아웃이다) 보안 저장소의 리프레시 토큰을 읽어 지운다. + /// 서버 세션을 끝내는 데 쓸 토큰을 돌려준다. 저장소를 읽거나 지우지 못해도 던지지 않는다. + Future signOut() { + final String? accessToken = _accessToken; + final String? unsaved = _unsavedRefreshToken; + _endSession(byUser: true); + return _serial(() async { + // 로그아웃은 어떤 실패에도 끝나야 한다 — 저장소의 뜻밖의 예외도 삼킨다 + String? refreshToken = unsaved; + try { + refreshToken ??= await _secure.read(kRefreshTokenKey); + } on Object { + refreshToken = null; + } + try { + await _secure.delete(kRefreshTokenKey); + } on Object { + // 남은 값은 다음 실행의 갱신이 거절되면 지운다 + } + return (refreshToken: refreshToken, accessToken: accessToken); + }); + } + + void _endSession({required bool byUser}) { + _generation++; + _endedByUser[_sessionGeneration] = byUser; + if (_endedByUser.length > _kEndedSessionMemory) { + _endedByUser.remove(_endedByUser.keys.first); + } + _sessionGeneration++; + _accessToken = null; + _accessLifetime = null; + _accessReceivedAt = null; + _unsavedRefreshToken = null; + _hasRefreshToken = false; + _signedOutByUser = byUser; + } + + Future _writeUnsaved(String refreshToken) async { + try { + await _secure.write(kRefreshTokenKey, refreshToken); + if (_unsavedRefreshToken == refreshToken) _unsavedRefreshToken = null; + } on PlatformException { + // 이번에도 쓰지 못했다 — 메모리의 토큰으로 이어 간다 + } + } + + /// 보안 저장소 일을 부른 차례대로 하나씩 한다 — 앞 일의 실패는 뒤 일에 옮기지 않는다 + Future _serial(Future Function() operation) { + final Future result = _storage.then((_) => operation()); + _storage = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } +} diff --git a/flutter/lib/core/storage/token_store_provider.dart b/flutter/lib/core/storage/token_store_provider.dart new file mode 100644 index 0000000..51b7e23 --- /dev/null +++ b/flutter/lib/core/storage/token_store_provider.dart @@ -0,0 +1,12 @@ +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 토큰 보관 — main 이 [TokenStore.prepare] 를 마친 것으로 덮어쓴다 +/// (`ProviderScope(overrides: [tokenStoreProvider.overrideWithValue(store)])`). +/// +/// 준비(첫 실행 지우기)를 마치기 전에 토큰을 읽지 않게, 덮지 않으면 일부러 실패한다. +final Provider tokenStoreProvider = Provider( + (Ref ref) => throw UnimplementedError( + 'tokenStoreProvider 는 main 에서 준비한 TokenStore 로 덮어써야 한다', + ), +); diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..eab1dff --- /dev/null +++ b/flutter/lib/core/theme/app_colors.dart @@ -0,0 +1,116 @@ +import 'package:flutter/painting.dart'; + +/// 색 — 웹 `frontend/src/styles/tokens.css` 의 색을 그대로 옮긴다 (이름도 토큰 이름을 따른다). +/// +/// 화면은 이 상수를 직접 쓰지 않고 `Theme.of(context).colorScheme` · [AppExtraColors] 로 쓴다. +/// 웹 토큰을 바꾸면 여기도 함께 바꾼다 — 대응은 `app_theme.dart` 의 `_colorScheme` 한 곳이다. +abstract final class AppColors { + // ── 브랜드 ── + /// `--color-brand-surface` — 바탕(레일 · 로그인 패널 · 성공 알림) + static const Color brandSurface = Color(0xFF0B3640); + + /// `--color-brand-surface-active` + static const Color brandSurfaceActive = Color(0xFF155A64); + + /// `--color-brand` — 강조 단추 · 링크 · 진행률 + static const Color brand = Color(0xFF0E7C86); + + /// `--color-brand-strong` — 강조 글자 · 눌린 상태 + static const Color brandStrong = Color(0xFF0A5F67); + + /// `--color-brand-subtle` — 고른 행 · 지금 메일함 · 아바타 + static const Color brandSubtle = Color(0xFFE3F2F3); + + /// `--color-brand-tint` — 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕 + static const Color brandTint = Color(0xFFF3F8F8); + + /// `--color-on-brand` + static const Color onBrand = Color(0xFFFFFFFF); + + /// `--color-on-brand-text` — 브랜드 바탕 위 글자 + static const Color onBrandText = Color(0xFFE6EFF0); + + /// `--color-on-brand-muted` + static const Color onBrandMuted = Color(0xFFA3C2C6); + + /// `--color-on-brand-link` — 브랜드 바탕 위 링크 · 알림 안 단추 + static const Color onBrandLink = Color(0xFF9ED6DB); + + // ── 글자 ── + /// `--color-text` + static const Color text = Color(0xFF171C26); + + /// `--color-text-body` — 메일 본문 + static const Color textBody = Color(0xFF1F2733); + + /// `--color-text-strong` — 입력 이름 · 도구 단추 + static const Color textStrong = Color(0xFF344054); + + /// `--color-text-secondary` + static const Color textSecondary = Color(0xFF4A5363); + + /// `--color-text-muted` + static const Color textMuted = Color(0xFF667085); + + /// `--color-icon-muted` + static const Color iconMuted = Color(0xFF98A2B3); + + // ── 면 · 선 ── + /// `--color-surface` + static const Color surface = Color(0xFFFFFFFF); + + /// `--color-surface-panel` — 메일함 패널 + static const Color surfacePanel = Color(0xFFF8F9FB); + + /// `--color-surface-muted` — 받는 사람 토큰 · 파일 아이콘 + static const Color surfaceMuted = Color(0xFFF2F4F7); + + /// `--color-surface-sunken` — 목록 머리 줄 + static const Color surfaceSunken = Color(0xFFFBFBFC); + + /// `--color-border` + static const Color border = Color(0xFFE6E8EC); + + /// `--color-border-subtle` + static const Color borderSubtle = Color(0xFFEEF0F3); + + /// `--color-border-strong` + static const Color borderStrong = Color(0xFFD0D5DD); + + /// `--color-border-input` — 입력 칸 테두리 (흰 바탕 3:1 이상) + static const Color borderInput = Color(0xFF8A94A3); + + /// `--color-skeleton` — 불러오는 중 막대 · 진행률 바탕 + static const Color skeleton = Color(0xFFE4E7EC); + + // ── 상태 ── + /// `--color-danger` + static const Color danger = Color(0xFFB42318); + + /// `--color-danger-strong` + static const Color dangerStrong = Color(0xFF912018); + + /// `--color-danger-surface` + static const Color dangerSurface = Color(0xFFFEF3F2); + + /// `--color-warning-surface` + static const Color warningSurface = Color(0xFFFFF7E6); + + /// `--color-warning-text` + static const Color warningText = Color(0xFF7A5200); + + /// `--color-warning-border` + static const Color warningBorder = Color(0xFFF0D9A8); + + /// `--color-success-text` + static const Color successText = Color(0xFF067647); + + /// `--color-star` — 중요 표시 + static const Color star = Color(0xFFB7791F); + + /// 그림자 · 가림막의 바탕색 — `--shadow-*` 의 rgba(16, 24, 28, …) + static const Color shadow = Color(0xFF10181C); + + /// `--color-backdrop` rgba(11, 54, 64, 0.32) — 대화상자 뒤 가림막 + static const Color backdrop = Color(0x520B3640); +} diff --git a/flutter/lib/core/theme/app_fonts.dart b/flutter/lib/core/theme/app_fonts.dart new file mode 100644 index 0000000..f483e1b --- /dev/null +++ b/flutter/lib/core/theme/app_fonts.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// 앱 글꼴 — Pretendard 1.3.9 정적 OTF 넷(Regular · Medium · SemiBold · Bold — pubspec `fonts`). +/// +/// SIL OFL 1.1 이다. 앱과 함께 나눌 때 저작권 표시와 라이선스 글을 함께 둬야 해서 +/// (OFL 2조) [registerFontLicenses] 가 Flutter 라이선스 화면(`showLicensePage`)에 올린다. +abstract final class AppFonts { + /// pubspec 의 `family` 와 같아야 한다 + static const String family = 'Pretendard'; + + /// 라이선스 글 — npm `pretendard` 1.3.9 의 `dist/LICENSE.txt` 를 그대로 옮겼다 (pubspec `assets`) + static const String licenseAsset = 'assets/licenses/pretendard-ofl.txt'; + + /// 라이선스 화면에 보일 이름 + static const String licensePackageName = 'Pretendard'; + + /// 글꼴 라이선스를 라이선스 화면에 올린다 — 앱을 띄우기 전에 한 번 부른다. + /// + /// 글은 라이선스 화면을 열 때에만 읽는다(LicenseRegistry 가 늦게 부른다). + static void registerFontLicenses({AssetBundle? bundle}) { + final AssetBundle source = bundle ?? rootBundle; + LicenseRegistry.addLicense(() async* { + final String text = await source.loadString(licenseAsset); + yield LicenseEntryWithLineBreaks([licensePackageName], text); + }); + } +} diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart new file mode 100644 index 0000000..0d28bbf --- /dev/null +++ b/flutter/lib/core/theme/app_spacing.dart @@ -0,0 +1,55 @@ +/// 간격 · 모서리 · 크기 — 웹 `tokens.css` 의 `--space-*` · `--radius-*` 를 dp 로 옮긴다 (이름의 숫자 = dp). +/// +/// 화면은 숫자를 직접 적지 않고 이 상수를 쓴다. 휴대폰에서 쓰지 않을 값은 옮기지 않았다 — +/// 캔버스에서 새 간격이 정해지면 여기에 더한다. +library; + +/// 간격 (웹 `--space-N` 과 같은 이름 · 같은 값) +abstract final class AppSpacing { + static const double s2 = 2; + static const double s4 = 4; + static const double s6 = 6; + static const double s8 = 8; + static const double s10 = 10; + static const double s12 = 12; + static const double s14 = 14; + static const double s16 = 16; + static const double s20 = 20; + static const double s24 = 24; + static const double s28 = 28; + static const double s36 = 36; + static const double s40 = 40; + static const double s48 = 48; + static const double s56 = 56; +} + +/// 모서리 반지름 (웹 `--radius-*`) +abstract final class AppRadius { + /// `--radius-xs` — 받는 사람 토큰 + static const double xs = 4; + + /// `--radius-sm` + static const double sm = 6; + + /// `--radius-md` — 단추 · 입력 + static const double md = 8; + + /// `--radius-lg` — 알림 + static const double lg = 10; + + /// `--radius-xl` — 대화상자 + static const double xl = 12; +} + +/// 크기 +abstract final class AppSize { + /// 누를 수 있는 곳의 최소 크기 — Material 기준 48dp (웹 `--size-touch` 44px 보다 크다). + /// 웹의 "밀도 예외"는 앱에 옮기지 않는다 — 캔버스에서 따로 정한다. + static const double touchMin = 48; + + /// 선 두께 — `--border-width` 1px + static const double borderWidth = 1; + + /// 초점 고리 두께 — `--focus-ring-width` 2px + static const double focusRingWidth = 2; +} diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..45038b9 --- /dev/null +++ b/flutter/lib/core/theme/app_theme.dart @@ -0,0 +1,234 @@ +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/theme/app_fonts.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:flutter/material.dart'; + +/// 앱 테마 — 밝은 테마 하나(다크 모드는 첫 판 밖). +/// +/// 색은 시드 생성(`ColorScheme.fromSeed`)을 쓰지 않고 웹 토큰을 역할마다 손으로 대응한다 — +/// 시드는 브랜드 청록과 다른 색을 만들어 웹 화면과 어긋난다. +abstract final class AppTheme { + static ThemeData light() { + const ColorScheme scheme = _colorScheme; + final TextTheme textTheme = AppTypography.textTheme(); + final RoundedRectangleBorder controlShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ); + const Size touchSize = Size(AppSize.touchMin, AppSize.touchMin); + + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + fontFamily: AppFonts.family, + textTheme: textTheme, + scaffoldBackgroundColor: scheme.surface, + // 누르는 곳은 늘 48dp 이상 — 작은 단추도 누르는 영역을 48 로 넓힌다 + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.standard, + dividerTheme: const DividerThemeData( + color: AppColors.border, + thickness: AppSize.borderWidth, + space: AppSize.borderWidth, + ), + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + titleTextStyle: textTheme.titleMedium?.copyWith( + color: scheme.onSurface, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: touchSize, + shape: controlShape, + textStyle: textTheme.labelLarge, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: touchSize, + shape: controlShape, + side: const BorderSide(color: AppColors.borderStrong), + textStyle: textTheme.labelLarge, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: touchSize, + shape: controlShape, + textStyle: textTheme.labelLarge, + ), + ), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(minimumSize: touchSize), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s12, + vertical: AppSpacing.s14, + ), + border: _inputBorder(AppColors.borderInput), + enabledBorder: _inputBorder(AppColors.borderInput), + focusedBorder: _inputBorder( + scheme.primary, + width: AppSize.focusRingWidth, + ), + errorBorder: _inputBorder(scheme.error), + focusedErrorBorder: _inputBorder( + scheme.error, + width: AppSize.focusRingWidth, + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: scheme.inverseSurface, + contentTextStyle: textTheme.bodyMedium?.copyWith( + color: scheme.onInverseSurface, + ), + actionTextColor: scheme.inversePrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + ), + extensions: const [AppExtraColors.light], + ); + } + + static OutlineInputBorder _inputBorder( + Color color, { + double width = AppSize.borderWidth, + }) { + return OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: color, width: width), + ); + } + + /// 웹 토큰 → Material 3 색 역할 (대응을 바꾸면 웹 화면과 견주어 본다) + static const ColorScheme _colorScheme = ColorScheme( + brightness: Brightness.light, + // 강조 단추 · 링크 · 진행률 + primary: AppColors.brand, + onPrimary: AppColors.onBrand, + // 고른 행 · 지금 메일함 — 그 위 글자는 강조 글자 + primaryContainer: AppColors.brandSubtle, + onPrimaryContainer: AppColors.brandStrong, + // 브랜드 바탕(로그인 패널 · 성공 알림) + secondary: AppColors.brandSurface, + onSecondary: AppColors.onBrandText, + secondaryContainer: AppColors.brandTint, + onSecondaryContainer: AppColors.text, + tertiary: AppColors.brandSurfaceActive, + onTertiary: AppColors.onBrand, + error: AppColors.danger, + onError: AppColors.onBrand, + errorContainer: AppColors.dangerSurface, + onErrorContainer: AppColors.dangerStrong, + surface: AppColors.surface, + onSurface: AppColors.text, + onSurfaceVariant: AppColors.textSecondary, + surfaceContainerLowest: AppColors.surface, + surfaceContainerLow: AppColors.surfaceSunken, + surfaceContainer: AppColors.surfacePanel, + surfaceContainerHigh: AppColors.surfaceMuted, + surfaceContainerHighest: AppColors.skeleton, + // 입력 칸 테두리(3:1) · 나눔선 + outline: AppColors.borderInput, + outlineVariant: AppColors.border, + shadow: AppColors.shadow, + scrim: AppColors.backdrop, + // 스낵바(알림) — 웹 알림처럼 브랜드 바탕에 밝은 글자 + inverseSurface: AppColors.brandSurface, + onInverseSurface: AppColors.onBrandText, + inversePrimary: AppColors.onBrandLink, + // 높이에 따라 면에 색을 섞지 않는다 — 웹처럼 흰 면 그대로 + surfaceTint: AppColors.surface, + ); +} + +/// Material 색 역할에 없는 웹 토큰 — `Theme.of(context).extension()!` 로 쓴다 +@immutable +class AppExtraColors extends ThemeExtension { + const AppExtraColors({ + required this.textMuted, + required this.textBody, + required this.iconMuted, + required this.warningSurface, + required this.warningText, + required this.warningBorder, + required this.successText, + required this.star, + }); + + static const AppExtraColors light = AppExtraColors( + textMuted: AppColors.textMuted, + textBody: AppColors.textBody, + iconMuted: AppColors.iconMuted, + warningSurface: AppColors.warningSurface, + warningText: AppColors.warningText, + warningBorder: AppColors.warningBorder, + successText: AppColors.successText, + star: AppColors.star, + ); + + /// 시각 · 개수 · 보조 설명 + final Color textMuted; + + /// 메일 본문 + final Color textBody; + final Color iconMuted; + + /// 노란 안내 줄 + final Color warningSurface; + final Color warningText; + final Color warningBorder; + final Color successText; + + /// 중요 표시 + final Color star; + + @override + AppExtraColors copyWith({ + Color? textMuted, + Color? textBody, + Color? iconMuted, + Color? warningSurface, + Color? warningText, + Color? warningBorder, + Color? successText, + Color? star, + }) { + return AppExtraColors( + textMuted: textMuted ?? this.textMuted, + textBody: textBody ?? this.textBody, + iconMuted: iconMuted ?? this.iconMuted, + warningSurface: warningSurface ?? this.warningSurface, + warningText: warningText ?? this.warningText, + warningBorder: warningBorder ?? this.warningBorder, + successText: successText ?? this.successText, + star: star ?? this.star, + ); + } + + @override + AppExtraColors lerp(ThemeExtension? other, double t) { + if (other is! AppExtraColors) return this; + return AppExtraColors( + textMuted: Color.lerp(textMuted, other.textMuted, t)!, + textBody: Color.lerp(textBody, other.textBody, t)!, + iconMuted: Color.lerp(iconMuted, other.iconMuted, t)!, + warningSurface: Color.lerp(warningSurface, other.warningSurface, t)!, + warningText: Color.lerp(warningText, other.warningText, t)!, + warningBorder: Color.lerp(warningBorder, other.warningBorder, t)!, + successText: Color.lerp(successText, other.successText, t)!, + star: Color.lerp(star, other.star, t)!, + ); + } +} diff --git a/flutter/lib/core/theme/app_typography.dart b/flutter/lib/core/theme/app_typography.dart new file mode 100644 index 0000000..58f2c1d --- /dev/null +++ b/flutter/lib/core/theme/app_typography.dart @@ -0,0 +1,113 @@ +import 'package:arasystem_app/core/theme/app_fonts.dart'; +import 'package:flutter/material.dart'; + +/// 글자 크기 · 굵기 · 줄 높이 — 바꿀 때는 이 파일 한 곳만 고친다. +/// +/// 정한 값(`docs/design/app/README.md` "크기 · 글꼴" — 2026-11-05 확정): 화면 글 15 · 입력 칸 16(`bodyLarge` — TextField 의 기본) +/// · 메일 본문 15 · 줄 간격 1.75([lineHeightBody] — 본문은 WebView 가 그린다) · 위 막대 제목 18(`titleMedium`). +/// 그 밖의 역할(제목 · 꼬리표)은 **임시 값**이다 — 화면 작업 때 보드와 견주어 정한다. +/// 화면은 숫자를 적지 않고 `Theme.of(context).textTheme` 의 역할 이름으로만 쓴다. +abstract final class AppTypography { + // ── 크기 (sp — 기기 글자 크기 설정을 따라 커진다) ── + static const double display = 28; + static const double headline = 22; + static const double titleLarge = 20; + + /// 위 막대 제목 (확정) + static const double titleMedium = 18; + static const double titleSmall = 15; + + /// 입력 칸 (확정) + static const double bodyLarge = 16; + + /// 화면 글 · 메일 본문 (확정) + static const double bodyMedium = 15; + static const double bodySmall = 13; + static const double labelLarge = 15; + static const double labelMedium = 13; + static const double labelSmall = 12; + + // ── 줄 높이 (웹 `--line-height-*`) ── + static const double lineHeightBase = 1.5; + static const double lineHeightHeading = 1.4; + static const double lineHeightTight = 1.3; + + /// 메일 본문 줄 간격 (웹 `--line-height-body` · 확정) + static const double lineHeightBody = 1.75; + + // ── 자간 (웹 `--letter-spacing-*` 의 em 값 — 크기를 곱해 dp 로 바꾼다) ── + static const double letterSpacingTightEm = -0.01; + static const double letterSpacingHeadingEm = -0.02; + + // ── 굵기 — pubspec 에 넣은 Pretendard 넷과 같다 ── + static const FontWeight regular = FontWeight.w400; + static const FontWeight medium = FontWeight.w500; + static const FontWeight semiBold = FontWeight.w600; + static const FontWeight bold = FontWeight.w700; + + /// 앱의 글자 체계 — 색은 테마가 입힌다 (`ThemeData.textTheme` 에 색을 넣지 않는다) + static TextTheme textTheme() { + return const TextTheme( + displaySmall: TextStyle( + fontSize: display, + fontWeight: bold, + height: lineHeightTight, + letterSpacing: display * letterSpacingHeadingEm, + ), + headlineSmall: TextStyle( + fontSize: headline, + fontWeight: bold, + height: lineHeightHeading, + letterSpacing: headline * letterSpacingHeadingEm, + ), + titleLarge: TextStyle( + fontSize: titleLarge, + fontWeight: semiBold, + height: lineHeightHeading, + letterSpacing: titleLarge * letterSpacingHeadingEm, + ), + titleMedium: TextStyle( + fontSize: titleMedium, + fontWeight: semiBold, + height: lineHeightHeading, + letterSpacing: titleMedium * letterSpacingTightEm, + ), + titleSmall: TextStyle( + fontSize: titleSmall, + fontWeight: semiBold, + height: lineHeightHeading, + letterSpacing: titleSmall * letterSpacingTightEm, + ), + bodyLarge: TextStyle( + fontSize: bodyLarge, + fontWeight: regular, + height: lineHeightBase, + ), + bodyMedium: TextStyle( + fontSize: bodyMedium, + fontWeight: regular, + height: lineHeightBase, + ), + bodySmall: TextStyle( + fontSize: bodySmall, + fontWeight: regular, + height: lineHeightBase, + ), + labelLarge: TextStyle( + fontSize: labelLarge, + fontWeight: semiBold, + height: lineHeightTight, + ), + labelMedium: TextStyle( + fontSize: labelMedium, + fontWeight: medium, + height: lineHeightTight, + ), + labelSmall: TextStyle( + fontSize: labelSmall, + fontWeight: medium, + height: lineHeightTight, + ), + ).apply(fontFamily: AppFonts.family); + } +} diff --git a/flutter/lib/core/utils/.gitkeep b/flutter/lib/core/utils/.gitkeep deleted file mode 100644 index 3cc233d..0000000 --- a/flutter/lib/core/utils/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -// Empty directory placeholder diff --git a/flutter/lib/core/utils/clock.dart b/flutter/lib/core/utils/clock.dart new file mode 100644 index 0000000..9949f9c --- /dev/null +++ b/flutter/lib/core/utils/clock.dart @@ -0,0 +1,7 @@ +/// 지금 시각을 주는 함수 — 시간에 기대는 로직(토큰 만료 · 알림 겹침 · 코드 단계 10분)은 이것을 주입받는다. +/// +/// 시험은 손으로 돌리는 시계를 넣어 고정 대기 없이 시간을 넘긴다 (테스트 규칙 "결정적"). +typedef Clock = DateTime Function(); + +/// 기본 시계 +DateTime systemClock() => DateTime.now(); diff --git a/flutter/lib/core/utils/provider_retry.dart b/flutter/lib/core/utils/provider_retry.dart new file mode 100644 index 0000000..47bcf5b --- /dev/null +++ b/flutter/lib/core/utils/provider_retry.dart @@ -0,0 +1,6 @@ +/// 프로바이더의 실패를 저절로 다시 하지 않는다 — 늘 null(다시 하지 않음)을 돌려준다. +/// +/// Riverpod 3 은 `retry` 를 주지 않으면(null) 기본으로 다시 한다(실패한 프로바이더를 10번까지 다시 만든다 — +/// 그때마다 요청을 다시 보낸다). 앱은 ProviderScope(main.dart) · 시험의 ProviderContainer 에 이것을 걸어 끄고, +/// 다시 하기는 화면의 [다시 시도]가 한다. +Duration? noAutomaticRetry(int retryCount, Object error) => null; diff --git a/flutter/lib/features/auth/data/models/app_login_result.dart b/flutter/lib/features/auth/data/models/app_login_result.dart new file mode 100644 index 0000000..6aae1f2 --- /dev/null +++ b/flutter/lib/features/auth/data/models/app_login_result.dart @@ -0,0 +1,53 @@ +import 'package:arasystem_app/core/storage/auth_tokens.dart'; +import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; + +/// 로그인 단계의 결과 (계약 5절 "로그인" — `status`) +enum LoginStatus { + /// 로그인했다 — 토큰이 실려 온다 + signedIn, + + /// 비밀번호는 맞고 2단계 인증 코드가 필요하다 — 토큰 없음, `email` · `name` 은 null + twoFactorRequired, +} + +/// `POST /api/auth/app/login` 의 응답 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인" +/// (웹 login 과 같은 평평한 칸 `status` · `email` · `name` · `restriction` · `twoFactorDeadline` + `tokens`) +class AppLoginResult { + const AppLoginResult({required this.status, this.session, this.tokens}); + + final LoginStatus status; + + /// `signedIn` 일 때 직원 · 세션 칸 + final AuthSessionInfo? session; + + /// `signedIn` 일 때만 — 코드 단계는 null + final AuthTokens? tokens; + + /// 응답을 읽는다. `signedIn` 인데 세션 칸 · 토큰이 없으면 서버와 계약이 어긋난 것이라 [FormatException]. + /// [serverTime] 은 응답의 `Date` 머리 — 토큰의 남은 시간을 서버 시계로 잰다. + factory AppLoginResult.fromJson( + Map json, { + DateTime? serverTime, + }) { + final Object? status = json['status']; + if (status == LoginStatus.twoFactorRequired.name) { + return const AppLoginResult(status: LoginStatus.twoFactorRequired); + } + if (status != LoginStatus.signedIn.name) { + throw const FormatException('로그인 응답의 status 를 알 수 없다'); + } + final AuthSessionInfo? session = AuthSessionInfo.tryParse(json); + final AuthTokens? tokens = AuthTokens.tryParse( + json['tokens'], + serverTime: serverTime, + ); + if (session == null || tokens == null) { + throw const FormatException('signedIn 응답에 직원 · 토큰 칸이 없다'); + } + return AppLoginResult( + status: LoginStatus.signedIn, + session: session, + tokens: tokens, + ); + } +} diff --git a/flutter/lib/features/auth/data/models/auth_session_info.dart b/flutter/lib/features/auth/data/models/auth_session_info.dart new file mode 100644 index 0000000..c4114c5 --- /dev/null +++ b/flutter/lib/features/auth/data/models/auth_session_info.dart @@ -0,0 +1,69 @@ +/// 제한된 세션 (계약 6절) — 로그인은 됐지만 그 단계를 마칠 때까지 다른 API 는 403 `AUTH_010` 이다 +enum SessionRestriction { + /// 새 비밀번호를 정할 때까지 + passwordChange, + + /// 2단계 인증을 켤 때까지 + twoFactorSetup, + + /// 앱이 모르는 제한 — 웹처럼 갱신 · 다시 보내기 없이 사전 문구와 [다른 계정으로 로그인]만 보인다 + unknown; + + /// 응답 칸을 읽는다 — 없거나 빈 값이면 제한 없음(null), 아는 이름이면 그 이름, 그 밖은 [unknown] + static SessionRestriction? parse(Object? value) { + if (value == null || value == '') return null; + for (final SessionRestriction restriction in SessionRestriction.values) { + if (restriction != unknown && restriction.name == value) { + return restriction; + } + } + return unknown; + } +} + +/// 로그인한 직원과 세션 칸 — 로그인(`signedIn`) · me · 갱신 응답이 함께 싣는다(계약 1 · 6절) +class AuthSessionInfo { + const AuthSessionInfo({ + required this.email, + required this.name, + this.restriction, + this.twoFactorDeadline, + }); + + final String email; + final String name; + + /// 제한 — null 이면 제한 없음 + final SessionRestriction? restriction; + + /// 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각(UTC) — 안내 줄은 그 1ms 앞 날짜까지 + final DateTime? twoFactorDeadline; + + /// 응답의 세션 칸을 읽는다 — `email` · `name` 이 없으면(코드 단계 · 토큰만 실린 갱신 응답) null + static AuthSessionInfo? tryParse(Object? json) { + if (json is! Map) return null; + final Object? email = json['email']; + final Object? name = json['name']; + if (email is! String || email.isEmpty || name is! String) return null; + final Object? deadline = json['twoFactorDeadline']; + return AuthSessionInfo( + email: email, + name: name, + restriction: SessionRestriction.parse(json['restriction']), + twoFactorDeadline: deadline is String + ? DateTime.tryParse(deadline)?.toUtc() + : null, + ); + } + + @override + bool operator ==(Object other) => + other is AuthSessionInfo && + other.email == email && + other.name == name && + other.restriction == restriction && + other.twoFactorDeadline == twoFactorDeadline; + + @override + int get hashCode => Object.hash(email, name, restriction, twoFactorDeadline); +} diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart new file mode 100644 index 0000000..73f20f8 --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -0,0 +1,107 @@ +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/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'; + +/// 로그인 화면이 칸 아래 · 위쪽 상자에 직접 보이는 실패 — 알림을 띄우지 않는다 +/// (계약 5절 "클라이언트": `AUTH_004` 위쪽 상자, `AUTH_006` · `AUTH_007` 코드 칸 아래, `VAL_001` 칸 검사) +const Set kLoginInlineCodes = { + 'AUTH_004', + 'AUTH_006', + 'AUTH_007', + 'VAL_001', +}; + +/// 응답이 계약과 다른 모양일 때의 코드 — 서버 쪽 문제로 본다 +const String kMalformedResponseCode = 'SYS_001'; + +/// 앱 로그인 API — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"(서버는 아직 없다 — 시험은 가짜 서버로 한다). +/// +/// 응답 언래핑과 에러 코드는 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다. +/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 [kMalformedResponseCode]. +/// 비밀번호 · 코드 · 토큰은 로그 · 예외에 싣지 않는다 — 비밀이 든 요청(`ApiOptions.secret`)의 실패 예외는 본문과 Bearer 를 비운 채 온다. +class AuthRepository { + const AuthRepository(this._dio, this._refresher); + + final Dio _dio; + final TokenRefresher _refresher; + + /// 로그인 — 2단계 인증을 켠 직원은 코드 단계에서 [otpCode] 와 비밀번호를 한 번 더 보낸다(계약 5절) + Future login({ + required String email, + required String password, + String? otpCode, + }) async { + final Response response = await _dio.post( + ApiPaths.appLogin, + data: { + 'email': email, + 'password': password, + 'otpCode': ?otpCode, + }, + // 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비워 던진다(인증 인터셉터가 지운다) + options: ApiOptions.secret(silent: kLoginInlineCodes), + ); + final Object? body = response.data; + if (body is Map) { + try { + return AppLoginResult.fromJson( + body, + serverTime: serverTimeOf(response), + ); + } on FormatException { + // 아래에서 같은 실패로 던진다 + } + } + throw _malformed(response); + } + + /// 지금 세션 — 직원 · 제한 · 유예. [silent] 면 실패를 알리지 않는다(세션 되살리기) + Future me({bool silent = false}) async { + final Response response = await _dio.get( + ApiPaths.me, + options: silent ? ApiOptions.silent() : null, + ); + final AuthSessionInfo? info = AuthSessionInfo.tryParse(response.data); + if (info == null) throw _malformed(response); + return info; + } + + /// 지금 세션을 인증 인터셉터 줄 밖에서 묻는다 — `AUTH_010` 을 다시 맞출 때만 쓴다 + /// (인터셉터가 그 결과를 기다리는 중이라 같은 줄로 보내면 서로 기다린다). 알림은 띄우지 않는다. + Future readSessionDirect() async { + final Map body = await _refresher.readSession(); + final AuthSessionInfo? info = AuthSessionInfo.tryParse(body); + if (info == null) { + throw apiFailure( + RequestOptions(path: ApiPaths.me), + kMalformedResponseCode, + ); + } + return info; + } + + /// 토큰을 새로 받는다 — 진행 중인 갱신이 있으면 함께 기다린다(한 번에 하나). + /// 제한을 푼 직후(계약 6절 — 곧바로 갱신해 새 토큰) · 옛 토큰의 `AUTH_010` 에서 부른다. + Future refresh() => _refresher.refresh(); + + /// 서버 세션을 끝낸다 — 리프레시 토큰을 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다. + /// 서버는 어느 쪽도 맞지 않아도 성공으로 답한다. 알림은 띄우지 않는다. + Future logout({ + required String? refreshToken, + required String? accessToken, + }) => + postAppLogout(_dio, refreshToken: refreshToken, accessToken: accessToken); + + DioException _malformed(Response response) => withoutSecrets( + apiFailure( + response.requestOptions, + kMalformedResponseCode, + response: response, + ), + ); +} diff --git a/flutter/lib/features/auth/presentation/providers/login_secret.dart b/flutter/lib/features/auth/presentation/providers/login_secret.dart new file mode 100644 index 0000000..cbf5ea8 --- /dev/null +++ b/flutter/lib/features/auth/presentation/providers/login_secret.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/utils/clock.dart'; + +/// 로그인 때 넣은 비밀번호를 쥐고 있는 시간 — 웹과 같다(계약 5절 코드 단계 · 6절 제한 단계) +const Duration kLoginSecretLifetime = Duration(minutes: 10); + +/// 로그인 때 넣은 주소 · 비밀번호 — 화면 상태(Riverpod 상태)에 두지 않고 세션 노티파이어 메모리에만 둔다. +/// +/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다). +/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 마지막으로 확인하고 10분. +/// 10분은 타이머와 시계 둘 다로 본다 — 앱이 뒤에 있는 동안 타이머가 늦어도 읽을 때 시계로 다시 본다. +class LoginSecret { + LoginSecret({ + Clock clock = systemClock, + Duration lifetime = kLoginSecretLifetime, + }) : _clock = clock, + _lifetime = lifetime; + + final Clock _clock; + final Duration _lifetime; + + String? _email; + String? _password; + DateTime? _confirmedAt; + Timer? _timer; + + /// 서버가 비밀번호를 맞다고 본 때 쥔다 — 이미 쥐고 있으면 바꾼다 + void hold(String email, String password) { + clear(); + _email = email; + _password = password; + renew(); + } + + /// 서버가 다시 확인했다 — 10분을 이때부터 다시 센다 + void renew() { + if (_password == null) return; + _confirmedAt = _clock(); + _timer?.cancel(); + _timer = Timer(_lifetime, clear); + } + + /// 쥐고 있는 주소 · 비밀번호 — 없거나 10분이 지났으면 null(지난 것은 지운다) + ({String email, String password})? read() { + final String? email = _email; + final String? password = _password; + final DateTime? confirmedAt = _confirmedAt; + if (email == null || password == null || confirmedAt == null) return null; + if (_clock().difference(confirmedAt) >= _lifetime) { + clear(); + return null; + } + return (email: email, password: password); + } + + /// 쥐고 있는가 (10분이 지났는지는 [read] 가 본다) + bool get isHeld => _password != null; + + void clear() { + _timer?.cancel(); + _timer = null; + _email = null; + _password = null; + _confirmedAt = null; + } +} diff --git a/flutter/lib/features/auth/presentation/providers/session_provider.dart b/flutter/lib/features/auth/presentation/providers/session_provider.dart new file mode 100644 index 0000000..9661534 --- /dev/null +++ b/flutter/lib/features/auth/presentation/providers/session_provider.dart @@ -0,0 +1,381 @@ +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/dio_provider.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/push/push_registration.dart'; +import 'package:arasystem_app/core/storage/auth_tokens.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/utils/provider_retry.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:arasystem_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 인증 저장소 +final Provider authRepositoryProvider = + Provider( + (Ref ref) => AuthRepository( + ref.watch(dioProvider), + ref.watch(tokenRefresherProvider), + ), + ); + +/// 세션 — 앱에 하나. 라우터 redirect 가 이것을 보고 화면을 고른다. +/// +/// 되살리기가 장애로 실패해도 저절로 다시 하지 않는다(`noAutomaticRetry` — Riverpod 3 은 `retry` 를 주지 않으면 기본으로 다시 한다). +/// 시작 화면이 [SessionNotifier.retry] 를 부른다. 앱 전체도 ProviderScope 의 `retry` 로 끈다(main.dart). +final AsyncNotifierProvider sessionProvider = + AsyncNotifierProvider( + SessionNotifier.new, + retry: noAutomaticRetry, + ); + +/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 로그아웃 · 세션 끝 · 제한 다시 맞추기. +/// +/// 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일"대로 한다. +/// - 실패는 늘 `DioException`(`error` 에 `ApiException`)으로 던진다 — 화면이 `apiErrorCodeOf` 로 갈라 칸 아래 · 위쪽 상자에 보인다. +/// 알림은 인터셉터가 이미 띄웠거나(알림을 끄지 않은 코드) 끈 것이다 — 여기서 문구를 만들지 않는다. +/// 비밀이 든 요청(로그인 · 코드)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다. +/// - **시도 세대**([_epoch]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · 제한 다시 맞추기의 +/// 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게). +class SessionNotifier extends AsyncNotifier { + LoginSecret? _secretHolder; + Future? _rechecking; + int _epoch = 0; + + LoginSecret get _secret => + _secretHolder ??= LoginSecret(clock: ref.read(clockProvider)); + + AuthRepository get _repository => ref.read(authRepositoryProvider); + + TokenStore get _store => ref.read(tokenStoreProvider); + + TokenRefresher get _refresher => ref.read(tokenRefresherProvider); + + PushRegistrar get _push => ref.read(pushRegistrarProvider); + + @override + Future build() async { + final StreamSubscription subscription = ref + .watch(sessionEventsProvider) + .stream + .listen(_onEvent); + ref.onDispose(() { + unawaited(subscription.cancel()); + _secretHolder?.clear(); + }); + + final TokenStore store = ref.watch(tokenStoreProvider); + if (!store.hasSession) return const SignedOut(); + return _restore(); + } + + /// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 새 비밀번호 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null) + ({String email, String password})? get heldLogin => _secret.read(); + + /// 되살리기가 장애로 실패했을 때 다시 한다 — 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다 + void retry() { + _refresher.forgetFailure(); + ref.invalidateSelf(); + } + + /// 비밀번호 단계 + Future signIn({required String email, required String password}) async { + final int epoch = _epoch; + final AppLoginResult result = await _repository.login( + email: email, + password: password, + ); + await _apply(result, epoch: epoch, email: email, password: password); + } + + /// 코드 단계 — 쥐고 있는 비밀번호와 함께 보낸다(계약 5절). + /// + /// 10분이 지났으면 보내지 않고 첫 단계로 돌아간다. `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다. + Future submitCode(String otpCode) async { + final ({String email, String password})? held = _secret.read(); + if (held == null) { + state = const AsyncData( + SignedOut(reason: SignOutReason.codeTimedOut), + ); + return; + } + final int epoch = _epoch; + final AppLoginResult result; + try { + result = await _repository.login( + email: held.email, + password: held.password, + otpCode: otpCode, + ); + } on DioException catch (error) { + // 그사이 [다른 계정으로 로그인] · 로그아웃했으면 지금 화면을 건드리지 않는다 + if (epoch == _epoch && + ref.mounted && + apiErrorCodeOf(error) == 'AUTH_004') { + _secret.clear(); + state = const AsyncData(SignedOut()); + } + rethrow; + } + await _apply( + result, + epoch: epoch, + email: held.email, + password: held.password, + ); + } + + /// [다른 계정으로 로그인] — 리프레시 토큰이 있으면(로그인한 세션 · 되살리는 중 · 되살리기 실패) 로그아웃하고, + /// 코드 단계면 쥔 비밀번호만 지운다. 진행 중인 로그인 · 코드의 늦은 결과는 버린다. + Future switchAccount() async { + if (_store.hasSession || state.value is SignedIn) { + await signOut(reason: SignOutReason.switchAccount); + return; + } + _epoch++; + _secret.clear(); + state = const AsyncData( + SignedOut(reason: SignOutReason.switchAccount), + ); + } + + /// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다. + /// 서버에 닿지 못해도 이 기기는 로그아웃 상태다. + Future signOut({SignOutReason reason = SignOutReason.none}) async { + _epoch++; + _secret.clear(); + // 메모리의 토큰은 이 부름 안에서 곧바로 지워진다(보안 저장소 읽기 · 지우기는 뒤에 — 실패해도 던지지 않는다) + final Future clearing = _store.signOut(); + state = AsyncData(SignedOut(reason: reason)); + final SignOutTokens tokens = await clearing; + unawaited(_unregisterPushQuietly()); + if (tokens.refreshToken == null && tokens.accessToken == null) return; + try { + await _repository.logout( + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + ); + } on DioException { + // 서버 세션은 쓰지 않으면 미사용 14일에 끝난다 + } + } + + /// 제한(`AUTH_010`)을 다시 맞춘다 — 웹 `recheckRestriction` 과 같은 결. 겹친 요청은 하나로 묶는다. + /// + /// 세션을 인증 인터셉터 줄 밖에서 묻는다(인터셉터가 이 결과를 기다리는 중이라). + /// - 제한이 있으면 그 단계로 옮긴다 → [RestrictionCheck.restricted] + /// - 제한이 없다는데 `AUTH_010` 이면 옛 토큰이다 — 갱신해 새 토큰을 받는다 → [RestrictionCheck.renewed] + /// - me 가 401(액세스 토큰이 끝남)이면 갱신해 본다(같은 규칙) — 갱신이 거절되면 세션 끝이 로그인 화면으로 옮긴다 → [RestrictionCheck.stale] + /// - 그사이 세션이 바뀌었으면 결과를 쓰지 않는다 → [RestrictionCheck.stale], 확인하지 못했으면 [RestrictionCheck.unknown] + Future recheck() { + return _rechecking ??= _recheck().whenComplete(() => _rechecking = null); + } + + Future _recheck() async { + final int epoch = _epoch; + if (state.value is! SignedIn) return RestrictionCheck.stale; + bool isCurrent() => + epoch == _epoch && ref.mounted && state.value is SignedIn; + + AuthSessionInfo? info; + try { + info = await _repository.readSessionDirect(); + } on DioException catch (error) { + if (!isCurrent()) return RestrictionCheck.stale; + // 액세스 토큰이 끝났으면 갱신해 본다(아래) — 그 밖의 장애는 확인하지 못한 것이다 + if (apiErrorCodeOf(error) != 'AUTH_001') return RestrictionCheck.unknown; + } + if (!isCurrent()) return RestrictionCheck.stale; + if (info != null && info.restriction != null) { + _setSignedIn(info); + return RestrictionCheck.restricted; + } + + final RefreshOutcome outcome = await _repository.refresh(); + if (!isCurrent()) return RestrictionCheck.stale; + switch (outcome) { + case RefreshSucceeded(:final Map? body): + final AuthSessionInfo? refreshed = + AuthSessionInfo.tryParse(body) ?? info; + if (refreshed == null) return RestrictionCheck.renewed; + _setSignedIn(refreshed); + return refreshed.restriction == null + ? RestrictionCheck.renewed + : RestrictionCheck.restricted; + case RefreshRejected(): + case RefreshDiscarded(): + // 세션 끝 흐름이 로그인 화면으로 옮긴다 + return RestrictionCheck.stale; + case RefreshFailed(): + return RestrictionCheck.unknown; + } + } + + Future _restore() async { + final int epoch = _epoch; + final AuthSessionInfo info; + try { + info = await _repository.me(silent: true); + } on DioException catch (error) { + // 되살리는 사이 [다른 계정으로 로그인] · 로그아웃했다 — 그 상태를 그대로 둔다 + if (epoch != _epoch) return _currentSignedOut(); + final String? code = apiErrorCodeOf(error); + if (code == 'AUTH_001' || code == 'AUTH_002') { + await _clearQuietly(); + return const SignedOut(); + } + // 연결 끊김 · 서버 장애 — 세션은 그대로 두고 시작 화면이 [다시 시도]를 보인다 + rethrow; + } + if (epoch != _epoch) return _currentSignedOut(); + _afterSignedIn(info, wasReady: false); + return SignedIn(info); + } + + /// 되살리는 사이 바뀐 로그아웃 상태 — build 의 결과가 그 상태를 덮어쓰지 않게 같은 값을 돌려준다 + SessionState _currentSignedOut() { + final SessionState? current = state.value; + return current is SignedOut ? current : const SignedOut(); + } + + Future _apply( + AppLoginResult result, { + required int epoch, + required String email, + required String password, + }) async { + if (!ref.mounted || epoch != _epoch) { + // 그사이 로그아웃 · [다른 계정으로 로그인] · 세션 끝 — 늦게 온 결과는 쓰지 않는다 + final AuthTokens? tokens = result.tokens; + if (tokens != null) unawaited(_logoutQuietly(tokens)); + return; + } + switch (result.status) { + case LoginStatus.twoFactorRequired: + _secret.hold(email, password); + state = AsyncData(CodeRequired(email)); + case LoginStatus.signedIn: + final AuthTokens tokens = result.tokens!; + // 저장은 메모리를 곧바로 바꾸고 세대를 올린다 — 이 저장의 세대는 기다리기 전에 읽는다 + final Future saving = _store.save(tokens); + final int savedGeneration = _store.generation; + try { + await saving; + } on PlatformException { + // 받은 토큰을 둘 곳이 없다 — 이 기기의 토큰을 지우고 그 세션도 끝낸 뒤 실패로 알린다 + if (_store.generation == savedGeneration) await _clearQuietly(); + unawaited(_logoutQuietly(tokens)); + throw apiFailure( + RequestOptions(path: ApiPaths.appLogin), + kMalformedResponseCode, + ); + } + if (!ref.mounted || epoch != _epoch) { + // 저장하는 사이 로그아웃했다 — 그 뒤에 다른 일이 없었으면 방금 둔 토큰을 지우고, 그 세션은 끝낸다 + if (_store.generation == savedGeneration) await _clearQuietly(); + unawaited(_logoutQuietly(tokens)); + return; + } + final AuthSessionInfo info = result.session!; + if (info.restriction != null) { + // 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다 + _secret.hold(email, password); + } + _setSignedIn(info); + } + } + + /// 로그인한 상태로 둔다 — 제한이 풀렸으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다 + void _setSignedIn(AuthSessionInfo info) { + final SessionState? previous = state.value; + final bool wasReady = + previous is SignedIn && previous.info.restriction == null; + state = AsyncData(SignedIn(info)); + _afterSignedIn(info, wasReady: wasReady); + } + + void _afterSignedIn(AuthSessionInfo info, {required bool wasReady}) { + if (info.restriction != null) return; + // 제한이 풀렸다 — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다(계약 1절 "앱 로그인 › 앱이 할 일") + _secret.clear(); + if (!wasReady) unawaited(_registerPush()); + } + + Future _registerPush() async { + try { + await _push.register(); + } on Object { + // 알림 등록이 안 돼도 로그인은 그대로다 — 다음 로그인 · 되살리기 때 다시 한다 + } + } + + Future _unregisterPushQuietly() async { + try { + await _push.unregister().timeout(kPushUnregisterTimeout); + } on Object { + // 이 기기의 알림 토큰만 남는다 — 서버의 기기 줄은 logout 이 세션과 함께 지운다 + } + } + + void _onEvent(SessionEvent event) { + final SessionState? current = state.value; + switch (event) { + case SessionRecheckNeeded(): + if (current is! SignedIn) { + event.reply(RestrictionCheck.stale); + return; + } + unawaited( + recheck().then( + event.reply, + onError: (Object _, StackTrace _) => + event.reply(RestrictionCheck.unknown), + ), + ); + case SessionEnded(): + if (current is! SignedIn) return; + _epoch++; + _secret.clear(); + state = const AsyncData( + SignedOut(reason: SignOutReason.expired), + ); + case SessionRefreshed(:final Map body): + // 갱신 응답의 제한 · 유예로 맞춘다(계약 6절 — 서버가 갱신 때마다 엔진 기준으로 다시 본다) + if (current is! SignedIn) return; + final AuthSessionInfo? info = AuthSessionInfo.tryParse(body); + if (info != null && + info.email == current.info.email && + info != current.info) { + _setSignedIn(info); + } + } + } + + Future _logoutQuietly(AuthTokens tokens) async { + try { + await _repository.logout( + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + ); + } on DioException { + // 닿지 못해도 쓰지 않는 세션은 미사용 14일에 끝난다 + } + } + + Future _clearQuietly() async { + try { + await _store.clear(); + } on PlatformException { + // 메모리의 토큰은 지웠다 — 남은 값은 다음 실행의 갱신이 거절되면 지운다 + } + } +} diff --git a/flutter/lib/features/auth/presentation/providers/session_state.dart b/flutter/lib/features/auth/presentation/providers/session_state.dart new file mode 100644 index 0000000..050244e --- /dev/null +++ b/flutter/lib/features/auth/presentation/providers/session_state.dart @@ -0,0 +1,65 @@ +import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; + +/// 세션 상태 — 라우터의 redirect 가 이것으로 화면을 고른다(core/router/session_redirect.dart). +/// +/// "되살리는 중"은 상태가 아니라 세션 노티파이어의 `AsyncLoading` 이고, 되살리기가 장애로 실패하면 `AsyncError` 다. +sealed class SessionState { + const SessionState(); +} + +/// 로그아웃 상태 — 로그인 화면 +final class SignedOut extends SessionState { + const SignedOut({this.reason = SignOutReason.none}); + + /// 로그인 화면이 위쪽 상자에 보일 까닭 (화면 작업 때 쓴다) + final SignOutReason reason; + + @override + bool operator ==(Object other) => + other is SignedOut && other.reason == reason; + + @override + int get hashCode => reason.hashCode; +} + +/// 로그아웃한 까닭 +enum SignOutReason { + /// 처음 · 스스로 로그아웃 + none, + + /// 세션이 끝났다(`AUTH_002` — 갱신 거절 · 다른 곳에서 끊음) + expired, + + /// 코드 단계에 10분 넘게 머물렀다 (계약 5절 — "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.") + codeTimedOut, + + /// [다른 계정으로 로그인] + switchAccount, +} + +/// 비밀번호는 맞고 2단계 인증 코드를 기다린다 — 비밀번호는 상태에 두지 않는다(노티파이어 메모리에만 10분) +final class CodeRequired extends SessionState { + const CodeRequired(this.email); + + final String email; + + @override + bool operator ==(Object other) => + other is CodeRequired && other.email == email; + + @override + int get hashCode => email.hashCode; +} + +/// 로그인했다 — 제한이 있으면 그 단계 화면, 없으면 메일 +final class SignedIn extends SessionState { + const SignedIn(this.info); + + final AuthSessionInfo info; + + @override + bool operator ==(Object other) => other is SignedIn && other.info == info; + + @override + int get hashCode => info.hashCode; +} diff --git a/flutter/lib/features/mail/data/.gitkeep b/flutter/lib/features/mail/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/flutter/lib/features/mail/domain/.gitkeep b/flutter/lib/features/mail/domain/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/flutter/lib/features/mail/mail_routes.dart b/flutter/lib/features/mail/mail_routes.dart new file mode 100644 index 0000000..b3f0a68 --- /dev/null +++ b/flutter/lib/features/mail/mail_routes.dart @@ -0,0 +1,66 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/modules/module_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; + +/// 메일 모듈의 경로 — 모두 `/mail` 아래다 +abstract final class MailRoutes { + /// 첫 화면 — 받은편지함 목록 (탭 안) + static final String home = findAppModule(AppModuleId.mail).rootPath; + + /// 다른 메일함의 목록 (탭 안 — 탭을 바꿔도 이 기록이 남는다) + static String mailbox(String mailboxId) => + '$home/mailboxes/${Uri.encodeComponent(mailboxId)}'; + + /// 메일 읽기 (전체 화면 — 탭 막대 없이) + static String message(String messageId) => + '$home/messages/${Uri.encodeComponent(messageId)}'; + + /// 메일 쓰기 (전체 화면 — 탭 막대 없이) + static String get compose => '$home/compose'; +} + +/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다. +/// +/// 화면은 모두 자리 화면이다. 캔버스(2026-11-05 확정 — 목록 · 읽기 · 쓰기)대로 화면을 만들면 +/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다). +final ModuleRoutes mailModuleRoutes = ModuleRoutes( + id: AppModuleId.mail, + // 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면 + locationOfItem: MailRoutes.message, + buildRoutes: (ModuleRouteContext context) => [ + GoRoute( + path: MailRoutes.home, + builder: (BuildContext _, GoRouterState _) => PlaceholderPage( + title: MailTexts.home, + links: [ + PlaceholderLink( + label: MailTexts.compose, + location: MailRoutes.compose, + ), + ], + ), + routes: [ + GoRoute( + path: 'mailboxes/:mailboxId', + builder: (BuildContext _, GoRouterState _) => + const PlaceholderPage(title: MailTexts.mailbox), + ), + GoRoute( + path: 'messages/:messageId', + parentNavigatorKey: context.rootNavigatorKey, + builder: (BuildContext _, GoRouterState _) => + const PlaceholderPage(title: MailTexts.message), + ), + GoRoute( + path: 'compose', + parentNavigatorKey: context.rootNavigatorKey, + builder: (BuildContext _, GoRouterState _) => + const PlaceholderPage(title: MailTexts.compose), + ), + ], + ), + ], +); diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart new file mode 100644 index 0000000..372384f --- /dev/null +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -0,0 +1,16 @@ +/// 메일 모듈의 화면 이름 — 지금은 자리 화면이 쓴다. +/// +/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(캔버스 확정본 기준). +abstract final class MailTexts { + /// 메일 첫 화면 — 받은편지함 목록 + static const String home = '메일'; + + /// 다른 메일함의 목록 (탭 안) + static const String mailbox = '메일함'; + + /// 메일 읽기 (전체 화면) + static const String message = '메일 읽기'; + + /// 메일 쓰기 (전체 화면) + static const String compose = '메일 쓰기'; +} diff --git a/flutter/lib/features/settings/data/.gitkeep b/flutter/lib/features/settings/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/flutter/lib/features/settings/domain/.gitkeep b/flutter/lib/features/settings/domain/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/flutter/lib/features/settings/presentation/settings_texts.dart b/flutter/lib/features/settings/presentation/settings_texts.dart new file mode 100644 index 0000000..635fac8 --- /dev/null +++ b/flutter/lib/features/settings/presentation/settings_texts.dart @@ -0,0 +1,25 @@ +/// 설정 모듈의 화면 이름 — 지금은 자리 화면이 쓴다. +/// +/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(설정 화면 보드 확정 뒤). +abstract final class SettingsTexts { + /// 설정 첫 화면 + static const String home = '설정'; + + /// 내 정보 — 이름 · 주소 + static const String account = '내 정보'; + + /// 알림 켜기 · 끄기 + static const String notifications = '알림'; + + /// 앱 정보 — 판 · 오픈소스 라이선스 + static const String about = '앱 정보'; + + /// 오픈소스 라이선스 (Flutter 라이선스 화면 — LicenseRegistry) + static const String licenses = '오픈소스 라이선스'; + + /// 문의 연락처 (`GET /api/support-contact`) + static const String support = '문의 연락처'; + + /// 로그아웃 (화면이 아니라 하는 일) + static const String signOut = '로그아웃'; +} diff --git a/flutter/lib/features/settings/settings_routes.dart b/flutter/lib/features/settings/settings_routes.dart new file mode 100644 index 0000000..dc73748 --- /dev/null +++ b/flutter/lib/features/settings/settings_routes.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/modules/module_routes.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; +import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 설정 모듈의 경로 — 모두 `/settings` 아래다 +abstract final class SettingsRoutes { + /// 첫 화면 — 설정 목록 (탭 안) + static final String home = findAppModule(AppModuleId.settings).rootPath; + + /// 내 정보 — 이름 · 주소 + static String get account => '$home/account'; + + /// 알림 켜기 · 끄기 + static String get notifications => '$home/notifications'; + + /// 앱 정보 — 판 · 오픈소스 라이선스 + static String get about => '$home/about'; + + /// 오픈소스 라이선스 (전체 화면) + static String get licenses => '$about/licenses'; + + /// 문의 연락처 + static String get support => '$home/support'; + + /// 다른 모듈이 내놓은 설정 절 (`/settings/`) + static String section(String path) => '$home/$path'; +} + +/// 설정 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다. +/// +/// 첫 판의 절: 내 정보 · 알림 · (모듈이 내놓은 절) · 앱 정보 · 문의 연락처 · 로그아웃. +/// 화면은 자리 화면이다 — 설정 화면 보드가 확정되면 builder 를 `features/settings/presentation/pages/` 의 화면으로 바꾼다. +final ModuleRoutes settingsModuleRoutes = ModuleRoutes( + id: AppModuleId.settings, + buildRoutes: (ModuleRouteContext context) => [ + GoRoute( + path: SettingsRoutes.home, + builder: (BuildContext _, GoRouterState _) => + _SettingsHomePlaceholder(sections: context.settingsSections), + routes: [ + GoRoute( + path: 'account', + builder: (BuildContext _, GoRouterState _) => + const PlaceholderPage(title: SettingsTexts.account), + ), + GoRoute( + path: 'notifications', + builder: (BuildContext _, GoRouterState _) => + const PlaceholderPage(title: SettingsTexts.notifications), + ), + GoRoute( + path: 'about', + builder: (BuildContext _, GoRouterState _) => PlaceholderPage( + title: SettingsTexts.about, + links: [ + PlaceholderLink( + label: SettingsTexts.licenses, + location: SettingsRoutes.licenses, + ), + ], + ), + routes: [ + GoRoute( + path: 'licenses', + parentNavigatorKey: context.rootNavigatorKey, + builder: (BuildContext _, GoRouterState _) => + const _LicensesPage(), + ), + ], + ), + GoRoute( + path: 'support', + builder: (BuildContext _, GoRouterState _) => + const PlaceholderPage(title: SettingsTexts.support), + ), + for (final ModuleSettingsSection section in context.settingsSections) + GoRoute(path: section.path, builder: section.builder), + ], + ), + ], +); + +/// 설정 첫 화면 자리 — 절 이름과 [로그아웃] +class _SettingsHomePlaceholder extends ConsumerWidget { + const _SettingsHomePlaceholder({required this.sections}); + + final List sections; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PlaceholderPage( + title: SettingsTexts.home, + links: [ + PlaceholderLink( + label: SettingsTexts.account, + location: SettingsRoutes.account, + ), + PlaceholderLink( + label: SettingsTexts.notifications, + location: SettingsRoutes.notifications, + ), + for (final ModuleSettingsSection section in sections) + PlaceholderLink( + label: section.title, + location: SettingsRoutes.section(section.path), + ), + PlaceholderLink( + label: SettingsTexts.about, + location: SettingsRoutes.about, + ), + PlaceholderLink( + label: SettingsTexts.support, + location: SettingsRoutes.support, + ), + PlaceholderLink( + label: SettingsTexts.signOut, + onTap: () => unawaited(ref.read(sessionProvider.notifier).signOut()), + ), + ], + ); + } +} + +/// 오픈소스 라이선스 — Flutter 라이선스 화면(LicenseRegistry — Pretendard OFL 포함) +class _LicensesPage extends ConsumerWidget { + const _LicensesPage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return LicensePage( + applicationName: kAppName, + applicationVersion: ref.watch(appVersionProvider), + ); + } +} diff --git a/flutter/lib/features/user/data/models/user_model.dart b/flutter/lib/features/user/data/models/user_model.dart index 3624e94..05a0f52 100644 --- a/flutter/lib/features/user/data/models/user_model.dart +++ b/flutter/lib/features/user/data/models/user_model.dart @@ -1,27 +1,24 @@ -/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다. -/// (freezed/json_serializable 도입 시 이 클래스를 생성 모델로 교체) +/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다. (템플릿 예제 — 앱 API 에 `/users` 는 없다) +/// +/// 모델은 손으로 쓴다 — 생성기(freezed · json_serializable)를 넣을 때의 주의는 `flutter/README.md` "build_runner". class UserModel { - const UserModel({ - required this.id, - required this.email, - required this.name, - }); + const UserModel({required this.id, required this.email, required this.name}); final int id; final String email; final String name; - factory UserModel.fromJson(Map json) { + factory UserModel.fromJson(Map json) { return UserModel( - id: json['id'] as int, - email: json['email'] as String, - name: json['name'] as String, + id: json['id']! as int, + email: json['email']! as String, + name: json['name']! as String, ); } - Map toJson() => { - 'id': id, - 'email': email, - 'name': name, - }; + Map toJson() => { + 'id': id, + 'email': email, + 'name': name, + }; } diff --git a/flutter/lib/features/user/data/repositories/user_repository.dart b/flutter/lib/features/user/data/repositories/user_repository.dart index 0ae4164..8da80b5 100644 --- a/flutter/lib/features/user/data/repositories/user_repository.dart +++ b/flutter/lib/features/user/data/repositories/user_repository.dart @@ -1,8 +1,7 @@ +import 'package:arasystem_app/features/user/data/models/user_model.dart'; import 'package:dio/dio.dart'; -import '../models/user_model.dart'; - -/// 사용자 API 호출 구현체 — 새 도메인은 이 패턴을 복제한다. +/// 사용자 API 호출 구현체 — 목록 페이지네이션(`{items, pageInfo}`)의 예제다. (템플릿 예제 — 앱 API 에 `/users` 는 없다) /// /// 응답 언래핑({ success, data } → data)과 에러 코드 매핑은 dio 인터셉터가 담당하므로 /// 리포지토리는 파싱만 책임진다. (여기서 try/catch 로 메시지를 만들지 않는다) @@ -12,7 +11,8 @@ class UserRepository { final Dio _dio; Future getUser(int id) async { - final response = await _dio.get>('/users/$id'); + final Response> response = await _dio + .get>('/users/$id'); return UserModel.fromJson(response.data!); } @@ -21,19 +21,20 @@ class UserRepository { int limit = 20, String? cursor, }) async { - final response = await _dio.get>( - '/users', - queryParameters: { - 'limit': limit, - 'cursor': ?cursor, - }, - ); + final Response> response = await _dio + .get>( + '/users', + queryParameters: {'limit': limit, 'cursor': ?cursor}, + ); - final data = response.data!; - final items = (data['items'] as List) - .map((json) => UserModel.fromJson(json as Map)) + final Map data = response.data!; + final List items = (data['items']! as List) + .map( + (Object? json) => UserModel.fromJson(json! as Map), + ) .toList(); - final pageInfo = data['pageInfo'] as Map; + final Map pageInfo = + data['pageInfo']! as Map; return ( items: items, diff --git a/flutter/lib/features/user/presentation/providers/user_provider.dart b/flutter/lib/features/user/presentation/providers/user_provider.dart index 7ffd7c2..867d522 100644 --- a/flutter/lib/features/user/presentation/providers/user_provider.dart +++ b/flutter/lib/features/user/presentation/providers/user_provider.dart @@ -1,15 +1,18 @@ +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/features/user/data/models/user_model.dart'; +import 'package:arasystem_app/features/user/data/repositories/user_repository.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/api/dio_provider.dart'; -import '../../data/models/user_model.dart'; -import '../../data/repositories/user_repository.dart'; +import 'package:flutter_riverpod/misc.dart'; -// 리포지토리 Provider — dioProvider 를 주입받아 생성 -final userRepositoryProvider = Provider((ref) { - return UserRepository(ref.watch(dioProvider)); -}); +/// 리포지토리 Provider — dioProvider 를 주입받아 생성 (템플릿 예제) +final Provider userRepositoryProvider = + Provider( + (Ref ref) => UserRepository(ref.watch(dioProvider)), + ); -// 특정 사용자 조회 Provider (family) -// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...) -final userProvider = FutureProvider.family((ref, id) { - return ref.watch(userRepositoryProvider).getUser(id); -}); +/// 특정 사용자 조회 Provider (family) +/// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...) +final FutureProviderFamily userProvider = + FutureProvider.family( + (Ref ref, int id) => ref.watch(userRepositoryProvider).getUser(id), + ); diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 0d77b72..fa6f394 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -1,67 +1,53 @@ +import 'package:arasystem_app/app.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/user_agent.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/storage/first_run_marker.dart'; +import 'package:arasystem_app/core/storage/secure_store.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/theme/app_fonts.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +/// `.env` 파일 이름 (pubspec assets) +const String _envFileName = '.env'; + Future main() async { - // dotenv 로드 — .env 파일이 없으면 기본값으로 동작 WidgetsFlutterBinding.ensureInitialized(); + + // .env 가 없으면 기본 주소로 동작한다 (ApiConfig.fromEnv) try { - await dotenv.load(fileName: '.env'); - } catch (_) { - // .env 파일이 없는 경우 무시 (기본값 사용) + await dotenv.load(fileName: _envFileName); + } on FileNotFoundError { + // 기본값을 쓴다 + } on EmptyEnvFileError { + // 기본값을 쓴다 } + AppFonts.registerFontLicenses(); + + // 토큰 보관 준비 — 첫 실행이면 보안 저장소에 남은 값을 지운다(앱을 띄우기 전에 끝낸다) + final TokenStore tokenStore = TokenStore( + secure: const FlutterSecureStore(), + firstRun: PrefsFirstRunMarker(), + ); + await tokenStore.prepare(); + final ({String? appVersion, String userAgent}) device = + await loadAppDeviceInfo(); + runApp( - // Riverpod Provider Scope 적용 - const ProviderScope( - child: MyApp(), + ProviderScope( + // 프로바이더의 실패를 저절로 다시 하지 않는다(core/utils/provider_retry.dart) + retry: noAutomaticRetry, + overrides: [ + tokenStoreProvider.overrideWithValue(tokenStore), + userAgentProvider.overrideWithValue(device.userAgent), + appVersionProvider.overrideWithValue(device.appVersion), + ], + child: const App(), ), ); } - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'TMA App', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const HomeView(), - ); - } -} - -class HomeView extends StatelessWidget { - const HomeView({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('TMA Global Architecture'), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Riverpod & Dio Interceptor Setup Complete.'), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - // 테스트 용 임시 스낵바 알림 호출 (User-Friendly Error UI) - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('에러 발생 시 보여질 Toast 예시입니다.')), - ); - }, - child: const Text('Show Global Alert Test'), - ), - ], - ), - ), - ); - } -} diff --git a/flutter/lib/shared/widgets/.gitkeep b/flutter/lib/shared/widgets/.gitkeep deleted file mode 100644 index 3cc233d..0000000 --- a/flutter/lib/shared/widgets/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -// Empty directory placeholder diff --git a/flutter/lib/shared/widgets/placeholder_page.dart b/flutter/lib/shared/widgets/placeholder_page.dart new file mode 100644 index 0000000..3a10af2 --- /dev/null +++ b/flutter/lib/shared/widgets/placeholder_page.dart @@ -0,0 +1,65 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// 자리 화면의 줄 하나 — 누르면 [location] 으로 가거나 [onTap] 을 부른다 +@immutable +class PlaceholderLink { + const PlaceholderLink({required this.label, this.location, this.onTap}); + + final String label; + + /// 갈 경로 (`context.push` — 뒤로 가기로 돌아온다) + final String? location; + final VoidCallback? onTap; +} + +/// 자리 화면 — 화면 이름과(있으면) 갈 곳의 이름만 보인다. +/// +/// 화면은 캔버스로 확인받은 뒤 만든다. 확정되면 그 모듈의 라우트 묶음(`features/<모듈>/<모듈>_routes.dart`)의 +/// builder 를 그 화면으로 바꾸고, 쓰는 곳이 없어지면 이 위젯을 지운다. +class PlaceholderPage extends StatelessWidget { + const PlaceholderPage({ + required this.title, + this.links = const [], + super.key, + }); + + final String title; + final List links; + + @override + Widget build(BuildContext context) { + final TextTheme text = Theme.of(context).textTheme; + return Scaffold( + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(AppSpacing.s24), + children: [ + Text(title, textAlign: TextAlign.center, style: text.titleLarge), + if (links.isNotEmpty) const SizedBox(height: AppSpacing.s16), + for (final PlaceholderLink link in links) _LinkTile(link: link), + ], + ), + ), + ); + } +} + +class _LinkTile extends StatelessWidget { + const _LinkTile({required this.link}); + + final PlaceholderLink link; + + @override + Widget build(BuildContext context) { + final String? location = link.location; + final VoidCallback? onTap = link.onTap; + return ListTile( + title: Text(link.label), + onTap: + onTap ?? + (location == null ? null : () => context.push(location)), + ); + } +} diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index 67d652c..348142a 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,14 @@ import FlutterMacOS import Foundation +import device_info_plus import flutter_secure_storage_darwin +import package_info_plus +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 813608b..b0c0ca1 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -113,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: ade1c36f8355d6284c98e4a47992ea263887ed3d1acb281e37635dd45f04c4aa + url: "https://pub.dev" + source: hosted + version: "13.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + url: "https://pub.dev" + source: hosted + version: "8.1.0" dio: dependency: "direct main" description: @@ -145,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -174,6 +198,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_riverpod: dependency: "direct main" description: @@ -226,10 +255,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -256,6 +285,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea + url: "https://pub.dev" + source: hosted + version: "17.5.0" hooks: dependency: transitive description: @@ -264,6 +301,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -280,6 +325,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: @@ -408,6 +461,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" + url: "https://pub.dev" + source: hosted + version: "10.2.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: transitive description: @@ -504,6 +573,62 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -697,10 +822,18 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.4.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + url: "https://pub.dev" + source: hosted + version: "3.0.3" xdg_directories: dependency: transitive description: @@ -718,5 +851,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.1 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.11.5 <4.0.0" + flutter: ">=3.41.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 6e64eb2..2eb9065 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,92 +1,64 @@ name: arasystem_app description: "ARA SYSTEM 그룹웨어 모바일 앱" -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +# pub.dev 에 올리지 않는 사내 앱이다. +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. +# 앱 판 — build-name(1.0.0)은 Android versionName · iOS CFBundleShortVersionString, +# build-number(+1)는 Android versionCode · iOS CFBundleVersion 이 된다. +# 서버에 보내는 User-Agent 의 판(ARASYSTEM-App/<판>)도 build-name 이다 (core/api/user_agent.dart). version: 1.0.0+1 environment: - sdk: ^3.11.1 + # Flutter 3.41.9 의 Dart — 판 고정은 .claude/skills/flutter/SKILL.md "Flutter 판" + sdk: ^3.11.5 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. +# 새 패키지는 pub.dev 배포 뒤 2주가 지난 판만 고른다 (보안 규칙 "의존성"의 2주 규칙과 같다). dependencies: flutter: sdk: flutter + # 한국어 시스템 문구(복사 · 붙여 넣기 메뉴 · 날짜 고르기) — locale: ko + flutter_localizations: + sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + # 기기 정보(Android 판 · 기종) — User-Agent 만들기 + device_info_plus: ^13.3.0 dio: ^5.9.2 flutter_dotenv: ^5.2.1 flutter_riverpod: ^3.3.1 flutter_secure_storage: ^10.0.0 + # 화면 이동 — redirect 하나가 세션 상태로 화면을 고른다 (core/router/) + go_router: ^17.5.0 + # 날짜 · 숫자 형식 — flutter_localizations 가 이 판으로 고정한다 + intl: 0.20.2 + # 앱 판 — User-Agent 만들기 + package_info_plus: ^10.2.2 + # 첫 실행 표시 하나만 둔다 — 토큰은 여기에 두지 않는다 (core/storage/first_run_marker.dart) + shared_preferences: ^2.5.6 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - # 런타임 환경변수 — flutter_dotenv 로 로드 assets: + # 런타임 환경변수 — flutter_dotenv 로 읽는다 (API_URL 만 — 앱 안에 그대로 들어가니 비밀은 넣지 않는다) - .env + # 글꼴 라이선스 — 설정의 [오픈소스 라이선스]에 보인다 (core/theme/app_fonts.dart) + - assets/licenses/pretendard-ofl.txt - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + # Pretendard 1.3.9 (SIL OFL 1.1) — 웹(frontend)과 같은 글꼴. 정적 OTF 넷만 넣는다 + fonts: + - family: Pretendard + fonts: + - asset: assets/fonts/Pretendard-Regular.otf + weight: 400 + - asset: assets/fonts/Pretendard-Medium.otf + weight: 500 + - asset: assets/fonts/Pretendard-SemiBold.otf + weight: 600 + - asset: assets/fonts/Pretendard-Bold.otf + weight: 700 diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart new file mode 100644 index 0000000..b2e8263 --- /dev/null +++ b/flutter/test/app_test.dart @@ -0,0 +1,271 @@ +import 'package:arasystem_app/app.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/router/app_router.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'support/fakes.dart'; +import 'support/provider_harness.dart'; + +void main() { + late ProviderHarness h; + + setUp(() => h = ProviderHarness()); + + Future pumpApp(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + retry: noAutomaticRetry, + overrides: h.overrides, + child: const App(), + ), + ); + await tester.pumpAndSettle(); + } + + group('앱 뿌리 · 라우터', () { + testWidgets('세션이 없으면 로그인 화면으로 간다 — 한국어 · 앱 이름', ( + WidgetTester tester, + ) async { + await h.prepare(); + + await pumpApp(tester); + + expect(find.text(ScreenTitles.login), findsOneWidget); + final MaterialApp app = tester.widget(find.byType(MaterialApp)); + expect(app.title, kAppName); + expect(app.locale, kAppLocale); + final BuildContext context = tester.element( + find.text(ScreenTitles.login), + ); + expect(Localizations.localeOf(context).languageCode, 'ko'); + }); + + testWidgets('되살린 세션에 새 비밀번호 제한이 있으면 그 단계 화면이다', ( + WidgetTester tester, + ) async { + await h.prepare(refreshToken: 'refresh-1'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + 'tokens': tokensJson(2, now: h.clock.now), + }), + ); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')), + ); + + await pumpApp(tester); + + expect(find.text(ScreenTitles.passwordChange), findsOneWidget); + }); + + testWidgets('되살린 세션에 제한이 없으면 메일 화면이다', (WidgetTester tester) async { + await h.prepare(refreshToken: 'refresh-1'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + 'tokens': tokensJson(2, now: h.clock.now), + }), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + + await pumpApp(tester); + + expect( + find.descendant( + of: find.byType(ListView), + matching: find.text(MailTexts.home), + ), + findsOneWidget, + ); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('되살리기가 장애로 실패하면 시작 화면에 남는다', (WidgetTester tester) async { + await h.prepare(refreshToken: 'refresh-1'); + h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline); + + await pumpApp(tester); + + expect(find.text(ScreenTitles.start), findsOneWidget); + }); + }); + + group('모듈 · 탭 막대', () { + Future pumpSignedIn(WidgetTester tester) async { + await h.prepare(refreshToken: 'refresh-1'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + 'tokens': tokensJson(2, now: h.clock.now), + }), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + await pumpApp(tester); + return ProviderScope.containerOf( + tester.element(find.byType(MaterialApp)), + ).read(routerProvider); + } + + Finder tab(String label) => find.descendant( + of: find.byType(NavigationBar), + matching: find.text(label), + ); + + Finder page(String title) => + find.descendant(of: find.byType(ListView), matching: find.text(title)); + + testWidgets('켜진 모듈만 탭이 된다 — 메일 · 설정 차례', (WidgetTester tester) async { + await pumpSignedIn(tester); + + final NavigationBar bar = tester.widget(find.byType(NavigationBar)); + expect(bar.destinations, hasLength(2)); + expect(tab('메일'), findsOneWidget); + expect(tab('설정'), findsOneWidget); + expect(tab('캘린더'), findsNothing); + expect(tab('주소록'), findsNothing); + expect( + tester.getTopLeft(tab('메일')).dx, + lessThan(tester.getTopLeft(tab('설정')).dx), + ); + }); + + testWidgets('탭을 바꿨다 돌아와도 메일 탭의 이동 기록이 남는다', (WidgetTester tester) async { + final GoRouter router = await pumpSignedIn(tester); + router.go(MailRoutes.mailbox('archive')); + await tester.pumpAndSettle(); + expect(page(MailTexts.mailbox), findsOneWidget); + + await tester.tap(tab('설정')); + await tester.pumpAndSettle(); + expect(page(SettingsTexts.home), findsOneWidget); + + await tester.tap(tab('메일')); + await tester.pumpAndSettle(); + expect(page(MailTexts.mailbox), findsOneWidget); + expect( + router.routerDelegate.currentConfiguration.uri.path, + MailRoutes.mailbox('archive'), + ); + + // 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 + await tester.tap(tab('메일')); + await tester.pumpAndSettle(); + expect(page(MailTexts.home), findsOneWidget); + }); + + testWidgets('읽기 · 쓰기는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async { + final GoRouter router = await pumpSignedIn(tester); + + router.go(MailRoutes.message('m1')); + await tester.pumpAndSettle(); + + expect(page(MailTexts.message), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + }); + + testWidgets('알 수 없는 모듈 · 꺼진 모듈 · 없는 주소는 메일 첫 화면으로 간다', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpSignedIn(tester); + + for (final String location in [ + '/calendar', + '/contacts/c1', + '/bogus', + '/mail/nowhere/at-all', + ]) { + router.go(MailRoutes.mailbox('archive')); + await tester.pumpAndSettle(); + router.go(location); + await tester.pumpAndSettle(); + expect( + router.routerDelegate.currentConfiguration.uri.path, + MailRoutes.home, + reason: location, + ); + expect(page(MailTexts.home), findsOneWidget, reason: location); + } + }); + + testWidgets('설정 첫 화면의 절과 [로그아웃] — 누르면 로그인 화면으로', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpSignedIn(tester); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await tester.tap(tab('설정')); + await tester.pumpAndSettle(); + + for (final String item in [ + SettingsTexts.account, + SettingsTexts.notifications, + SettingsTexts.about, + SettingsTexts.support, + SettingsTexts.signOut, + ]) { + expect(page(item), findsOneWidget, reason: item); + } + + await tester.tap(page(SettingsTexts.signOut)); + await tester.pumpAndSettle(); + + expect(find.text(ScreenTitles.login), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.uri.path, '/login'); + expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); + }); + }); + + group('전역 알림', () { + testWidgets('API 실패는 전역 ScaffoldMessenger 의 스낵바로 사전 문구를 보인다', ( + WidgetTester tester, + ) async { + final GlobalKey key = + GlobalKey(); + final ErrorNotifier notifier = ErrorNotifier(messengerKey: key); + await tester.pumpWidget( + MaterialApp( + scaffoldMessengerKey: key, + home: const Scaffold(body: SizedBox.shrink()), + ), + ); + + notifier.show(ApiException.fromCode('MAIL_001')); + await tester.pump(); + + expect(find.text(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + }); + + testWidgets('앱의 알림 자리가 MaterialApp 에 걸려 있다', (WidgetTester tester) async { + await h.prepare(); + + await pumpApp(tester); + + final MaterialApp app = tester.widget(find.byType(MaterialApp)); + final ProviderContainer container = ProviderScope.containerOf( + tester.element(find.byType(MaterialApp)), + ); + expect( + app.scaffoldMessengerKey, + container.read(scaffoldMessengerKeyProvider), + ); + }); + }); +} diff --git a/flutter/test/core/api/auth_interceptor_test.dart b/flutter/test/core/api/auth_interceptor_test.dart new file mode 100644 index 0000000..4357da9 --- /dev/null +++ b/flutter/test/core/api/auth_interceptor_test.dart @@ -0,0 +1,974 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:arasystem_app/core/api/api_exception.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/session_events.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; + +/// 보호 API 하나 — 시험용 +const String _mailState = '/mail-state'; + +void main() { + late ApiHarness api; + + setUp(() async { + api = ApiHarness(); + await api.prepare(); + }); + + /// 지금 액세스 토큰이면 200, 아니면 401 인 보호 API + void protectedRoute({required String validToken}) { + api.server.on('GET', _mailState, (RequestOptions options) { + return bearerTokenOf(options) == validToken + ? FakeResponse.ok({'state': 's1'}) + : FakeResponse.fail(401, 'AUTH_001'); + }); + } + + /// 갱신할 때마다 다음 토큰(2 · 3 · …)을 주는 서버 — 서버 시각은 [serverNow] 로 센다 + void refreshIssuesNext({DateTime Function()? serverNow}) { + int next = 2; + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(next++, now: serverNow?.call() ?? api.clock.now), + }), + ); + } + + int refreshCount() => api.server.received('POST', ApiPaths.appRefresh).length; + + group('토큰 붙이기 · 미리 갱신', () { + test('로그인한 요청에 Bearer 와 앱 User-Agent 를 싣고 응답을 푼다', () async { + await api.signIn(); + protectedRoute(validToken: 'access-1'); + + final Response> response = await api.dio + .get>(_mailState); + + expect(response.data, {'state': 's1'}); + final RequestOptions sent = api.server.requests.single; + expect(bearerTokenOf(sent), 'access-1'); + expect(sent.headers['User-Agent'], kTestUserAgent); + }); + + test('앱을 새로 띄워 액세스 토큰이 없으면 보내기 전에 한 번 갱신한다(401 을 보지 않는다)', () async { + await api.coldStartWithRefreshToken('refresh-1'); + refreshIssuesNext(); + protectedRoute(validToken: 'access-2'); + + await api.dio.get(_mailState); + + expect(refreshCount(), 1); + expect( + api.server.received('POST', ApiPaths.appRefresh).single.data, + {'refreshToken': 'refresh-1'}, + ); + expect(api.server.received('GET', _mailState), hasLength(1)); + expect(api.secure.values[kRefreshTokenKey], 'refresh-2'); + expect(api.emitted.whereType(), hasLength(1)); + }); + + test('액세스 토큰이 곧 끝나면(30초 안) 미리 갱신한다', () async { + await api.signIn(); + api.clock.advance(const Duration(minutes: 59, seconds: 40)); + refreshIssuesNext(); + protectedRoute(validToken: 'access-2'); + + await api.dio.get(_mailState); + + expect(refreshCount(), 1); + expect( + bearerTokenOf(api.server.received('GET', _mailState).single), + 'access-2', + ); + }); + + test('기기 시계가 서버보다 2시간 빨라도 응답의 Date 로 남은 시간을 재 요청마다 갱신하지 않는다', () async { + final DateTime serverNow = api.clock.now.subtract( + const Duration(hours: 2), + ); + api.server.serverTime = serverNow; + await api.coldStartWithRefreshToken('refresh-1'); + refreshIssuesNext(serverNow: () => serverNow); + api.server.on( + 'GET', + _mailState, + (_) => FakeResponse.ok({'state': 's1'}), + ); + + await api.dio.get(_mailState); + await api.dio.get(_mailState); + expect(refreshCount(), 1); + + // 받은 때부터 59분 31초 — 만료 30초 전이라 다시 갱신한다 + api.clock.advance(const Duration(minutes: 59, seconds: 31)); + await api.dio.get(_mailState); + expect(refreshCount(), 2); + }); + + test('미리 갱신이 거절되면 원래 요청을 보내지 않고 AUTH_002 로 한 번 알린다', () async { + await api.coldStartWithRefreshToken('refresh-1'); + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + protectedRoute(validToken: 'access-2'); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'AUTH_002'); + expect(api.server.received('GET', _mailState), isEmpty); + expect(api.notifier.shownCodes, ['AUTH_002']); + expect(api.emitted.whereType(), hasLength(1)); + expect(api.store.hasSession, isFalse); + }); + + test('미리 갱신이 장애면 원래 요청을 보내지 않고 그 코드로 알리며 세션은 둔다', () async { + await api.coldStartWithRefreshToken('refresh-1'); + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + protectedRoute(validToken: 'access-2'); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'MAIL_001'); + expect(api.server.received('GET', _mailState), isEmpty); + expect(api.notifier.shownCodes, ['MAIL_001']); + expect(api.store.hasSession, isTrue); + }); + + test('연결이 끊긴 동안 줄 선 요청들은 갱신을 한 번만 보내고, 5초가 지나면 다시 묻는다', () async { + await api.coldStartWithRefreshToken('refresh-1'); + api.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline); + protectedRoute(validToken: 'access-2'); + + final List errors = await Future.wait(>[ + errorOf(api.dio.get(_mailState)), + errorOf(api.dio.get(_mailState)), + errorOf(api.dio.get(_mailState)), + ]); + + expect(errors.map((Object e) => apiErrorOf(e).code), [ + 'SYS_001', + 'SYS_001', + 'SYS_001', + ]); + expect(refreshCount(), 1); + expect(api.notifier.shownCodes, ['SYS_001']); + + api.clock.advance(const Duration(seconds: 5)); + await errorOf(api.dio.get(_mailState)); + expect(refreshCount(), 2); + }); + }); + + group('401 → 갱신 → 다시', () { + test('401 이면 갱신을 한 번 하고 원래 요청을 새 토큰으로 한 번 다시 보낸다', () async { + await api.signIn(); + refreshIssuesNext(); + protectedRoute(validToken: 'access-2'); + + final Response> response = await api.dio + .get>(_mailState); + + expect(response.data, {'state': 's1'}); + final List calls = api.server.received('GET', _mailState); + expect(calls.map(bearerTokenOf), ['access-1', 'access-2']); + expect(refreshCount(), 1); + expect(api.store.accessToken, 'access-2'); + expect(api.secure.values[kRefreshTokenKey], 'refresh-2'); + expect(api.notifier.shownCodes, isEmpty); + }); + + test('동시에 401 을 받은 요청 셋은 갱신 한 번에 묶이고 모두 성공한다', () async { + await api.signIn(); + final Completer allRejected = Completer(); + int rejected = 0; + api.server.on('GET', _mailState, (RequestOptions options) { + if (bearerTokenOf(options) == 'access-2') { + return FakeResponse.ok({'state': 's1'}); + } + rejected++; + if (rejected == 3) allRejected.complete(); + return FakeResponse.fail(401, 'AUTH_001'); + }); + api.server.on('POST', ApiPaths.appRefresh, (_) async { + // 세 요청이 모두 401 을 받은 뒤에야 갱신이 끝난다 + await allRejected.future; + return FakeResponse.ok({'tokens': tokensJson(2)}); + }); + + final List> responses = + await Future.wait(>>[ + api.dio.get(_mailState), + api.dio.get(_mailState), + api.dio.get(_mailState), + ]); + + expect(responses.map((Response r) => r.statusCode), [ + 200, + 200, + 200, + ]); + expect(refreshCount(), 1); + expect(api.server.received('GET', _mailState), hasLength(6)); + }); + + test('FormData 본문은 다시 보낼 때 새로 만든다(한 번 보낸 FormData 는 다시 쓸 수 없다)', () async { + await api.signIn(); + refreshIssuesNext(); + api.server.on('POST', '/mail-attachments', (RequestOptions options) { + return bearerTokenOf(options) == 'access-2' + ? FakeResponse.ok({'id': 'a1'}) + : FakeResponse.fail(401, 'AUTH_001'); + }); + + final Response> response = await api.dio + .post>( + '/mail-attachments', + data: FormData.fromMap({ + 'note': '첨부', + 'file': MultipartFile.fromString('hello', filename: 'a.txt'), + }), + ); + + expect(response.data, {'id': 'a1'}); + final List sent = api.server.received( + 'POST', + '/mail-attachments', + ); + expect(sent, hasLength(2)); + final FormData first = sent.first.data! as FormData; + final FormData second = sent.last.data! as FormData; + expect(identical(first, second), isFalse); + expect(second.fields, first.fields); + expect(second.files.single.value.filename, 'a.txt'); + }); + + test('갱신이 401 이면 토큰을 지우고 AUTH_002 로 알리며 세션 끝을 알린다', () async { + await api.signIn(); + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + protectedRoute(validToken: 'never'); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'AUTH_002'); + expect(api.store.accessToken, isNull); + expect(api.store.hasSession, isFalse); + expect(api.secure.values.containsKey(kRefreshTokenKey), isFalse); + expect(api.emitted.whereType(), hasLength(1)); + expect(api.notifier.shownCodes, ['AUTH_002']); + // 원래 요청은 다시 보내지 않았다 + expect(api.server.received('GET', _mailState), hasLength(1)); + }); + + test('동시에 세션이 끝난 요청들의 AUTH_002 알림은 한 번만 뜬다', () async { + await api.signIn(); + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + protectedRoute(validToken: 'never'); + + final List errors = await Future.wait(>[ + errorOf(api.dio.get(_mailState)), + errorOf(api.dio.get(_mailState)), + ]); + + expect(errors.map((Object e) => apiErrorOf(e).code), [ + 'AUTH_002', + 'AUTH_002', + ]); + expect(refreshCount(), 1); + expect(api.notifier.shownCodes, ['AUTH_002']); + }); + + test('갱신이 일시 장애(503 · 연결 끊김)면 세션을 두고 그 코드로 알린다', () async { + await api.signIn(); + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + protectedRoute(validToken: 'never'); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'MAIL_001'); + expect(api.store.hasSession, isTrue); + expect(api.secure.values[kRefreshTokenKey], 'refresh-1'); + expect(api.emitted.whereType(), isEmpty); + expect(api.notifier.shownCodes, ['MAIL_001']); + + // 5초 안에는 같은 실패를 다시 쓴다 — 지난 뒤에 다시 묻는다 + api.clock.advance(const Duration(seconds: 5)); + api.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline); + final ApiException offline = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + expect(offline.code, 'SYS_001'); + expect(api.store.hasSession, isTrue); + expect(refreshCount(), 2); + }); + + test('새 토큰으로 다시 보낸 요청도 401 이면 세션을 끝낸다(한 번만 다시 보낸다)', () async { + await api.signIn(); + refreshIssuesNext(); + protectedRoute(validToken: 'never'); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'AUTH_002'); + expect(api.server.received('GET', _mailState), hasLength(2)); + expect(refreshCount(), 1); + expect(api.store.hasSession, isFalse); + expect(api.emitted.whereType(), hasLength(1)); + }); + + test('갱신 유예 응답(tokens: null)은 이어 쓸 토큰이 없어 세션 끝으로 다룬다', () async { + await api.signIn(); + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': null, + }), + ); + protectedRoute(validToken: 'never'); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'AUTH_002'); + expect(api.store.hasSession, isFalse); + }); + + test('갱신하는 사이 로그아웃하면 받은 토큰을 쓰지 않고 그 세션을 Bearer 와 함께 끝내며 알리지 않는다', () async { + await api.signIn(); + final Completer release = Completer(); + api.server.on('POST', ApiPaths.appRefresh, (_) async { + await release.future; + return FakeResponse.ok({'tokens': tokensJson(2)}); + }); + api.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + protectedRoute(validToken: 'access-2'); + + final Future pending = errorOf(api.dio.get(_mailState)); + // 갱신이 서버에 닿을 때까지 기다린 뒤 로그아웃한다 + await pumpEventsUntil(() => refreshCount() == 1, reason: '갱신이 서버에 닿기'); + await api.store.clear(byUser: true); + release.complete(); + + final ApiException error = apiErrorOf(await pending); + expect(error.code, 'AUTH_001'); + expect(api.store.accessToken, isNull); + expect(api.secure.values.containsKey(kRefreshTokenKey), isFalse); + await pumpEventsUntil( + () => api.server.received('POST', ApiPaths.appLogout).isNotEmpty, + reason: '조용한 로그아웃', + ); + final RequestOptions logout = api.server + .received('POST', ApiPaths.appLogout) + .single; + expect(logout.data, {'refreshToken': 'refresh-2'}); + expect(bearerTokenOf(logout), 'access-2'); + expect(api.notifier.shownCodes, isEmpty); + }); + + test( + '사용자가 로그아웃한 뒤 늦게 온, 로그인 중에 보낸 요청의 401 은 승격하지 않고(AUTH_001) 알리지 않는다', + () async { + await api.signIn(); + final Completer release = Completer(); + api.server.on('GET', _mailState, (_) async { + await release.future; + return FakeResponse.fail(401, 'AUTH_001'); + }); + + final Future pending = errorOf( + api.dio.get(_mailState), + ); + await pumpEventsUntil( + () => api.server.received('GET', _mailState).isNotEmpty, + reason: '요청이 서버에 닿기', + ); + await api.store.clear(byUser: true); + release.complete(); + + final ApiException error = apiErrorOf(await pending); + expect(error.code, 'AUTH_001'); + expect(api.notifier.shownCodes, isEmpty); + expect(refreshCount(), 0); + expect(api.store.signedOutByUser, isTrue); + }, + ); + }); + + group('갱신하지 않는 경우', () { + test('로그인 · 갱신 · 로그아웃 요청의 401 은 갱신하지 않는다', () async { + await api.signIn(); + api.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(401, 'AUTH_004'), + ); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.post(ApiPaths.appLogin)), + ); + + expect(error.code, 'AUTH_004'); + expect(refreshCount(), 0); + expect(api.store.hasSession, isTrue); + // 로그인 요청에는 Bearer 를 싣지 않는다 + expect(bearerTokenOf(api.server.requests.single), isNull); + }); + + test('세션 없이 받은 401 은 AUTH_001 그대로다(세션 만료로 올리지 않는다)', () async { + protectedRoute(validToken: 'never'); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'AUTH_001'); + expect(refreshCount(), 0); + expect(api.emitted, isEmpty); + }); + }); + + group('AUTH_010 — 제한 다시 맞추기', () { + /// [attempts] 번째 요청까지 AUTH_010, 그 뒤는 성공하는 보호 API + void restrictedUntil(int attempts) { + int seen = 0; + api.server.on('GET', _mailState, (_) { + seen++; + return seen <= attempts + ? FakeResponse.fail(403, 'AUTH_010') + : FakeResponse.ok({'state': 's1'}); + }); + } + + test('제한을 확인했으면(restricted) 다시 보내지 않고 알리지 않는다', () async { + await api.signIn(); + api.recheckAnswer = RestrictionCheck.restricted; + restrictedUntil(1); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'AUTH_010'); + expect(api.server.received('GET', _mailState), hasLength(1)); + expect(api.notifier.shownCodes, isEmpty); + expect(api.emitted.whereType(), hasLength(1)); + expect(refreshCount(), 0); + }); + + test('옛 토큰이었으면(renewed) 원래 요청을 한 번 다시 보낸다', () async { + await api.signIn(); + api.recheckAnswer = RestrictionCheck.renewed; + restrictedUntil(1); + + final Response> response = await api.dio + .get>(_mailState); + + expect(response.data, {'state': 's1'}); + final List sent = api.server.received('GET', _mailState); + expect(sent, hasLength(2)); + expect(sent.last.extra[kRestrictionRetriedExtraKey], isTrue); + expect(api.notifier.shownCodes, isEmpty); + }); + + test('다시 보낸 요청도 AUTH_010 이면 다시 맞추지 않고 알린다', () async { + await api.signIn(); + api.recheckAnswer = RestrictionCheck.renewed; + restrictedUntil(2); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'AUTH_010'); + expect(api.server.received('GET', _mailState), hasLength(2)); + expect(api.emitted.whereType(), hasLength(1)); + expect(api.notifier.shownCodes, ['AUTH_010']); + }); + + test( + '확인하지 못했으면(unknown) 다시 보내지 않고 알린다 — 그사이 세션이 바뀌었으면(stale) 알리지 않는다', + () async { + await api.signIn(); + restrictedUntil(99); + + api.recheckAnswer = RestrictionCheck.unknown; + await errorOf(api.dio.get(_mailState)); + expect(api.notifier.shownCodes, ['AUTH_010']); + + api.clock.advance(const Duration(seconds: 5)); + api.recheckAnswer = RestrictionCheck.stale; + await errorOf(api.dio.get(_mailState)); + expect(api.notifier.shownCodes, ['AUTH_010']); + expect(api.server.received('GET', _mailState), hasLength(2)); + }, + ); + + test('듣는 쪽(세션 노티파이어)이 없으면 확인하지 못한 것으로 보고 알린다', () async { + final ApiHarness alone = ApiHarness(); + await alone.store.prepare(); + await alone.store.save(tokensFor(1, now: alone.clock.now)); + alone.server.on( + 'GET', + _mailState, + (_) => FakeResponse.fail(403, 'AUTH_010'), + ); + + final ApiException error = apiErrorOf( + await errorOf(alone.dio.get(_mailState)), + ); + + expect(error.code, 'AUTH_010'); + expect(alone.notifier.shownCodes, ['AUTH_010']); + }); + + test('사용자가 로그아웃한 뒤 늦게 온 AUTH_010 은 다시 맞추지 않고 알리지 않는다', () async { + await api.signIn(); + final Completer release = Completer(); + api.server.on('GET', _mailState, (_) async { + await release.future; + return FakeResponse.fail(403, 'AUTH_010'); + }); + + final Future pending = errorOf(api.dio.get(_mailState)); + await pumpEventsUntil( + () => api.server.received('GET', _mailState).isNotEmpty, + reason: '요청이 서버에 닿기', + ); + await api.store.clear(byUser: true); + release.complete(); + + expect(apiErrorOf(await pending).code, 'AUTH_010'); + expect(api.emitted.whereType(), isEmpty); + expect(api.notifier.shownCodes, isEmpty); + }); + }); + + group('응답 풀기 · 알림 끄기', () { + test('성공한 바이트 · 스트림 응답은 풀지 않고 그대로 준다(첨부 받기)', () async { + await api.signIn(); + api.server.on( + 'GET', + '/files/f1', + (_) => FakeResponse.ok({'not': 'unwrapped'}), + ); + + final Response> bytes = await api.dio.get>( + '/files/f1', + options: Options(responseType: ResponseType.bytes), + ); + expect(jsonDecode(utf8.decode(bytes.data!)), { + 'success': true, + 'data': {'not': 'unwrapped'}, + }); + + final Response stream = await api.dio.get( + '/files/f1', + options: Options(responseType: ResponseType.stream), + ); + final List streamed = [ + for (final List chunk in await stream.data!.stream.toList()) + ...chunk, + ]; + expect(utf8.decode(streamed), contains('"success":true')); + }); + + test('silent: true 면 알리지 않지만 코드는 그대로 온다', () async { + await api.signIn(); + api.server.on( + 'GET', + _mailState, + (_) => FakeResponse.fail(404, 'RES_001'), + ); + + final ApiException error = apiErrorOf( + await errorOf( + api.dio.get( + _mailState, + options: Options(extra: {kSilentExtraKey: true}), + ), + ), + ); + + expect(error.code, 'RES_001'); + expect(api.notifier.shownCodes, isEmpty); + }); + + test('silent 에 코드 목록을 주면 그 코드만 알리지 않는다', () async { + await api.signIn(); + api.server.on( + 'GET', + _mailState, + (_) => FakeResponse.fail(404, 'RES_001'), + ); + api.server.on('GET', '/other', (_) => FakeResponse.fail(429, 'REQ_001')); + + await errorOf( + api.dio.get( + _mailState, + options: ApiOptions.silentFor({'RES_001'}), + ), + ); + await errorOf( + api.dio.get( + '/other', + options: ApiOptions.silentFor({'RES_001'}), + ), + ); + + expect(api.notifier.shownCodes, ['REQ_001']); + }); + + test('같은 코드는 3초 안에 다시 알리지 않고, 지나면 다시 알린다', () async { + api.server.on( + 'GET', + _mailState, + (_) => FakeResponse.fail(429, 'REQ_001'), + ); + + await errorOf(api.dio.get(_mailState)); + api.clock.advance(const Duration(seconds: 2)); + await errorOf(api.dio.get(_mailState)); + api.clock.advance(const Duration(seconds: 2)); + await errorOf(api.dio.get(_mailState)); + + expect(api.notifier.shownCodes, ['REQ_001', 'REQ_001']); + }); + + test('200 에 success: false 가 와도 실패로 바꿔 코드로 알린다', () async { + api.server.on( + 'GET', + _mailState, + (_) => const FakeResponse(200, { + 'success': false, + 'error': {'code': 'MAIL_001', 'traceId': 't-1'}, + }), + ); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'MAIL_001'); + expect(error.traceId, 't-1'); + expect(api.notifier.shownCodes, ['MAIL_001']); + }); + }); + + group('다시 보낸 요청 · 세션을 넘는 응답', () { + test('401 뒤 다시 보낸 요청이 AUTH_010 이면 제한을 다시 맞추고 확인했으면 알리지 않는다', () async { + await api.signIn(); + api.recheckAnswer = RestrictionCheck.restricted; + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(restriction: 'passwordChange'), + 'tokens': tokensJson(2), + }), + ); + api.server.on('GET', _mailState, (RequestOptions options) { + return bearerTokenOf(options) == 'access-2' + ? FakeResponse.fail(403, 'AUTH_010') + : FakeResponse.fail(401, 'AUTH_001'); + }); + + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + + expect(error.code, 'AUTH_010'); + expect(api.recheckCount, 1); + expect(api.server.received('GET', _mailState), hasLength(2)); + expect(api.notifier.shownCodes, isEmpty); + }); + + test('다시 보낸 요청이 401 인데 그사이 사용자가 로그아웃했으면 세션 만료로 알리지 않는다', () async { + await api.signIn(); + refreshIssuesNext(); + final Completer release = Completer(); + api.server.on('GET', _mailState, (RequestOptions options) async { + if (bearerTokenOf(options) == 'access-2') await release.future; + return FakeResponse.fail(401, 'AUTH_001'); + }); + + final Future pending = errorOf(api.dio.get(_mailState)); + await pumpEventsUntil( + () => api.server.received('GET', _mailState).length == 2, + reason: '다시 보낸 요청이 서버에 닿기', + ); + await api.store.signOut(); + release.complete(); + + final ApiException error = apiErrorOf(await pending); + expect(error.code, 'AUTH_001'); + expect(api.store.signedOutByUser, isTrue); + expect(api.emitted.whereType(), isEmpty); + expect(api.notifier.shownCodes, isEmpty); + }); + + test('로그아웃 뒤 다른 직원이 로그인했으면 앞 세션의 늦은 401 을 새 토큰으로 다시 보내지 않는다', () async { + await api.signIn(); + final Completer release = Completer(); + api.server.on('GET', _mailState, (_) async { + await release.future; + return FakeResponse.fail(401, 'AUTH_001'); + }); + refreshIssuesNext(); + + final Future pending = errorOf(api.dio.get(_mailState)); + await pumpEventsUntil( + () => api.server.received('GET', _mailState).isNotEmpty, + reason: '앞 직원의 요청이 서버에 닿기', + ); + await api.store.signOut(); + await api.store.save(tokensFor(5, now: api.clock.now)); + release.complete(); + + expect(apiErrorOf(await pending).code, 'AUTH_001'); + expect(api.server.received('GET', _mailState), hasLength(1)); + expect(refreshCount(), 0); + expect(api.store.accessToken, 'access-5'); + expect(api.notifier.shownCodes, isEmpty); + }); + + test('같은 토큰으로 줄 선 AUTH_010 셋은 제한을 한 번만 묻는다(제한을 확인한 결과를 다시 쓴다)', () async { + await api.signIn(); + api.recheckAnswer = RestrictionCheck.restricted; + api.server.on( + 'GET', + _mailState, + (_) => FakeResponse.fail(403, 'AUTH_010'), + ); + + final List errors = await Future.wait(>[ + errorOf(api.dio.get(_mailState)), + errorOf(api.dio.get(_mailState)), + errorOf(api.dio.get(_mailState)), + ]); + + expect(errors.map((Object e) => apiErrorOf(e).code).toSet(), { + 'AUTH_010', + }); + expect(api.recheckCount, 1); + expect(api.notifier.shownCodes, isEmpty); + }); + + test('앞 요청이 다시 맞춰 새 토큰을 받았으면 줄 선 AUTH_010 은 묻지 않고 다시 보낸다', () async { + await api.signIn(); + api.onRecheck = () async { + await api.store.save(tokensFor(2, now: api.clock.now)); + return RestrictionCheck.renewed; + }; + api.server.on('GET', _mailState, (RequestOptions options) { + return bearerTokenOf(options) == 'access-2' + ? FakeResponse.ok({'state': 's1'}) + : FakeResponse.fail(403, 'AUTH_010'); + }); + + final List> responses = + await Future.wait(>>[ + api.dio.get(_mailState), + api.dio.get(_mailState), + api.dio.get(_mailState), + ]); + + expect( + responses.map((Response r) => r.statusCode).toSet(), + {200}, + ); + expect(api.recheckCount, 1); + expect(api.server.received('GET', _mailState), hasLength(6)); + }); + + test('갱신한 토큰을 보안 저장소에 두지 못해도 이번 실행은 메모리의 새 토큰으로 이어 가고 다음에 다시 쓴다', () async { + await api.signIn(); + refreshIssuesNext(); + protectedRoute(validToken: 'access-2'); + api.secure.failWrites = true; + + final Response response = await api.dio.get(_mailState); + + expect(response.statusCode, 200); + expect(api.store.accessToken, 'access-2'); + expect(api.store.hasSession, isTrue); + expect(api.secure.values[kRefreshTokenKey], 'refresh-1'); + expect(api.notifier.shownCodes, isEmpty); + + // 저장소가 돌아오면 다음 읽기 때 메모리의 새 토큰을 다시 쓴다 + api.secure.failWrites = false; + expect(await api.store.readRefreshToken(), 'refresh-2'); + expect(api.secure.values[kRefreshTokenKey], 'refresh-2'); + }); + + test('리프레시 토큰을 읽는 사이 로그아웃하면 세션 끝으로 다루지 않는다(알리지 않는다)', () async { + await api.coldStartWithRefreshToken('refresh-1'); + refreshIssuesNext(); + protectedRoute(validToken: 'access-2'); + final Completer gate = Completer(); + api.secure.readGate = gate; + final int readsBefore = api.secure.readCalls; + + final Future pending = errorOf(api.dio.get(_mailState)); + // 갱신이 리프레시 토큰을 읽으려고 기다리는 동안 로그아웃한다 + await pumpEventsUntil( + () => api.secure.readCalls > readsBefore, + reason: '갱신이 리프레시 토큰을 읽기 시작', + ); + final Future signingOut = api.store.signOut(); + api.secure.readGate = null; + gate.complete(); + await signingOut; + + expect(apiErrorOf(await pending).code, 'AUTH_001'); + expect(refreshCount(), 0); + expect(api.store.signedOutByUser, isTrue); + expect(api.emitted.whereType(), isEmpty); + expect(api.notifier.shownCodes, isEmpty); + }); + + test('유예 응답(tokens: null)이면 메모리의 액세스 토큰으로 서버 세션도 조용히 끝낸다', () async { + await api.signIn(); + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': null, + }), + ); + api.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + protectedRoute(validToken: 'never'); + + expect( + apiErrorOf(await errorOf(api.dio.get(_mailState))).code, + 'AUTH_002', + ); + await pumpEventsUntil( + () => api.server.received('POST', ApiPaths.appLogout).isNotEmpty, + reason: '조용한 로그아웃', + ); + final RequestOptions logout = api.server + .received('POST', ApiPaths.appLogout) + .single; + expect(logout.data, {}); + expect(bearerTokenOf(logout), 'access-1'); + }); + + test('갱신 알림 · 결과에는 세션 칸만 싣고 토큰은 싣지 않는다', () async { + await api.signIn(); + refreshIssuesNext(); + protectedRoute(validToken: 'access-2'); + + await api.dio.get(_mailState); + + final SessionRefreshed refreshed = api.emitted + .whereType() + .single; + expect(refreshed.body.keys.toSet(), kSessionFieldNames); + expect(refreshed.body.containsKey('tokens'), isFalse); + }); + + test('비밀이 든 요청의 실패 예외는 본문과 Bearer 를 비운 채 온다', () async { + await api.signIn(); + api.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(401, 'AUTH_004'), + ); + api.server.on( + 'GET', + _mailState, + (_) => FakeResponse.fail(404, 'RES_001'), + ); + const Map loginBody = { + 'email': 'ara.kim@arasystem.it.kr', + 'password': 'secret-pw', + }; + + final Object login = await errorOf( + api.dio.post( + ApiPaths.appLogin, + data: loginBody, + options: ApiOptions.secret(silent: true), + ), + ); + final Object plain = await errorOf(api.dio.get(_mailState)); + + final DioException loginError = login as DioException; + expect(loginError.requestOptions.data, isNull); + expect(loginError.response?.requestOptions.data, isNull); + final DioException plainError = plain as DioException; + expect( + plainError.requestOptions.headers.containsKey(kAuthorizationHeader), + isFalse, + ); + // 서버에는 그대로 갔다(보낸 그때의 모습) + expect( + api.server.received('POST', ApiPaths.appLogin).single.data, + loginBody, + ); + }); + + test('인터셉터 안에서 뜻밖의 예외가 나도 요청은 SYS_001 로 끝난다(줄이 서지 않는다)', () async { + await api.coldStartWithRefreshToken('refresh-1'); + api.secure.throwOnRead = StateError('뜻밖의 오류(시험)'); + protectedRoute(validToken: 'access-2'); + + final ApiException beforeSend = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + expect(beforeSend.code, 'SYS_001'); + expect(api.server.received('GET', _mailState), isEmpty); + + // 401 뒤의 갱신에서 나도 같다 + api.secure.throwOnRead = null; + await api.store.save(tokensFor(1, now: api.clock.now)); + api.secure.throwOnRead = StateError('뜻밖의 오류(시험)'); + final ApiException afterError = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); + expect(afterError.code, 'SYS_001'); + expect(api.server.received('GET', _mailState), hasLength(1)); + }); + }); +} diff --git a/flutter/test/core/api/session_events_test.dart b/flutter/test/core/api/session_events_test.dart new file mode 100644 index 0000000..a98f21e --- /dev/null +++ b/flutter/test/core/api/session_events_test.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('제한 다시 맞추기 요청', () { + test('듣는 쪽이 없으면 기다리지 않고 unknown 이다', () async { + final SessionEventBus bus = SessionEventBus(); + addTearDown(bus.close); + + expect(await bus.requestRecheck(), RestrictionCheck.unknown); + }); + + test('듣는 쪽이 답하면 그 답이다', () async { + final SessionEventBus bus = SessionEventBus(); + addTearDown(bus.close); + bus.stream.listen((SessionEvent event) { + if (event is SessionRecheckNeeded) { + event + ..reply(RestrictionCheck.restricted) + // 두 번째 답은 쓰지 않는다 + ..reply(RestrictionCheck.renewed); + } + }); + + expect(await bus.requestRecheck(), RestrictionCheck.restricted); + }); + + testWidgets('듣는 쪽이 답하지 않으면 한도가 지나 unknown 이다(손으로 돌리는 시계)', ( + WidgetTester tester, + ) async { + final SessionEventBus bus = SessionEventBus(); + bus.stream.listen((_) {}); + RestrictionCheck? answer; + unawaited( + bus.requestRecheck().then((RestrictionCheck check) => answer = check), + ); + + await tester.pump( + kRestrictionRecheckTimeout - const Duration(seconds: 1), + ); + expect(answer, isNull); + await tester.pump(const Duration(seconds: 1)); + expect(answer, RestrictionCheck.unknown); + unawaited(bus.close()); + }); + + test('한도는 안쪽 Dio 두 요청(세션 확인 · 갱신)의 연결 · 받기 한도를 다 쓴 것보다 길다', () { + expect( + kRestrictionRecheckTimeout > + (kApiConnectTimeout + kApiReceiveTimeout) * 2, + isTrue, + ); + }); + }); + + test('갱신 응답에서 세션 칸만 남긴다(토큰을 빼고)', () { + expect( + sessionFieldsOf({ + 'email': 'ara.kim@arasystem.it.kr', + 'name': '김아라', + 'restriction': null, + 'twoFactorDeadline': null, + 'tokens': {'refreshToken': 'r'}, + 'other': 1, + }), + { + 'email': 'ara.kim@arasystem.it.kr', + 'name': '김아라', + 'restriction': null, + 'twoFactorDeadline': null, + }, + ); + }); +} diff --git a/flutter/test/core/api/user_agent_test.dart b/flutter/test/core/api/user_agent_test.dart new file mode 100644 index 0000000..6893afb --- /dev/null +++ b/flutter/test/core/api/user_agent_test.dart @@ -0,0 +1,47 @@ +import 'package:arasystem_app/core/api/user_agent.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('User-Agent', () { + test('계약 1절 "앱 로그인"의 꼴로 만든다', () { + expect( + buildUserAgent( + appVersion: '1.0.0', + osName: 'Android', + osVersion: '15', + model: 'SM-S921N', + ), + 'ARASYSTEM-App/1.0.0 (Android 15; SM-S921N)', + ); + }); + + test('모르는 칸은 빼고 아는 것만 싣는다', () { + expect( + buildUserAgent(appVersion: null, osName: 'Android', osVersion: '15'), + 'ARASYSTEM-App (Android 15)', + ); + expect( + buildUserAgent(appVersion: '1.0.0', osName: ''), + 'ARASYSTEM-App/1.0.0', + ); + }); + + test('머리글을 깨는 글자(괄호 · 쌍반점 · 한글)는 지우고 길이를 줄인다', () { + final String agent = buildUserAgent( + appVersion: '1.0.0', + osName: 'Android', + osVersion: '15', + model: '갤럭시 (S24); Ultra', + ); + expect(agent, 'ARASYSTEM-App/1.0.0 (Android 15; S24 Ultra)'); + + final String long = buildUserAgent( + appVersion: '1.0.0', + osName: 'Android', + model: 'X' * 200, + ); + expect(long.length, lessThan(120)); + expect(RegExp(r'^[\x20-\x7E]+$').hasMatch(long), isTrue); + }); + }); +} diff --git a/flutter/test/core/modules/app_modules_test.dart b/flutter/test/core/modules/app_modules_test.dart new file mode 100644 index 0000000..7469188 --- /dev/null +++ b/flutter/test/core/modules/app_modules_test.dart @@ -0,0 +1,150 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/modules/module_routes.dart'; +import 'package:arasystem_app/core/push/push_target.dart'; +import 'package:arasystem_app/core/router/module_registry.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +void main() { + group('모듈 목록', () { + test('웹 app-modules.ts 와 같은 id · 이름 · 차례다(설정은 맨 끝)', () { + // 짝: frontend/src/shared/constants/app-modules.ts 의 APP_MODULES — 한쪽을 바꾸면 둘 다 + expect( + kAppModules.map((AppModule m) => '${m.id.name}:${m.label}').toList(), + [ + 'mail:메일', + 'calendar:캘린더', + 'files:파일', + 'contacts:주소록', + 'settings:설정', + ], + ); + }); + + test('모든 모듈 id 가 목록에 정확히 한 번 있다', () { + for (final AppModuleId id in AppModuleId.values) { + expect( + kAppModules.where((AppModule m) => m.id == id), + hasLength(1), + reason: id.name, + ); + } + expect(kAppModules, hasLength(AppModuleId.values.length)); + }); + + test('홈은 넘겨받은 목록을 본다 — 메일이 꺼졌으면 켜진 첫 모듈', () { + final List mailOff = [ + for (final AppModule m in kAppModules) + m.id == AppModuleId.mail + ? AppModule( + id: m.id, + label: m.label, + icon: m.icon, + selectedIcon: m.selectedIcon, + isEnabled: false, + ) + : m, + ]; + + expect(homeLocationOf(), '/mail'); + expect(homeLocationOf(mailOff), '/settings'); + }); + + test('첫 판은 메일 · 설정만 켠다 — 켜진 것만 목록 차례대로', () { + expect( + enabledAppModules().map((AppModule m) => m.id).toList(), + [AppModuleId.mail, AppModuleId.settings], + ); + }); + + test('주소는 모듈 이름부터 시작하고, 경로의 첫 칸으로 켜진 모듈을 찾는다', () { + expect(findAppModule(AppModuleId.mail).rootPath, '/mail'); + expect(homeLocation, '/mail'); + expect(enabledModuleOfPath('/mail/messages/1')?.id, AppModuleId.mail); + expect(enabledModuleOfPath('/settings')?.id, AppModuleId.settings); + expect(enabledModuleOfPath('/calendar'), isNull); + expect(enabledModuleOfPath('/bogus'), isNull); + expect(enabledModuleOfPath('/'), isNull); + }); + + test('켜진 모듈은 모두 라우트 묶음이 있고, 첫 라우트가 모듈 경로다', () { + final List routes = enabledModuleRoutes(); + expect(routes.map((ModuleRoutes r) => r.id).toList(), [ + AppModuleId.mail, + AppModuleId.settings, + ]); + for (final ModuleRoutes module in routes) { + final List built = module.buildRoutes( + ModuleRouteContext(rootNavigatorKey: GlobalKey()), + ); + expect( + (built.first as GoRoute).path, + findAppModule(module.id).rootPath, + reason: module.id.name, + ); + } + }); + + test('켰는데 라우트 묶음이 없는 모듈은 라우터를 만들 때 멈춘다', () { + final List withCalendar = [ + for (final AppModule m in kAppModules) + m.id == AppModuleId.calendar + ? AppModule( + id: m.id, + label: m.label, + icon: m.icon, + selectedIcon: m.selectedIcon, + isEnabled: true, + ) + : m, + ]; + expect(() => enabledModuleRoutes(withCalendar), throwsStateError); + }); + }); + + group('알림 · 깊은 링크', () { + test('모듈 이름과 id 로 화면을 고른다', () { + expect( + locationForPush( + PushTarget.fromData({'module': 'mail', 'id': 'm 1'}), + moduleRoutesOf, + ), + MailRoutes.message('m 1'), + ); + expect( + locationForPush( + PushTarget.fromData({'module': 'mail'}), + moduleRoutesOf, + ), + '/mail', + ); + expect( + locationForPush( + PushTarget.fromData({ + 'module': 'settings', + 'id': 'x', + }), + moduleRoutesOf, + ), + '/settings', + ); + }); + + test('모르는 · 꺼진 모듈이면 메일 첫 화면이다', () { + expect( + locationForPush( + PushTarget.fromData({ + 'module': 'calendar', + 'id': 'e1', + }), + moduleRoutesOf, + ), + homeLocation, + ); + expect(PushTarget.fromData({'module': 'bogus'}), isNull); + expect(locationForPush(null, moduleRoutesOf), homeLocation); + }); + }); +} diff --git a/flutter/test/core/router/session_redirect_test.dart b/flutter/test/core/router/session_redirect_test.dart new file mode 100644 index 0000000..3de0095 --- /dev/null +++ b/flutter/test/core/router/session_redirect_test.dart @@ -0,0 +1,155 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/router/app_routes.dart'; +import 'package:arasystem_app/core/router/session_redirect.dart'; +import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +AsyncValue _signedIn([SessionRestriction? restriction]) { + return AsyncData( + SignedIn( + AuthSessionInfo( + email: 'ara.kim@arasystem.it.kr', + name: '김아라', + restriction: restriction, + ), + ), + ); +} + +void main() { + group('세션 상태로 화면 고르기', () { + test('되살리는 중이면 시작 화면으로 가고, 이미 있으면 그대로 둔다', () { + const AsyncValue loading = AsyncLoading(); + + expect(resolveSessionRedirect(loading, homeLocation), AppRoutes.start); + expect(resolveSessionRedirect(loading, AppRoutes.start), isNull); + }); + + test('되살리기가 장애로 실패해도 시작 화면이다([다시 시도] 자리)', () { + final AsyncValue failed = AsyncError( + Exception('offline'), + StackTrace.empty, + ); + + expect(resolveSessionRedirect(failed, AppRoutes.login), AppRoutes.start); + }); + + test('로그아웃이면 어디서든 로그인 화면으로 간다', () { + const AsyncValue signedOut = AsyncData( + SignedOut(), + ); + + expect(resolveSessionRedirect(signedOut, homeLocation), AppRoutes.login); + expect( + resolveSessionRedirect(signedOut, AppRoutes.loginCode), + AppRoutes.login, + ); + expect(resolveSessionRedirect(signedOut, AppRoutes.login), isNull); + }); + + test('코드 단계면 코드 화면이다', () { + const AsyncValue code = AsyncData( + CodeRequired('ara.kim@arasystem.it.kr'), + ); + + expect( + resolveSessionRedirect(code, AppRoutes.login), + AppRoutes.loginCode, + ); + expect(resolveSessionRedirect(code, AppRoutes.loginCode), isNull); + }); + + test('제한된 세션은 그 단계 화면에서 벗어나지 못한다', () { + expect( + resolveSessionRedirect( + _signedIn(SessionRestriction.passwordChange), + homeLocation, + ), + AppRoutes.passwordChange, + ); + expect( + resolveSessionRedirect( + _signedIn(SessionRestriction.twoFactorSetup), + AppRoutes.passwordChange, + ), + AppRoutes.twoFactorSetup, + ); + expect( + resolveSessionRedirect( + _signedIn(SessionRestriction.unknown), + homeLocation, + ), + AppRoutes.restricted, + ); + expect( + resolveSessionRedirect( + _signedIn(SessionRestriction.twoFactorSetup), + AppRoutes.twoFactorSetup, + ), + isNull, + ); + }); + + test('로그인했으면 로그인 전 화면에서 메일로 가고, 로그인 뒤 화면은 그대로 둔다', () { + expect( + resolveSessionRedirect(_signedIn(), AppRoutes.start), + homeLocation, + ); + expect( + resolveSessionRedirect(_signedIn(), AppRoutes.login), + homeLocation, + ); + expect( + resolveSessionRedirect(_signedIn(), AppRoutes.passwordChange), + homeLocation, + ); + expect(resolveSessionRedirect(_signedIn(), homeLocation), isNull); + expect(resolveSessionRedirect(_signedIn(), '/mail/inbox/m1'), isNull); + }); + + test('로그인했는데 모르는 모듈 · 꺼진 모듈의 주소면 메일 첫 화면으로 간다', () { + expect(resolveSessionRedirect(_signedIn(), '/calendar'), homeLocation); + expect(resolveSessionRedirect(_signedIn(), '/files/f1'), homeLocation); + expect(resolveSessionRedirect(_signedIn(), '/nowhere'), homeLocation); + expect(resolveSessionRedirect(_signedIn(), '/'), homeLocation); + expect(resolveSessionRedirect(_signedIn(), '/settings/about'), isNull); + }); + + test('홈은 넘겨받은 모듈 목록을 따른다 — 메일이 꺼졌으면 켜진 첫 모듈로', () { + final List mailOff = [ + for (final AppModule m in kAppModules) + m.id == AppModuleId.mail + ? AppModule( + id: m.id, + label: m.label, + icon: m.icon, + selectedIcon: m.selectedIcon, + isEnabled: false, + ) + : m, + ]; + + expect( + resolveSessionRedirect(_signedIn(), AppRoutes.login, modules: mailOff), + '/settings', + ); + expect( + resolveSessionRedirect(_signedIn(), '/mail', modules: mailOff), + '/settings', + ); + }); + + test('모르는 제한 이름은 제한이 있는 것(unknown)으로 읽는다', () { + expect(SessionRestriction.parse(null), isNull); + expect(SessionRestriction.parse(''), isNull); + expect( + SessionRestriction.parse('passwordChange'), + SessionRestriction.passwordChange, + ); + expect(SessionRestriction.parse('newThing'), SessionRestriction.unknown); + expect(SessionRestriction.parse('unknown'), SessionRestriction.unknown); + }); + }); +} diff --git a/flutter/test/core/storage/token_store_test.dart b/flutter/test/core/storage/token_store_test.dart new file mode 100644 index 0000000..4fd76f9 --- /dev/null +++ b/flutter/test/core/storage/token_store_test.dart @@ -0,0 +1,140 @@ +import 'package:arasystem_app/core/storage/auth_tokens.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; + +void main() { + group('첫 실행', () { + test('첫 실행 표시가 없으면 보안 저장소에 남은 토큰을 지우고 표시를 남긴다', () async { + final ApiHarness api = ApiHarness(firstRunMarked: false); + // 앱을 지웠다 다시 깔았다 — iOS Keychain 에는 옛 토큰이 남아 있다 + api.secure.values[kRefreshTokenKey] = 'refresh-old'; + + await api.store.prepare(); + + expect(api.secure.values, isEmpty); + expect(api.secure.deleteAllCount, 1); + expect(api.firstRun.marked, isTrue); + expect(api.store.hasSession, isFalse); + }); + + test('첫 실행 표시가 있으면 남은 리프레시 토큰으로 세션이 있다고 본다', () async { + final ApiHarness api = ApiHarness(); + api.secure.values[kRefreshTokenKey] = 'refresh-1'; + + await api.store.prepare(); + + expect(api.secure.deleteAllCount, 0); + expect(api.store.hasSession, isTrue); + // 액세스 토큰은 메모리에만 있으므로 새로 띄우면 없다 + expect(api.store.accessToken, isNull); + expect(api.store.hasUsableAccessToken, isFalse); + }); + + test('두 번째 실행부터는 지우지 않는다', () async { + final ApiHarness api = ApiHarness(firstRunMarked: false); + await api.store.prepare(); + api.secure.values[kRefreshTokenKey] = 'refresh-1'; + + await api.store.prepare(); + + expect(api.secure.values[kRefreshTokenKey], 'refresh-1'); + expect(api.secure.deleteAllCount, 1); + }); + }); + + group('보관', () { + late ApiHarness api; + + setUp(() async { + api = ApiHarness(); + await api.store.prepare(); + }); + + test('리프레시 토큰은 보안 저장소에, 액세스 토큰은 메모리에만 둔다', () async { + await api.store.save(tokensFor(1)); + + expect(api.secure.values, { + kRefreshTokenKey: 'refresh-1', + }); + expect(api.store.accessToken, 'access-1'); + expect(api.store.hasSession, isTrue); + expect(await api.store.readRefreshToken(), 'refresh-1'); + }); + + test('저장 · 지우기마다 토큰 세대가 오른다', () async { + final int start = api.store.generation; + await api.store.save(tokensFor(1)); + await api.store.clear(); + + expect(api.store.generation, start + 2); + expect(api.store.accessToken, isNull); + expect(api.store.hasSession, isFalse); + expect(api.secure.values, isEmpty); + }); + + test('액세스 토큰은 만료 30초 전부터 쓸 수 없는 것으로 본다', () async { + await api.store.save(tokensFor(1, now: api.clock.now)); + + api.clock.advance(const Duration(minutes: 59, seconds: 29)); + expect(api.store.hasUsableAccessToken, isTrue); + api.clock.advance(const Duration(seconds: 1)); + expect(api.store.hasUsableAccessToken, isFalse); + }); + + test('남은 시간은 응답의 Date(서버 시각)로 잰다 — 기기 시계가 2시간 빨라도 쓸 수 있다', () async { + final DateTime serverNow = api.clock.now.subtract( + const Duration(hours: 2), + ); + final AuthTokens tokens = AuthTokens.tryParse( + tokensJson(1, now: serverNow), + serverTime: serverNow, + )!; + + await api.store.save(tokens); + + // 기기 시계로는 이미 지난 만료 시각이지만 받은 때부터 1시간을 쓸 수 있다 + expect(tokens.accessExpiresAt.isBefore(api.clock.now), isTrue); + expect(api.store.hasUsableAccessToken, isTrue); + api.clock.advance(const Duration(minutes: 59, seconds: 29)); + expect(api.store.hasUsableAccessToken, isTrue); + api.clock.advance(const Duration(seconds: 1)); + expect(api.store.hasUsableAccessToken, isFalse); + }); + + test('기기 시계를 뒤로 돌리면 지난 시간을 모르니 미리 갱신하는 쪽으로 본다', () async { + await api.store.save(tokensFor(1, now: api.clock.now)); + + api.clock.advance(const Duration(minutes: -1)); + + expect(api.store.hasUsableAccessToken, isFalse); + }); + + test('사용자가 로그아웃한 것을 기억하고, 새 로그인이 지운다', () async { + await api.store.save(tokensFor(1)); + await api.store.clear(byUser: true); + expect(api.store.signedOutByUser, isTrue); + + await api.store.save(tokensFor(2)); + expect(api.store.signedOutByUser, isFalse); + + await api.store.clear(); + expect(api.store.signedOutByUser, isFalse); + }); + + test('토큰 값은 toString 에 보이지 않는다', () { + expect(tokensFor(1).toString(), isNot(contains('access-1'))); + expect(tokensFor(1).toString(), isNot(contains('refresh-1'))); + }); + + test('모양이 틀린 tokens 칸은 읽지 않는다', () { + expect(AuthTokens.tryParse(null), isNull); + expect( + AuthTokens.tryParse({'accessToken': 'a'}), + isNull, + ); + expect(AuthTokens.tryParse(tokensJson(3))?.refreshToken, 'refresh-3'); + }); + }); +} diff --git a/flutter/test/core/theme/app_theme_test.dart b/flutter/test/core/theme/app_theme_test.dart new file mode 100644 index 0000000..ff02be3 --- /dev/null +++ b/flutter/test/core/theme/app_theme_test.dart @@ -0,0 +1,57 @@ +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/theme/app_fonts.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('테마', () { + final ThemeData theme = AppTheme.light(); + + test('웹 토큰의 브랜드 색을 시드 없이 그대로 쓴다', () { + expect(theme.colorScheme.primary, AppColors.brand); + expect(theme.colorScheme.primaryContainer, AppColors.brandSubtle); + expect(theme.colorScheme.secondary, AppColors.brandSurface); + expect(theme.colorScheme.error, AppColors.danger); + expect(theme.colorScheme.onSurface, AppColors.text); + expect(theme.extension()?.textMuted, AppColors.textMuted); + }); + + test('글꼴은 Pretendard, 본문은 15 기준이다', () { + expect(theme.textTheme.bodyMedium?.fontFamily, AppFonts.family); + expect(theme.textTheme.bodyMedium?.fontSize, AppTypography.bodyMedium); + expect(AppTypography.bodyMedium, 15); + }); + + test('단추의 누르는 곳은 48dp 이상이다', () { + expect(AppSize.touchMin, 48); + expect( + theme.filledButtonTheme.style?.minimumSize?.resolve({}), + const Size(AppSize.touchMin, AppSize.touchMin), + ); + expect(theme.materialTapTargetSize, MaterialTapTargetSize.padded); + }); + }); + + test('Pretendard 의 OFL 1.1 라이선스를 라이선스 화면에 올린다', () async { + AppFonts.registerFontLicenses(); + + final List entries = await LicenseRegistry.licenses.toList(); + final Iterable pretendard = entries.where( + (LicenseEntry entry) => + entry.packages.contains(AppFonts.licensePackageName), + ); + + expect(pretendard, isNotEmpty); + final String text = pretendard.first.paragraphs + .map((LicenseParagraph p) => p.text) + .join('\n'); + expect(text, contains('SIL OPEN FONT LICENSE Version 1.1')); + expect(text, contains('Reserved Font Name Pretendard')); + }); +} diff --git a/flutter/test/error_lexicon_test.dart b/flutter/test/error_lexicon_test.dart index 2210e0b..f455b57 100644 --- a/flutter/test/error_lexicon_test.dart +++ b/flutter/test/error_lexicon_test.dart @@ -19,7 +19,8 @@ void main() { }); test('클라이언트 로직이 참조하는 코드가 사전에 존재한다', () { - // dio_client 의 상태코드 역매핑과 세션 만료 승격이 이 코드들을 직접 사용한다. + // 상태 코드 역매핑(api_error_mapper) · 세션 만료 승격 · AUTH_010 다시 확인(auth_interceptor) · + // 로그인 화면이 칸에 보이는 실패(auth_repository 의 kLoginInlineCodes)가 이 코드들을 직접 사용한다. // // ⚠️ 전체 목록을 비교하지 않는 이유: 세 플랫폼의 사전은 backend 레지스트리에서 // 생성되므로 목록 일치는 구조적으로 보장되고, CI 가 드리프트를 검사한다. @@ -30,9 +31,14 @@ void main() { 'AUTH_001', 'AUTH_002', 'AUTH_003', + 'AUTH_004', + 'AUTH_006', + 'AUTH_007', + 'AUTH_010', 'VAL_001', 'RES_001', 'BIZ_001', + 'FILE_002', ]; for (final code in usedByClient) { diff --git a/flutter/test/features/auth/session_provider_test.dart b/flutter/test/features/auth/session_provider_test.dart new file mode 100644 index 0000000..5394d26 --- /dev/null +++ b/flutter/test/features/auth/session_provider_test.dart @@ -0,0 +1,663 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/provider_harness.dart'; + +void main() { + late ProviderHarness h; + late ProviderContainer container; + + Future start({String? refreshToken}) async { + await h.prepare(refreshToken: refreshToken); + container = h.container(); + addTearDown(container.dispose); + // 세션을 듣기 시작해 되살리기를 돌린다 + container.listen(sessionProvider, (_, _) {}); + return container; + } + + Future settled() async { + try { + return await container.read(sessionProvider.future); + } on Object { + return null; + } + } + + SessionNotifier notifier() => container.read(sessionProvider.notifier); + + SessionState? current() => container.read(sessionProvider).value; + + SessionRestriction? restrictionNow() { + final SessionState? state = current(); + return state is SignedIn ? state.info.restriction : null; + } + + void refreshGives(int n, {String? restriction}) { + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(restriction: restriction), + 'tokens': tokensJson(n, now: h.clock.now), + }), + ); + } + + /// 코드 단계로 답하는 로그인 + FakeResponse codeStep() => FakeResponse.ok({ + 'status': 'twoFactorRequired', + 'email': null, + 'name': null, + 'restriction': null, + 'twoFactorDeadline': null, + 'tokens': null, + }); + + /// 로그인을 마친 응답 ([n] 번째 토큰) + FakeResponse signedInWith(int n, {String? restriction}) => + FakeResponse.ok({ + 'status': 'signedIn', + ...sessionJson(restriction: restriction), + 'tokens': tokensJson(n, now: h.clock.now), + }); + + List logouts() => + h.server.received('POST', ApiPaths.appLogout); + + setUp(() => h = ProviderHarness()); + + group('되살리기', () { + test('리프레시 토큰이 없으면 서버에 묻지 않고 로그아웃 상태다', () async { + await start(); + + expect(await settled(), const SignedOut()); + expect(h.server.requests, isEmpty); + }); + + test('리프레시 토큰이 있으면 갱신 뒤 me 로 세션을 되살린다', () async { + await start(refreshToken: 'refresh-1'); + refreshGives(2); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')), + ); + + final SessionState? state = await settled(); + + expect(state, isA()); + expect(restrictionNow(), SessionRestriction.passwordChange); + expect( + bearerTokenOf(h.server.received('GET', ApiPaths.me).single), + 'access-2', + ); + // 되살리기는 알리지 않는다 + expect(h.notifier.shownCodes, isEmpty); + }); + + test('갱신이 거절되면 토큰을 지우고 알림 없이 로그아웃 상태로 시작한다', () async { + await start(refreshToken: 'refresh-1'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + + expect(await settled(), const SignedOut()); + expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('연결이 끊겨 되살리지 못하면 세션을 두고 오류 상태다 — [다시 시도]는 곧바로 다시 묻는다', () async { + await start(refreshToken: 'refresh-1'); + h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline); + + expect(await settled(), isNull); + expect(container.read(sessionProvider).hasError, isTrue); + expect(h.secure.values[kRefreshTokenKey], 'refresh-1'); + + // 5초 안에 눌러도 방금의 실패를 다시 쓰지 않는다 + refreshGives(2); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + notifier().retry(); + + expect(await settled(), isA()); + expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(2)); + }); + }); + + group('로그인', () { + test('2단계 인증을 켠 직원은 코드 단계로 가고, 코드와 비밀번호를 함께 보내 로그인한다', () async { + await start(); + await settled(); + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + final Map body = options.data as Map; + return body['otpCode'] == null ? codeStep() : signedInWith(1); + }); + + await notifier().signIn( + email: 'ara.kim@arasystem.it.kr', + password: 'pw-1234', + ); + expect(current(), const CodeRequired('ara.kim@arasystem.it.kr')); + + await notifier().submitCode('123456'); + + expect(current(), isA()); + expect( + h.server.received('POST', ApiPaths.appLogin).last.data, + { + 'email': 'ara.kim@arasystem.it.kr', + 'password': 'pw-1234', + 'otpCode': '123456', + }, + ); + expect(h.secure.values[kRefreshTokenKey], 'refresh-1'); + expect(h.store.accessToken, 'access-1'); + // 제한 없이 로그인을 마치면 쥐고 있던 비밀번호를 지운다 + expect(notifier().heldLogin, isNull); + }); + + test('코드 단계에 10분 넘게 머물면 보내지 않고 첫 단계로 돌아간다', () async { + await start(); + await settled(); + h.server.on('POST', ApiPaths.appLogin, (_) => codeStep()); + await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'); + expect(notifier().heldLogin?.password, 'pw'); + + h.clock.advance(const Duration(minutes: 10)); + await notifier().submitCode('123456'); + + expect(current(), const SignedOut(reason: SignOutReason.codeTimedOut)); + expect(h.server.received('POST', ApiPaths.appLogin), hasLength(1)); + }); + + test('제한된 세션으로 로그인하면 새 비밀번호 단계가 쓸 비밀번호를 쥐고 있는다', () async { + await start(); + await settled(); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => signedInWith(1, restriction: 'passwordChange'), + ); + + await notifier().signIn( + email: 'ara.kim@arasystem.it.kr', + password: 'temp-pw', + ); + + expect(restrictionNow(), SessionRestriction.passwordChange); + expect(notifier().heldLogin?.password, 'temp-pw'); + }); + + test('로그인 화면이 칸에 보이는 실패(AUTH_004)는 알리지 않고 코드로 던진다', () async { + await start(); + await settled(); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(401, 'AUTH_004'), + ); + + final Object error = await errorOf( + notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'wrong'), + ); + + expect(apiErrorOf(error).code, 'AUTH_004'); + expect(h.notifier.shownCodes, isEmpty); + expect(current(), const SignedOut()); + }); + + test('응답 모양이 계약과 다르면 DioException(SYS_001)으로 던진다', () async { + await start(); + await settled(); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.ok({'status': 'signedIn'}), + ); + + final Object error = await errorOf( + notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'), + ); + + expect(error, isA()); + expect(apiErrorOf(error).code, 'SYS_001'); + expect(h.store.hasSession, isFalse); + }); + }); + + group('늦게 온 결과 버리기 (시도 세대)', () { + test('[다른 계정으로 로그인] 뒤에 온 코드 단계의 로그인 결과는 쓰지 않고 그 세션을 조용히 끝낸다', () async { + await start(); + await settled(); + final Completer release = Completer(); + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) async { + final Map body = options.data as Map; + if (body['otpCode'] == null) return codeStep(); + await release.future; + return signedInWith(1); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'); + + final Future code = notifier().submitCode('123456'); + await pumpEventsUntil( + () => h.server.received('POST', ApiPaths.appLogin).length == 2, + reason: '코드 요청이 서버에 닿기', + ); + await notifier().switchAccount(); + release.complete(); + await code; + + expect(current(), const SignedOut(reason: SignOutReason.switchAccount)); + expect(h.store.hasSession, isFalse); + expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse); + await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃'); + expect(logouts().single.data, { + 'refreshToken': 'refresh-1', + }); + expect(bearerTokenOf(logouts().single), 'access-1'); + }); + + test('로그아웃 뒤에 온 로그인 결과도 버린다', () async { + await start(); + await settled(); + final Completer release = Completer(); + h.server.on('POST', ApiPaths.appLogin, (_) async { + await release.future; + return signedInWith(1); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + + final Future signIn = notifier().signIn( + email: 'ara.kim@arasystem.it.kr', + password: 'pw', + ); + await pumpEventsUntil( + () => h.server.received('POST', ApiPaths.appLogin).isNotEmpty, + reason: '로그인 요청이 서버에 닿기', + ); + await notifier().signOut(); + release.complete(); + await signIn; + + expect(current(), const SignedOut()); + expect(h.store.accessToken, isNull); + await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃'); + expect(bearerTokenOf(logouts().single), 'access-1'); + }); + + test('제한을 다시 맞추는 사이 로그아웃하면 결과를 쓰지 않는다(stale)', () async { + await start(refreshToken: 'refresh-1'); + refreshGives(2); + final Completer release = Completer(); + int meCalls = 0; + h.server.on('GET', ApiPaths.me, (_) async { + meCalls++; + if (meCalls == 1) return FakeResponse.ok(sessionJson()); + await release.future; + return FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup')); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + expect(await settled(), isA()); + + final Future check = notifier().recheck(); + await pumpEventsUntil(() => meCalls == 2, reason: '세션 확인이 서버에 닿기'); + await notifier().signOut(); + release.complete(); + + expect(await check, RestrictionCheck.stale); + expect(current(), const SignedOut()); + }); + }); + + group('로그인한 뒤', () { + Future signedIn({String? restriction}) async { + await start(refreshToken: 'refresh-1'); + refreshGives(2, restriction: restriction); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: restriction)), + ); + expect(await settled(), isA()); + } + + test('갱신이 거절되면(다른 곳에서 끊음) 로그인 화면으로 간다', () async { + await signedIn(); + h.clock.advance(const Duration(hours: 2)); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok([])); + + await errorOf(container.read(dioProvider).get('/mailboxes')); + await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝 알림'); + + expect(current(), const SignedOut(reason: SignOutReason.expired)); + expect(h.notifier.shownCodes, ['AUTH_002']); + }); + + test( + 'AUTH_010 — me 에 제한이 있으면 그 단계로 옮기고 원래 요청은 다시 보내지 않으며 알리지 않는다', + () async { + await signedIn(); + h.server.on( + 'GET', + '/mailboxes', + (_) => FakeResponse.fail(403, 'AUTH_010'), + ); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup')), + ); + + final Object error = await errorOf( + container.read(dioProvider).get('/mailboxes'), + ); + + expect(apiErrorOf(error).code, 'AUTH_010'); + expect(restrictionNow(), SessionRestriction.twoFactorSetup); + expect(h.server.received('GET', '/mailboxes'), hasLength(1)); + // 제한이 있으면 갱신하지 않는다 — 되살리기 때의 한 번뿐 + expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(1)); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + test('AUTH_010 — me 에 제한이 없으면(옛 토큰) 갱신한 뒤 원래 요청을 한 번 다시 보낸다', () async { + await signedIn(); + int mailboxCalls = 0; + h.server.on('GET', '/mailboxes', (_) { + mailboxCalls++; + return mailboxCalls == 1 + ? FakeResponse.fail(403, 'AUTH_010') + : FakeResponse.ok(['inbox']); + }); + refreshGives(3); + + final Response response = await container + .read(dioProvider) + .get('/mailboxes'); + + expect(response.data, ['inbox']); + final List sent = h.server.received('GET', '/mailboxes'); + expect(sent.map(bearerTokenOf), ['access-2', 'access-3']); + expect(h.notifier.shownCodes, isEmpty); + expect(current(), isA()); + }); + + test('갱신 응답의 제한이 바뀌면 화면 상태도 바꾼다', () async { + await signedIn(); + h.clock.advance(const Duration(hours: 2)); + refreshGives(3, restriction: 'twoFactorSetup'); + h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok([])); + + await container.read(dioProvider).get('/mailboxes'); + await pumpEventsUntil( + () => restrictionNow() == SessionRestriction.twoFactorSetup, + reason: '갱신 응답의 제한 반영', + ); + + expect(restrictionNow(), SessionRestriction.twoFactorSetup); + }); + + test('제한이 풀리면 쥐고 있던 비밀번호를 바로 지운다', () async { + await start(); + await settled(); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => signedInWith(1, restriction: 'passwordChange'), + ); + await notifier().signIn( + email: 'ara.kim@arasystem.it.kr', + password: 'temp-pw', + ); + expect(notifier().heldLogin, isNotNull); + + // 새 비밀번호를 정한 뒤 곧바로 갱신 — 응답에 제한이 없다 + refreshGives(2); + await container.read(authRepositoryProvider).refresh(); + await pumpEventsUntil( + () => restrictionNow() == null && current() is SignedIn, + reason: '제한이 풀린 갱신 반영', + ); + + expect(notifier().heldLogin, isNull); + }); + + test('로그아웃하면 토큰을 지우고 서버 세션을 리프레시 토큰과 Bearer 로 끝낸다', () async { + await signedIn(); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + + await notifier().signOut(); + + expect(current(), const SignedOut()); + expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse); + expect(h.store.accessToken, isNull); + expect(h.store.signedOutByUser, isTrue); + expect(logouts().single.data, { + 'refreshToken': 'refresh-2', + }); + expect(bearerTokenOf(logouts().single), 'access-2'); + }); + + test('보안 저장소를 읽지 못해도 이 기기의 토큰은 지우고 Bearer 로 서버 세션을 끝낸다', () async { + await signedIn(); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + h.secure.failReads = true; + + await notifier().signOut(); + + expect(current(), const SignedOut()); + expect(h.store.accessToken, isNull); + expect(h.store.hasSession, isFalse); + expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse); + expect(logouts().single.data, {}); + expect(bearerTokenOf(logouts().single), 'access-2'); + }); + + test('로그아웃 요청이 닿지 않아도 이 기기는 로그아웃 상태다', () async { + await signedIn(); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.offline); + + await notifier().signOut(); + + expect(current(), const SignedOut()); + expect(h.notifier.shownCodes, isEmpty); + }); + }); + + group('제한 다시 맞추기 — 노티파이어의 갈래', () { + Future signedIn() async { + await start(refreshToken: 'refresh-1'); + refreshGives(2); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + expect(await settled(), isA()); + } + + int refreshCalls() => h.server.received('POST', ApiPaths.appRefresh).length; + + test('me 가 401(액세스 토큰이 끝남)이면 갱신해 보고, 새 토큰에 제한이 없으면 renewed', () async { + await signedIn(); + h.server.on('GET', ApiPaths.me, (RequestOptions options) { + return bearerTokenOf(options) == 'access-2' + ? FakeResponse.fail(401, 'AUTH_001') + : FakeResponse.ok(sessionJson()); + }); + refreshGives(3); + + expect(await notifier().recheck(), RestrictionCheck.renewed); + expect(refreshCalls(), 2); + expect(h.store.accessToken, 'access-3'); + expect(current(), isA()); + expect(restrictionNow(), isNull); + }); + + test('me 401 뒤 갱신도 401 이면 stale — 세션 끝이 로그인 화면으로 옮긴다', () async { + await signedIn(); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + + expect(await notifier().recheck(), RestrictionCheck.stale); + await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝'); + expect(current(), const SignedOut(reason: SignOutReason.expired)); + expect(h.store.hasSession, isFalse); + }); + + test('me 가 장애(503)면 unknown — 상태와 토큰은 그대로', () async { + await signedIn(); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + + expect(await notifier().recheck(), RestrictionCheck.unknown); + expect(current(), isA()); + expect(refreshCalls(), 1); + expect(h.store.hasSession, isTrue); + }); + }); + + group('저장 · 로그아웃의 경합', () { + test('로그인 토큰을 저장하는 사이 로그아웃하면 토큰이 남지 않고 그 세션도 끝낸다', () async { + await start(); + await settled(); + h.server.on('POST', ApiPaths.appLogin, (_) => signedInWith(1)); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + final Completer gate = Completer(); + h.secure.writeGate = gate; + + final Future signIn = notifier().signIn( + email: 'ara.kim@arasystem.it.kr', + password: 'pw', + ); + await pumpEventsUntil( + () => h.secure.writeCalls == 1, + reason: '토큰 쓰기가 시작', + ); + final Future signOut = notifier().signOut(); + gate.complete(); + await signOut; + await signIn; + + expect(current(), const SignedOut()); + expect(h.store.hasSession, isFalse); + expect(h.store.accessToken, isNull); + expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse); + await pumpEventsUntil(() => logouts().isNotEmpty, reason: '서버 세션 끝내기'); + expect( + logouts().every( + (RequestOptions r) => + (r.data! as Map)['refreshToken'] == 'refresh-1', + ), + isTrue, + ); + }); + + test( + '로그인 토큰을 보안 저장소에 두지 못하면 이 기기의 토큰을 지우고 그 세션을 끝낸 뒤 SYS_001 로 던진다', + () async { + await start(); + await settled(); + h.server.on('POST', ApiPaths.appLogin, (_) => signedInWith(1)); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + h.secure.failWrites = true; + + final Object error = await errorOf( + notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'), + ); + + expect(error, isA()); + expect(apiErrorOf(error).code, 'SYS_001'); + expect(current(), const SignedOut()); + expect(h.store.hasSession, isFalse); + expect(h.store.accessToken, isNull); + await pumpEventsUntil(() => logouts().isNotEmpty, reason: '서버 세션 끝내기'); + expect(bearerTokenOf(logouts().single), 'access-1'); + }, + ); + + test('로그아웃은 토큰과 상태를 먼저 지우고, 알림 토큰 지우기를 기다리지 않는다', () async { + await start(refreshToken: 'refresh-1'); + refreshGives(2); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + expect(await settled(), isA()); + // 알림 토큰 지우기가 끝나지 않아도 로그아웃은 끝난다(짧은 한도 · 실패는 삼킨다) + h.push.unregisterGate = Completer(); + + await notifier().signOut(); + + expect(current(), const SignedOut()); + expect(h.store.hasSession, isFalse); + expect(h.push.unregistered, 1); + expect(h.push.hadSessionAtUnregister, isFalse); + expect(logouts(), hasLength(1)); + }); + + test('되살리기가 장애로 멈춘 채 [다른 계정으로 로그인]을 눌러도 남은 리프레시 토큰을 지운다', () async { + await start(refreshToken: 'refresh-1'); + h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + expect(await settled(), isNull); + + await notifier().switchAccount(); + + expect(current(), const SignedOut(reason: SignOutReason.switchAccount)); + expect(h.store.hasSession, isFalse); + expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse); + expect(logouts().single.data, { + 'refreshToken': 'refresh-1', + }); + }); + + test('되살리는 사이 로그아웃하면 늦게 온 되살리기 결과가 로그아웃을 덮지 않는다', () async { + final Completer release = Completer(); + await start(refreshToken: 'refresh-1'); + refreshGives(2); + h.server.on('GET', ApiPaths.me, (_) async { + await release.future; + return FakeResponse.ok(sessionJson()); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await pumpEventsUntil( + () => h.server.received('GET', ApiPaths.me).isNotEmpty, + reason: '되살리기의 me 가 서버에 닿기', + ); + + await notifier().signOut(); + release.complete(); + + expect(await settled(), const SignedOut()); + expect(current(), const SignedOut()); + expect(h.store.hasSession, isFalse); + }); + }); +} diff --git a/flutter/test/support/fakes.dart b/flutter/test/support/fakes.dart new file mode 100644 index 0000000..489c5c5 --- /dev/null +++ b/flutter/test/support/fakes.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/dio_client.dart'; +import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:arasystem_app/core/push/push_registration.dart'; +import 'package:arasystem_app/core/storage/auth_tokens.dart'; +import 'package:arasystem_app/core/storage/first_run_marker.dart'; +import 'package:arasystem_app/core/storage/secure_store.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 시험의 기준 시각 (UTC) +final DateTime kTestNow = DateTime.utc(2026, 11, 5, 3); + +/// 시험의 User-Agent +const String kTestUserAgent = 'ARASYSTEM-App/1.0.0 (Android 15; Pixel 8)'; + +/// 손으로 돌리는 시계 — 고정 대기 없이 시간을 넘긴다 +class ManualClock { + ManualClock([DateTime? start]) : now = start ?? kTestNow; + + DateTime now; + + DateTime call() => now; + + void advance(Duration duration) => now = now.add(duration); +} + +/// 메모리 보안 저장소 — [failReads] · [failWrites] · [failDeletes] 를 켜면 그 일을 `PlatformException` 으로 실패한다. +/// [readGate] · [writeGate] 를 두면 그 일이 그 Completer 가 끝날 때까지 기다린다(경합 시험). +class MemorySecureStore implements SecureStore { + final Map values = {}; + int deleteAllCount = 0; + int writeCount = 0; + + /// 쓰기를 부른 수(문을 기다리기 전에 센다) + int writeCalls = 0; + + /// 읽기에서 던질 뜻밖의 예외(`PlatformException` 이 아닌 것) — 인터셉터의 처리기 지키기 시험 + Object? throwOnRead; + bool failReads = false; + bool failWrites = false; + bool failDeletes = false; + Completer? readGate; + Completer? writeGate; + + static PlatformException _failure(String what) => + PlatformException(code: 'test', message: '$what 실패(시험)'); + + /// 읽기를 부른 수(문을 기다리기 전에 센다) + int readCalls = 0; + + @override + Future read(String key) async { + readCalls++; + await readGate?.future; + final Object? unexpected = throwOnRead; + if (unexpected != null) throw unexpected; + if (failReads) throw _failure('읽기'); + return values[key]; + } + + @override + Future write(String key, String value) async { + writeCalls++; + await writeGate?.future; + writeCount++; + if (failWrites) throw _failure('쓰기'); + values[key] = value; + } + + @override + Future delete(String key) async { + if (failDeletes) throw _failure('지우기'); + values.remove(key); + } + + @override + Future deleteAll() async { + deleteAllCount++; + values.clear(); + } +} + +/// 메모리 첫 실행 표시 +class MemoryFirstRunMarker implements FirstRunMarker { + MemoryFirstRunMarker({this.marked = true}); + + bool marked; + + @override + Future isMarked() async => marked; + + @override + Future mark() async => marked = true; +} + +/// 푸시 기기 등록을 적어 두는 가짜 — 지울 때 세션이 남아 있었는지도 적는다 +class RecordingPushRegistrar implements PushRegistrar { + RecordingPushRegistrar(this._store); + + final TokenStore _store; + int registered = 0; + int unregistered = 0; + bool? hadSessionAtUnregister; + + /// 두면 지우기가 이것이 끝날 때까지 기다린다 + Completer? unregisterGate; + + @override + Future register() async => registered++; + + @override + Future unregister() async { + unregistered++; + hadSessionAtUnregister = _store.hasSession; + await unregisterGate?.future; + } +} + +/// 알림을 띄우지 않고 적어 두는 알림 — 겹침 거르기([ErrorNotifier.show])는 그대로 탄다 +class RecordingNotifier extends ErrorNotifier { + RecordingNotifier(ManualClock clock) + : super( + messengerKey: GlobalKey(), + clock: clock.call, + ); + + final List shownCodes = []; + + @override + void display(ApiException error) => shownCodes.add(error.code); +} + +/// 가짜 응답 +class FakeResponse { + const FakeResponse(this.status, this.body, {this.rawBody}); + + /// 표준 성공 `{ success: true, data }` + factory FakeResponse.ok([Object? data]) => + FakeResponse(200, {'success': true, 'data': data}); + + /// 표준 실패 `{ success: false, error: { code, message, traceId } }` + factory FakeResponse.fail(int status, String code) => + FakeResponse(status, { + 'success': false, + 'error': { + 'code': code, + 'message': '', + 'traceId': 'trace-$code', + }, + }); + + /// 응답 없음(연결 끊김) + static const FakeResponse offline = FakeResponse(-1, null); + + final int status; + final Object? body; + + /// JSON 이 아닌 그대로의 본문 (있으면 [body] 대신) + final String? rawBody; +} + +typedef FakeHandler = FutureOr Function(RequestOptions options); + +/// 가짜 서버 — 경로마다 처리기를 둔다 (HttpClientAdapter) +class FakeServer implements HttpClientAdapter { + final Map _routes = {}; + + /// 받은 요청 차례대로 + final List requests = []; + + /// 응답의 `Date` 머리(서버 시각) — null 이면 싣지 않는다 + DateTime? serverTime; + + void on(String method, String path, FakeHandler handler) { + _routes['$method $path'] = handler; + } + + /// [method] [path] 로 받은 요청들 + List received(String method, String path) => requests + .where((RequestOptions r) => r.method == method && r.path == path) + .toList(); + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + // 받은 그때의 모습을 남긴다 — 인터셉터가 실패를 내보내며 토큰 · 비밀 본문을 지워도 시험이 볼 수 있게 + requests.add(options.copyWith()); + final FakeHandler? handler = _routes['${options.method} ${options.path}']; + final FakeResponse response = handler == null + ? FakeResponse.fail(404, 'RES_001') + : await handler(options); + if (response.status < 0) { + throw DioException.connectionError( + requestOptions: options, + reason: 'offline', + ); + } + final DateTime? date = serverTime; + return ResponseBody.fromString( + response.rawBody ?? jsonEncode(response.body), + response.status, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + if (date != null) + HttpHeaders.dateHeader: [HttpDate.format(date)], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +/// 요청의 Bearer 토큰 (없으면 null) +String? bearerTokenOf(RequestOptions options) { + final Object? header = options.headers['Authorization']; + if (header is! String || !header.startsWith('Bearer ')) return null; + return header.substring('Bearer '.length); +} + +/// 토큰 한 벌 — [n] 번째 발급 +AuthTokens tokensFor( + int n, { + DateTime? now, + Duration accessLife = const Duration(hours: 1), +}) { + final DateTime base = now ?? kTestNow; + return AuthTokens( + accessToken: 'access-$n', + accessExpiresAt: base.add(accessLife), + refreshToken: 'refresh-$n', + refreshExpiresAt: base.add(const Duration(days: 30)), + ); +} + +/// 응답의 `tokens` 칸 꼴 +Map tokensJson(int n, {DateTime? now}) { + final AuthTokens tokens = tokensFor(n, now: now); + return { + 'accessToken': tokens.accessToken, + 'accessExpiresAt': tokens.accessExpiresAt.toIso8601String(), + 'refreshToken': tokens.refreshToken, + 'refreshExpiresAt': tokens.refreshExpiresAt.toIso8601String(), + }; +} + +/// 직원 · 세션 칸 +Map sessionJson({String? restriction, String? deadline}) => + { + 'email': 'ara.kim@arasystem.it.kr', + 'name': '김아라', + 'restriction': restriction, + 'twoFactorDeadline': deadline, + }; + +/// 네트워크 층을 한데 묶은 시험대 +class ApiHarness { + ApiHarness({bool firstRunMarked = true}) + : firstRun = MemoryFirstRunMarker(marked: firstRunMarked); + + final FakeServer server = FakeServer(); + final MemorySecureStore secure = MemorySecureStore(); + final MemoryFirstRunMarker firstRun; + final ManualClock clock = ManualClock(); + final SessionEventBus events = SessionEventBus(); + final List emitted = []; + + /// `AUTH_010` 을 다시 맞춰 달라는 알림에 이 시험대가 돌려줄 결과 (세션 노티파이어 대신) + RestrictionCheck recheckAnswer = RestrictionCheck.unknown; + + /// 있으면 [recheckAnswer] 대신 이것으로 답한다 — 노티파이어처럼 새 토큰을 받아 두고 답하는 흉내 + Future Function()? onRecheck; + + /// 제한 다시 맞추기를 몇 번 물었나 + int get recheckCount => emitted.whereType().length; + + late final TokenStore store = TokenStore( + secure: secure, + firstRun: firstRun, + clock: clock.call, + ); + late final RecordingNotifier notifier = RecordingNotifier(clock); + late final DioClient client = DioClient.create( + config: const ApiConfig(baseUrl: 'https://portal.test/api'), + tokenStore: store, + events: events, + notifier: notifier, + userAgent: kTestUserAgent, + clock: clock.call, + httpClientAdapter: server, + ); + + Dio get dio => client.dio; + + /// 준비 — 첫 실행 처리 · 세션 알림 적기(제한 다시 맞추기에는 [recheckAnswer] 로 답한다) + Future prepare() async { + events.stream.listen((SessionEvent event) async { + emitted.add(event); + if (event is! SessionRecheckNeeded) return; + final Future Function()? answer = onRecheck; + event.reply(answer == null ? recheckAnswer : await answer()); + }); + await store.prepare(); + } + + /// 로그인한 상태로 만든다 ([n] 번째 토큰) + Future signIn([int n = 1]) => store.save(tokensFor(n, now: clock.now)); + + /// 앱을 새로 띄운 상태 — 보안 저장소에 리프레시 토큰만 있다 + Future coldStartWithRefreshToken(String refreshToken) async { + secure.values[kRefreshTokenKey] = refreshToken; + await store.prepare(); + } +} + +/// 실패에서 ApiException 을 꺼낸다 +ApiException apiErrorOf(Object error) { + if (error is DioException && error.error is ApiException) { + return error.error! as ApiException; + } + if (error is ApiException) return error; + throw StateError('ApiException 이 아니다: $error'); +} + +/// [condition] 이 될 때까지 이벤트 차례를 넘긴다 — 고정 대기 없이, [maxTurns] 를 넘으면 실패한다 +Future pumpEventsUntil( + bool Function() condition, { + int maxTurns = 1000, + String reason = '조건', +}) async { + for (int turn = 0; turn < maxTurns; turn++) { + if (condition()) return; + await Future.delayed(Duration.zero); + } + if (!condition()) throw StateError('$reason 이 $maxTurns 차례 안에 되지 않았다'); +} + +/// 미래가 실패하면 그 오류를 돌려준다(성공하면 시험 실패) +Future errorOf(Future future) async { + try { + await future; + } on Object catch (error) { + return error; + } + throw StateError('실패해야 하는데 성공했다'); +} diff --git a/flutter/test/support/provider_harness.dart b/flutter/test/support/provider_harness.dart new file mode 100644 index 0000000..83fc5b1 --- /dev/null +++ b/flutter/test/support/provider_harness.dart @@ -0,0 +1,47 @@ +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/push/push_registration.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; + +import 'fakes.dart'; + +/// 프로바이더 시험대 — 가짜 서버 · 메모리 저장소 · 손 시계로 앱의 프로바이더를 띄운다 +class ProviderHarness { + ProviderHarness(); + + final FakeServer server = FakeServer(); + final MemorySecureStore secure = MemorySecureStore(); + final MemoryFirstRunMarker firstRun = MemoryFirstRunMarker(); + final ManualClock clock = ManualClock(); + + late final TokenStore store = TokenStore( + secure: secure, + firstRun: firstRun, + clock: clock.call, + ); + late final RecordingNotifier notifier = RecordingNotifier(clock); + late final RecordingPushRegistrar push = RecordingPushRegistrar(store); + + /// ProviderScope · ProviderContainer 에 넣을 덮어쓰기 + List get overrides => [ + tokenStoreProvider.overrideWithValue(store), + httpClientAdapterProvider.overrideWithValue(server), + clockProvider.overrideWithValue(clock.call), + errorNotifierProvider.overrideWithValue(notifier), + userAgentProvider.overrideWithValue(kTestUserAgent), + pushRegistrarProvider.overrideWithValue(push), + ]; + + /// 앱과 같게 프로바이더 다시 하기를 끈 컨테이너 + ProviderContainer container() => + ProviderContainer(overrides: overrides, retry: noAutomaticRetry); + + /// 보안 저장소에 리프레시 토큰을 두고(앱을 새로 띄운 상태) 준비한다 + Future prepare({String? refreshToken}) async { + if (refreshToken != null) secure.values[kRefreshTokenKey] = refreshToken; + await store.prepare(); + } +}