docs(rules): 앱 토큰 경로 · 모듈 틀을 flutter 스킬과 공통 규칙에 적는다

- flutter 스킬: 기본 제공 골격을 이번 앱 기반으로(인증 인터셉터 · 토큰 보관 · 세션 노티파이어 ·
  모듈 틀), 새 모듈 붙이는 법, 비밀이 든 요청의 실패 예외, 자동 다시 하기 끔, Flutter 판
- 보안 · 에러 코드 · 코딩 규칙: 앱은 리프레시 토큰을 본문으로 받아 보안 저장소에(kRefreshTokenKey),
  세션 만료 승격은 갱신 뒤 · 스스로 로그아웃한 뒤의 401 은 승격하지 않는다(웹과 같다)
- mobile-dev 역할: 작업 순서 · 완료 기준(apk 빌드 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 11:11:29 +09:00
parent dc0fb81387
commit 2bb7fa4cdb
5 changed files with 143 additions and 108 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙
Dart `const` 누락·await 누락, backend 에서 `HttpException` 직접 생성(→ `BusinessException` 사용)
- 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/`
- 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리
(반복 사용되는 키 문자열도 상수로: 예 `kJwtTokenKey`, `REQUEST_ID_HEADER`)
(반복 사용되는 키 문자열도 상수로: 예 `kRefreshTokenKey`, `REQUEST_ID_HEADER`)
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
— backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`)
- **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]])
+2 -2
View File
@@ -50,9 +50,9 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
웹은 그 전에 `/auth/refresh` 를 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절).
웹 · 앱은 그 전에 갱신을 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절).
- 웹: `useApi.ts` 의 `toAuthCode()` — `authStore.expireSession()` 을 불러 세션 만료 대화상자(`SessionExpiredDialog.vue`)를 띄운다
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
- 앱: `core/api/auth_interceptor.dart` · `token_refresher.dart` — 웹처럼 갱신(`/auth/app/refresh`)을 한 번 해 보고, 갱신까지 거절됐을 때만 승격한다 (리프레시 토큰 삭제 · 로그인 화면 — `docs/mail/api-contract.md` 1절 "앱 로그인"). 그 요청을 보낸 세션이 이미 끝났으면(세션 세대가 다르다) 알리지 않는다 — 사용자가 스스로 로그아웃했으면 승격하지 않고 `AUTH_001` 그대로(웹 `endedBySignOut` 과 같다), 세션이 끝난 뒤면 `AUTH_002`
## 도메인 코드 추가
+2 -2
View File
@@ -11,8 +11,8 @@
## 인증 · 토큰
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 리프레시 토큰만 `flutter_secure_storage`(키: `refresh_token`), 액세스 토큰은 메모리 — 앱 전용 토큰 경로(계약 `docs/mail/api-contract.md` 1절 "앱 로그인"), Android 백업 끔
- Access Token 만료는 짧게(15분~1시간). Refresh Token 은 웹은 HttpOnly 쿠키로만, 앱은 1절 "앱 로그인"대로(본문으로 받아 보안 저장소에만 — 로그 · 일반 저장소에 두지 않는다)
- 리프레시 토큰은 서버 세션에 **해시만** 저장하고 **1회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다.
동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게)
- 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다.