Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical). - 17개 설계서를 Draft → Approved로 갱신 - #267(backend-user)은 critical 결함으로 06-Reviewer 유지 - 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영 (critical 3 / major 46 / minor 75) - docs/README.md에 18개 설계서 인덱스 추가 - CHANGELOG.md 2026-06-15 섹션 추가 Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
174 lines
11 KiB
Markdown
174 lines
11 KiB
Markdown
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
|
|
|
|
# 설계서: 백엔드 - 인증/로그인 (#266)
|
|
|
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
|
> **추적성** — Redmine: #266 · 관련 ADR: 없음
|
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/AuthService.java`, `backend-java/src/main/java/com/tasteby/controller/AuthController.java` · 테스트: TBD (현재 없음)
|
|
|
|
## 1. 목적 (Why)
|
|
Tasteby 사용자가 Google 계정으로 1탭 로그인하여 즐겨찾기/리뷰/메모 등 개인화 기능을 사용할 수 있도록 한다. 자체 가입/비밀번호 운영 부담을 제거하고 검증된 ID 토큰 기반으로 안전한 세션 토큰(JWT)을 발급한다.
|
|
|
|
## 2. 범위 (Scope)
|
|
- **포함**
|
|
- Google OAuth ID Token 검증 후 사용자 조회/생성(Upsert) → 자체 JWT 발급 (`POST /api/auth/google`).
|
|
- 현재 로그인 사용자 정보 반환 (`GET /api/auth/me`).
|
|
- Google 검증 실패/사용자 미존재 시 표준 HTTP 에러 매핑.
|
|
- **제외 (out of scope)**
|
|
- 자체 ID/PW 회원가입·비밀번호 재설정.
|
|
- Apple/Kakao/Naver 등 추가 소셜 로그인.
|
|
- 리프레시 토큰, 토큰 회수(blacklist).
|
|
- 로그아웃 처리(클라이언트 토큰 삭제로 처리).
|
|
- 권한 부여(role) 변경 — 사용자 관리(#267) 책임.
|
|
|
|
## 3. 인수조건 (Acceptance Criteria)
|
|
- [ ] 유효한 Google ID Token으로 `POST /api/auth/google` 호출 시 `access_token`(JWT)과 `user` 객체를 반환한다.
|
|
- [ ] 신규 Google 계정 첫 로그인 시 `tasteby_users` 행이 생성되고, 재로그인 시 `last_login_at`이 갱신된다.
|
|
- [ ] Google ID Token이 위조/만료/오디언스 불일치인 경우 HTTP 401을 반환한다.
|
|
- [ ] 발급된 JWT를 `Authorization: Bearer ...`로 `GET /api/auth/me` 호출 시 본인 정보를 반환한다.
|
|
- [ ] JWT의 sub가 존재하지 않는 사용자 ID인 경우 `GET /api/auth/me`는 HTTP 404를 반환한다.
|
|
|
|
## 4. 컨텍스트 & 제약
|
|
- **의존성**
|
|
- `google-api-client`의 `GoogleIdTokenVerifier`(`NetHttpTransport` + `GsonFactory`).
|
|
- `UserService` → `UserMapper`(MyBatis) → Oracle 23ai (`tasteby_users`).
|
|
- 자체 `JwtTokenProvider` (HMAC 서명 가정), `AuthUtil`(SecurityContext에서 userId 추출).
|
|
- **제약**
|
|
- Google Client ID는 `app.google.client-id` 프로퍼티로 단일 audience로 고정. 모바일/웹 다중 클라이언트 ID는 현 시점 미지원.
|
|
- JWT 만료/서명 정책은 `JwtTokenProvider`에서 관리(본 설계서 범위 외).
|
|
- CORS는 `WebConfig`에서 `POST`/`GET` 허용 필요(이미 적용).
|
|
- 모든 외부 호출은 동기 HTTP, 실패 시 401로 합쳐서 반환.
|
|
- **가정**
|
|
- Google ID Token의 `sub`는 영구 고유 식별자이며, 동일 사용자가 이메일을 바꾸어도 `(provider='google', providerId=sub)`로 식별 가능.
|
|
- 사용자 객체의 `nickname`/`avatarUrl`은 최초 생성 시 Google payload 값을 그대로 저장(이후 사용자 편집은 본 기능 범위 외).
|
|
|
|
## 5. 아키텍처 개요
|
|
- **모듈/파일**
|
|
- `controller/AuthController.java` — HTTP 진입점 (thin).
|
|
- `service/AuthService.java` — Google 검증 + JWT 발급 오케스트레이션.
|
|
- `service/UserService.java#findOrCreate/findById` — DB 조회/upsert.
|
|
- `security/JwtTokenProvider`, `security/AuthUtil` — 토큰 생성 / SecurityContext 추출.
|
|
- **데이터 흐름**
|
|
|
|
```
|
|
[Client]
|
|
│ POST /api/auth/google { id_token }
|
|
▼
|
|
AuthController.loginGoogle
|
|
│
|
|
▼
|
|
AuthService.loginGoogle
|
|
├─ GoogleIdTokenVerifier.verify(idToken) ── (외부 I/O: Google 공개키 검증)
|
|
├─ UserService.findOrCreate(provider, sub, email, name, picture)
|
|
│ └─ UserMapper.findByProviderAndProviderId / insert / updateLastLogin
|
|
└─ JwtTokenProvider.createToken(userMap)
|
|
│
|
|
▼
|
|
{ access_token, user }
|
|
|
|
|
|
[Client] GET /api/auth/me (Authorization: Bearer <jwt>)
|
|
▼
|
|
AuthController.me → AuthUtil.getUserId() → AuthService.getCurrentUser
|
|
▼ UserService.findById → UserMapper.findById
|
|
▼
|
|
UserInfo
|
|
```
|
|
|
|
- **I/O ↔ 순수 로직 경계**
|
|
- I/O: Google 토큰 검증, DB 조회/저장.
|
|
- 순수: payload → `UserInfo` 매핑, `Map<String,Object>` 클레임 빌드.
|
|
|
|
## 6. 데이터 모델
|
|
- **입력**
|
|
- `POST /api/auth/google` body: `{ "id_token": string(JWT, Google issued) }`.
|
|
- `GET /api/auth/me`: 헤더 `Authorization: Bearer <accessToken>`.
|
|
- **출력**
|
|
- `loginGoogle`: `{ access_token: string, user: UserInfo }`.
|
|
- `me`: `UserInfo`.
|
|
- **`UserInfo`(domain/UserInfo.java)**
|
|
- `id: String(32 hex)`, `email: String`, `nickname: String`, `avatarUrl: String`, `admin: boolean (@JsonProperty("is_admin"))`, `provider: String`, `providerId: String`, `createdAt: String`, `favoriteCount/reviewCount/memoCount: int`.
|
|
- **저장(`tasteby_users`)**
|
|
- PK: `id` (`IdGenerator.newId()`, 32-char uppercase hex), 유니크 가정: `(provider, provider_id)`.
|
|
- `is_admin NUMBER`(0/1), `last_login_at TIMESTAMP`.
|
|
- **경계 검증**
|
|
- `id_token` 비어있거나 null → Google verifier가 `null` 리턴 → 401.
|
|
- JWT 클레임 내 `email`/`nickname`이 null이면 빈 문자열로 정규화.
|
|
|
|
## 7. 함수 명세 (Function Specs)
|
|
|
|
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
|
|------|-----------|----------|------|------|-----------|-------|
|
|
| `AuthService(UserService, JwtTokenProvider, String)` | 의존성 주입 및 GoogleIdTokenVerifier 초기화 | `AuthService(UserService userService, JwtTokenProvider jwtProvider, @Value("${app.google.client-id}") String googleClientId)` | DI 빈, client-id | 인스턴스 | 프로퍼티 누락 시 빈 생성 실패 | 단순 |
|
|
| `AuthService.loginGoogle` | Google ID Token 검증 → 사용자 upsert → JWT 발급 | `Map<String,Object> loginGoogle(String idTokenString)` | Google id_token 문자열 | `{ access_token, user }` | 검증 실패/예외 → 401 `ResponseStatusException` | **복잡** (외부 I/O + DB upsert + 토큰 발급) |
|
|
| `AuthService.getCurrentUser` | JWT sub로 사용자 조회 | `UserInfo getCurrentUser(String userId)` | userId | `UserInfo` | 미존재 → 404 | 단순 |
|
|
| `AuthController(AuthService)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
|
|
| `AuthController.loginGoogle` | `/api/auth/google` 엔드포인트 | `Map<String,Object> loginGoogle(@RequestBody Map<String,String> body)` | body.id_token | `{ access_token, user }` | AuthService 예외 위임 | 단순 |
|
|
| `AuthController.me` | `/api/auth/me` 엔드포인트 | `UserInfo me()` | (헤더에서 userId 자동 추출) | `UserInfo` | 인증 실패 → 401, 미존재 → 404 | 단순 |
|
|
|
|
> 복잡 표시 함수(`loginGoogle`)는 흐름이 8장에 상세 기술되어 있어 별도 `fn-loginGoogle.md`는 생략 가능.
|
|
|
|
## 8. 흐름 / 알고리즘
|
|
**시나리오 A — Google 로그인**
|
|
1. 클라이언트가 Google Identity Services로 ID Token을 발급받아 `POST /api/auth/google {id_token}` 호출.
|
|
2. `AuthService`가 `GoogleIdTokenVerifier.verify`로 서명/만료/aud 검증. null이면 401.
|
|
3. payload에서 `sub`, `email`, `name`, `picture` 추출.
|
|
4. `UserService.findOrCreate("google", sub, email, name, picture)` 호출.
|
|
- 기존 유저: `updateLastLogin` 후 최신 사용자 반환.
|
|
- 신규 유저: `IdGenerator.newId()`로 PK 발급 → insert → 재조회 반환.
|
|
5. `UserInfo`의 핵심 필드를 `Map`으로 패키징하여 `JwtTokenProvider.createToken` 호출.
|
|
6. `{ access_token, user }` 응답.
|
|
|
|
**시나리오 B — 현재 사용자 조회 (`/api/auth/me`)**
|
|
1. Spring Security 필터가 Bearer 토큰을 검증해 `SecurityContext`에 principal(userId) 설정.
|
|
2. `AuthUtil.getUserId()`로 sub 추출.
|
|
3. `AuthService.getCurrentUser` → `UserService.findById` → `UserMapper.findById`.
|
|
4. 없으면 404, 있으면 `UserInfo` 반환.
|
|
|
|
## 9. 엣지케이스 & 에러 처리
|
|
- **id_token이 null/공백**: Verifier가 null 또는 예외 발생 → 401 "Invalid Google token".
|
|
- **Google 공개키 조회 실패(네트워크/타임아웃)**: catch-all로 401에 메시지 포함. 재시도/백오프 없음(클라이언트가 재시도).
|
|
- **audience 불일치**: Verifier가 null 반환 → 401.
|
|
- **신규 사용자 insert 중 충돌**: 트랜잭션(`@Transactional`)으로 묶여 있으며, (provider, provider_id) 유니크 위반 시 예외 발생 → 상위에서 500 변환(전역 예외 처리에 의존). 동시 첫 로그인은 드물어 별도 재시도 없음.
|
|
- **`findById` race**: insert 직후 즉시 재조회 — 동일 트랜잭션 가시성 가정.
|
|
- **JWT 클레임 내 email/nickname null**: 빈 문자열로 정규화 후 토큰에 포함.
|
|
- **`/api/auth/me`에서 sub가 존재하지 않는 ID(사용자 삭제 등)**: 404.
|
|
- **안전한 기본값**: 어떤 실패든 401/404로 매핑, 500은 예외적.
|
|
|
|
## 10. 테스트 계획
|
|
- **현 상태**: 자동화 테스트 없음 (TBD).
|
|
- **추가 권장 단위 테스트** (Mockito 기반)
|
|
- `AuthService.loginGoogle`
|
|
- 유효 토큰 → `UserService.findOrCreate` 호출 + `JwtTokenProvider.createToken` 결과 포함.
|
|
- Verifier null 반환 → 401.
|
|
- Verifier 예외 → 401.
|
|
- email/nickname null payload → 토큰 클레임에 빈 문자열.
|
|
- `AuthService.getCurrentUser`
|
|
- 존재 → `UserInfo` 반환.
|
|
- 미존재 → 404.
|
|
- **통합 테스트** (`@SpringBootTest` + MockMvc)
|
|
- `POST /api/auth/google` happy path (Google verifier 모킹).
|
|
- `GET /api/auth/me` 인증 헤더 유효/무효.
|
|
- **모킹 전략**: `GoogleIdTokenVerifier`는 `@MockBean`으로 교체. JWT는 실제 `JwtTokenProvider` 사용해 round-trip 검증.
|
|
|
|
## 11. 리스크 & 대안 검토
|
|
- **선택**: Google 단일 IdP + 자체 단기 JWT.
|
|
- 장점: 구현 단순, 비밀번호 미관리, 즉시 사용 가능.
|
|
- 단점: 리프레시 토큰 없음 → 만료 시 재로그인 필요.
|
|
- **대안 1**: Spring Security OAuth2 Client + 세션 쿠키.
|
|
- 트레이드오프: 백엔드 세션 저장소 추가, SPA-친화 낮음. 현재 거부.
|
|
- **대안 2**: 리프레시 토큰 + 회수 리스트(Redis).
|
|
- 트레이드오프: 복잡도 ↑. 향후 필요 시 도입.
|
|
- **되돌리기 어려운 결정**: `(provider, provider_id)` 식별 스키마. → 변경 시 ADR 필요.
|
|
- **보안 리스크**
|
|
- JWT 시크릿 유출 시 위조 가능. 시크릿은 `k8s/secrets.yaml`로 관리.
|
|
- audience 단일 — 모바일/웹 client_id 분리 시 verifier 다중 audience 지원 필요.
|
|
|
|
## 12. 미해결 질문 (Open Questions)
|
|
- 리프레시 토큰을 도입할 것인가, 단기 만료 + 재로그인을 유지할 것인가?
|
|
- 사용자 닉네임/프로필 사진을 매 로그인마다 Google 값으로 덮어쓸지(현 코드는 첫 생성만 반영) 여부.
|
|
- 어드민 권한 부여 정책(최초 가입자 admin, 환경변수 화이트리스트 등)을 어디서 결정할지.
|
|
- 멀티 클라이언트(웹/iOS/Android)별 Google Client ID 분리 시점.
|
|
- 토큰 만료/서명 알고리즘(HS256 → RS256 전환) 시점.
|