diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d35a1f20..ab0380c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,10 +156,13 @@ jobs: DB_URL: jdbc:mysql://127.0.0.1:3306/offway?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul DB_USERNAME: root DB_PASSWORD: ci-verify - # 이 둘은 비면 부팅이 실패한다(열린 채 뜨는 쪽이 더 위험해서 그렇게 만들어 두었다). + # 아래 셋은 비면 부팅이 실패한다(열린 채 뜨는 쪽이 더 위험해서 그렇게 만들어 두었다). # 검증용 더미이고 이 잡 밖으로 나가지 않는다. OFFWAY_BASIC_USERNAME: ci OFFWAY_BASIC_PASSWORD: '{noop}ci' + # 자체 토큰 서명키(#34). 없으면 아무 토큰이나 위조 가능해져 TokenIssuer 가 부팅을 막는다. + # HS256 최소 32바이트라 길이를 채운 더미를 쓴다. 운영 값은 JWT_SECRET 시크릿에서 온다. + JWT_SECRET: ci-migration-verify-only-not-a-real-signing-key run: | set -uo pipefail JAR=$(ls build/libs/*.jar | grep -v plain | head -1) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c2ed5972..2d0f22c9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -112,6 +112,14 @@ jobs: DATA_GO_KR_SERVICE_KEY=${{ secrets.DATA_GO_KR_SERVICE_KEY }} TMAP_APP_KEY=${{ secrets.TMAP_APP_KEY }} DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }} + JWT_SECRET=${{ secrets.JWT_SECRET }} + KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }} + KAKAO_APP_ID=${{ secrets.KAKAO_APP_ID }} + GOOGLE_WEB_CLIENT_ID=${{ secrets.GOOGLE_WEB_CLIENT_ID }} + APPLE_SERVICE_ID=${{ secrets.APPLE_SERVICE_ID }} + APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }} + APPLE_KEY_ID=${{ secrets.APPLE_KEY_ID }} + APPLE_PRIVATE_KEY_BASE64=${{ secrets.APPLE_PRIVATE_KEY_BASE64 }} ENVEOF scp -i ~/.ssh/deploy_key env.prod \ "${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:~/offway/env.prod" diff --git a/.gitignore b/.gitignore index 3d1b4b1d..badb1b16 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ secret.properties *.jks *.keystore *.key +# Apple 로그인 키 (AuthKey_XXXXXXXXXX.p8) — 레포가 public 이라 확장자로 먼저 막는다. +# 재발급이 안 되는 키라 한 번 새면 콘솔에서 폐기하고 새로 만드는 수밖에 없다. +*.p8 credentials.json **/secrets/ diff --git a/application-secret.properties.example b/application-secret.properties.example index 792252f5..c6dad87c 100644 --- a/application-secret.properties.example +++ b/application-secret.properties.example @@ -25,6 +25,52 @@ TMAP_APP_KEY= APIDOG_ACCESS_TOKEN= APIDOG_PROJECT_ID= +# ── 소셜 로그인 (#34) ───────────────────────────────────────── +# 비어 있어도 부팅된다 — 해당 provider 로그인만 USER-002 로 실패한다(로컬 실행성 규칙). +# 로컬에서 실 provider 토큰 없이 API 를 부르려면 POST /api/v1/auth/dev-login 을 쓴다(local 전용). + +# 자체 JWT 서명키 (HS256, 최소 32바이트) +# ⚠️ 이 값을 아는 사람은 아무 사용자의 토큰이나 위조할 수 있다. +# - local 은 application-local.properties 의 개발용 고정값이 쓰이므로 비워둬도 된다. +# - 운영은 반드시 주입한다. 없으면 부팅이 실패한다(조용히 약한 키로 뜨지 않게). +# - 생성: openssl rand -base64 48 +JWT_SECRET= + +# 카카오 REST API 키 +# - 발급: https://developers.kakao.com > 내 애플리케이션 > 앱 키 > REST API 키 +# - 서버는 이 키로 카카오를 부르지 않는다(프로필 조회는 사용자 액세스 토큰만 받는다). +# 앱이 등록됐는지 판별하는 용도라, 비면 카카오 로그인을 받지 않는다. +# - client secret 은 필요 없다. 그 값이 쓰이는 토큰 엔드포인트(POST /oauth/token)는 앱이 SDK 로 끝낸다. +KAKAO_REST_API_KEY= + +# 카카오 앱 번호 (앱 ID) — Apple 의 APPLE_SERVICE_ID · 구글의 GOOGLE_WEB_CLIENT_ID 와 같은 역할이다 +# ⚠️ 이 값이 비면 카카오 로그인을 아예 받지 않는다. 조용히 검증을 건너뛰지 않기 위해서다. +# 검증이 없으면 **다른 카카오 앱에서 발급된 액세스 토큰**을 그대로 우리 서버에 던져 그 사용자로 +# 로그인할 수 있다 — 토큰 하나만 손에 넣으면 되는 계정 탈취다. +# - 확인: https://developers.kakao.com > 내 애플리케이션 > 앱 설정 > 요약 정보 > 앱 ID (숫자) +# - REST API 키가 아니다. 서버는 토큰 정보 조회(/v1/user/access_token_info)가 돌려주는 app_id 와 이 값을 대조한다. +KAKAO_APP_ID= + +# 구글 '웹' 클라이언트 ID +# ⚠️ iOS/Android 클라이언트 ID 가 아니라 **웹** 클라이언트 ID 다. ID 토큰의 aud 검증에 쓴다 — +# 값이 어긋나면 앱이 보낸 토큰이 전부 401 이 된다. +# - 발급: https://console.cloud.google.com > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID (웹) +GOOGLE_WEB_CLIENT_ID= + +# 애플 Service ID — identityToken 의 aud 검증에 쓴다 +# - 발급: https://developer.apple.com > Certificates, Identifiers & Profiles > Identifiers > Services IDs +APPLE_SERVICE_ID= + +# 애플 서버 인증용 (지금은 미사용 — 회원 탈퇴 시 Apple 연결 해제(revoke)에 필요해 미리 받아 둔다) +# ⚠️ APPLE_PRIVATE_KEY_BASE64 는 .p8 키 파일을 base64 로 인코딩한 값이다. +# env-file 이 여러 줄 값을 못 받아 한 줄로 만든 것이라, 쓸 때 디코딩한다. +# 원본 .p8 은 재발급이 안 된다 — 레포에 두지 말 것(.gitignore 가 *.p8 을 막는다). +# - 생성: base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n' +# - 발급: https://developer.apple.com > Keys > Sign in with Apple 활성화한 키 +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY_BASE64= + # 디스코드 알림 웹훅 URL (외부 API 한도 알림 · 정책 만료 알림) # ⚠️ URL 끝 token 조각을 아는 사람은 누구나 그 채널에 글을 쓸 수 있다 — 비밀로 다룬다. # - 발급: 디스코드 채널 > 채널 편집 > 연동 > 웹훅 > 새 웹훅 > 웹훅 URL 복사 diff --git a/docs/adr/0002-oauth-user-authentication.md b/docs/adr/0002-oauth-user-authentication.md new file mode 100644 index 00000000..df8778e1 --- /dev/null +++ b/docs/adr/0002-oauth-user-authentication.md @@ -0,0 +1,381 @@ +# ADR 0002 — OAuth 인증 기반 User 도메인 (게스트 폐기) + +- 상태: 채택(2026-07-29) +- 대상 이슈: #34 (기존 "게스트 식별 기반 사용자" → 범위 전환) +- 영향 이슈: #7(에픽) · #89 · #90 · #91 · #41 +- 관련: `user/`, `itinerary/domain/Course`, `common/response/ApiResponseBody`, `docs/specs/api-spec.md` + +## 맥락 + +`X-Guest-Id` 헤더가 `CourseStorageController` 에 로컬 상수로 박혀 있고, 서버는 그 값을 **발급하지도 검증하지도 않는다**. 아무 문자열이나 보내면 그 사람이 된다. + +여기에 #89(연차 영속)가 얹히면 서버가 그 문자열을 키로 남은 연차·사용내역까지 저장하게 된다. 그 시점의 "게스트"는 **자격증명만 없는 유저 레코드**다. 별도 개념으로 유지할 이유가 없다. + +## 결정 + +| 결정 | 내용 | +|---|---| +| 게스트 개념 | **폐기.** 첫 진입부터 OAuth 로그인 강제 | +| 가입 수단 | Google · Kakao · Apple (OAuth 만) | +| FE | Flutter 네이티브 앱 (iOS/Android) | +| 인증 흐름 | **클라이언트 주도** — 앱이 provider SDK 로 ID 토큰 획득 → 서버는 검증만 | +| 서버 토큰 | access JWT(1h) + refresh(60일, DB 저장·회전) | +| 내부 식별자 | **UUID** (`BINARY(16)`, 시간정렬) | + +### 왜 클라이언트 주도인가 + +Flutter 앱이므로 서버 주도 리다이렉트(`oauth2Login()`)가 필요 없다. `google_sign_in` · `kakao_flutter_sdk` · `sign_in_with_apple` 셋 다 **OIDC ID 토큰**을 돌려주므로 서버는 세 provider 를 하나의 검증 경로로 처리한다. 쿠키·딥링크·리다이렉트 URL 관리가 전부 빠진다. + +### 왜 refresh 를 넣는가 + +access 만료를 길게 잡고 refresh 를 생략하는 안의 전제는 "만료되면 앱이 provider SDK 로 조용히 재로그인하면 된다"이다. Google 은 `signInSilently()`, Kakao 는 자체 갱신이 되지만 **Sign in with Apple 은 무인 재인증을 지원하지 않는다.** refresh 를 빼면 애플 유저가 만료마다 로그인 화면을 다시 본다. + +refresh 를 **Redis 가 아니라 DB** 에 두는 이유는 로컬 실행성 불변식이다. `application-local.properties` 에 Redis 설정이 없고 현재 아무도 Redis 를 쓰지 않아 부팅이 된다. 인증 상태를 Redis 에 얹으면 FE 가 Redis 없이 백엔드를 못 띄운다. + +### 새 의존성 없음 + +`spring-boot-starter-security-oauth2-client` 가 이미 있고, 그 안의 `spring-security-oauth2-jose`(Nimbus)가 JWKS 기반 ID 토큰 검증(`NimbusJwtDecoder.withJwkSetUri`)과 자체 JWT 서명(`NimbusJwtEncoder`)을 모두 제공한다. + +## 데이터 모델 + +FK 제약 없이 raw ID + 조회 인덱스만 둔다(persistence-convention). + +| 테이블 | 컬럼 | 제약 | +|---|---|---| +| `users` | `id BINARY(16)` PK · `nickname VARCHAR(50)` · `created_at` · `updated_at` | — | +| `user_identities` | `id BINARY(16)` PK · `user_id BINARY(16)` · `provider VARCHAR(20)` · `provider_user_id VARCHAR(255)` · `created_at` | `UNIQUE(provider, provider_user_id)` · `KEY idx_user_id` | +| `refresh_tokens` | `id BINARY(16)` PK · `user_id BINARY(16)` · `token_hash VARCHAR(64)` · `expires_at` · `created_at` | `UNIQUE(token_hash)` · `KEY idx_user_id` | + +**`user_identities` 를 분리한 이유 — `sub` 으로만 매칭한다.** +이메일로 provider 계정을 매칭하면 안 된다. Apple Private Relay 는 익명 주소를 주고, Kakao 는 이메일 동의를 거부할 수 있어 값이 아예 없을 수 있다. ID 토큰의 `sub` 만이 안정적인 키다. 테이블을 분리해두면 나중에 한 유저에 provider 를 여러 개 붙이는 계정 연결도 스키마 변경 없이 열린다. + +**JPA 연관관계를 쓰지 않는다.** +`User` ↔ `UserIdentity` 가 생명주기를 공유하긴 하나 **항상 같이 로드되지 않는다** — 로그인은 identity → user 단방향 조회가 주 경로다. 규약의 애그리거트 조건에 맞지 않으므로 셋 다 독립 엔티티 + raw ID. + +**UUID 저장은 `BINARY(16)`.** +MySQL·H2 양쪽에서 동작하고 Hibernate 의 UUID 기본 매핑이다. 생성은 `@UuidGenerator(style = TIME)` — 랜덤 v4 는 InnoDB 클러스터드 인덱스를 파편화시키는데, 설정 한 줄 차이라 처음부터 시간정렬로 둔다. + +**refresh 는 원문을 저장하지 않는다.** +`token_hash` 에 SHA-256 해시만 저장한다. DB 유출 시 토큰이 그대로 쓰이는 걸 막는다. + +## 패키지 구조 + +```text +user/ +├── controller/ +│ ├── AuthController @RestController @RequestMapping("/api/v1/auth") +│ ├── AuthApi OpenAPI 인터페이스 +│ ├── DevAuthController @Profile("local") 전용 +│ └── dto/ LoginRequest · ReissueRequest · TokenResponse +├── service/ +│ ├── AuthService 로그인·재발급·로그아웃 조율 +│ ├── TokenIssuer 자체 JWT 발급·검증 +│ └── dto/ LoginCommand · IssuedToken +├── domain/ +│ ├── User · UserIdentity · RefreshToken +│ ├── AuthProvider enum — 상수별 issuer·jwksUri 보유 +│ └── UserErrorCode · UserException +├── repository/ 각 엔티티 Repository(port) + Impl + JpaRepository +├── infrastructure/oidc/ +│ ├── OidcTokenVerifier port +│ └── NimbusOidcTokenVerifier adapter (JWKS) +└── config/ + ├── SecurityConfig 기존 permitAll 교체 + ├── JwtAuthenticationFilter + ├── ApiAuthenticationEntryPoint 401 을 ApiResponseBody 로 (§에러 처리) + ├── ApiAccessDeniedHandler 403 을 ApiResponseBody 로 (§에러 처리) + └── LoginUserArgumentResolver + @LoginUser +``` + +### `AuthProvider` enum + +상수별로 `issuer` · `jwksUri` 를 보유해 provider 분기를 없앤다(CLAUDE.md 다형성 원칙). + +```java +GOOGLE("https://accounts.google.com", "https://www.googleapis.com/oauth2/v3/certs") +KAKAO ("https://kauth.kakao.com", "https://kauth.kakao.com/.well-known/jwks.json") +APPLE ("https://appleid.apple.com", "https://appleid.apple.com/auth/keys") +``` + +**audience(클라이언트 ID)는 enum 이 아니라 설정으로 뺀다.** 환경별로 다르고, 특히 Google 은 iOS/Android 클라이언트 ID 가 달라 값이 복수다. `offway.auth.oidc..audiences` 로 주입한다. + +## 인증 흐름 + +### 로그인 — `POST /api/v1/auth/login` + +```jsonc +// 요청 +{ "provider": "APPLE", "idToken": "eyJ...", "nickname": "세빈" } // nickname optional +// 응답 200 +{ "status": 200, "code": "OK", "data": { "accessToken": "...", "refreshToken": "...", "expiresIn": 3600 } } +``` + +1. `OidcTokenVerifier.verify(provider, idToken)` — JWKS 서명 + `iss`·`aud`·`exp` 검증 +2. `sub` 추출 → `user_identities` 조회 +3. 없으면 `User` + `UserIdentity` 생성 (최초 로그인 = 가입) +4. access JWT(1h) 서명 + refresh(60일) 발급·저장 + +**`nickname` 을 요청에 optional 로 두는 이유.** +Google 은 ID 토큰에 `name`, Kakao 는 `nickname` 클레임이 온다. 그러나 **Apple 은 ID 토큰에 이름을 주지 않는다** — 최초 인증 응답에만, 그것도 사용자가 제공을 선택했을 때만 온다. 앱이 그 시점에 받아 넘기지 않으면 애플 유저는 영구히 이름이 없다. 서버는 요청 `nickname` → 토큰 클레임 → 기본값 순으로 채운다. + +신규 가입도 `200` 으로 응답한다. 생성되는 건 세션이지 클라이언트가 URL 로 가리킬 리소스가 아니므로 `201` 을 쓰지 않는다. + +### 재발급 — `POST /api/v1/auth/reissue` + +해시로 조회 → 유효하면 **회전**(기존 행 폐기 + 새 access·refresh 발급). + +**폐기된 refresh 가 다시 오면 해당 유저의 refresh 를 전부 삭제한다.** 정상 클라이언트는 폐기된 토큰을 재사용하지 않으므로 탈취 정황이다. 회전을 하는 이유 자체가 이 감지라, 회전만 넣고 감지를 빼면 의미가 절반이다. + +### 로그아웃 — `POST /api/v1/auth/logout` + +해당 유저의 refresh 를 폐기한다. **access 는 만료(1h)까지 유효하다** — stateless JWT 의 대가이며 API 문서에 명시한다. + +### 요청 인증 + +`JwtAuthenticationFilter` 가 `Authorization: Bearer ` 를 검증해 `SecurityContext` 에 `userId`(UUID) 를 넣는다. 컨트롤러는 `@LoginUser UUID userId` 로 받는다. + +이 필터가 #41 의 MDC `userId` 연결점이 된다 (현재 `"guest"` 고정). + +### 전면 인증 전환은 2단계로 나눈다 + +목표 상태는 `anyRequest().authenticated()` 다. 다만 **지금 잠그지 않는다.** + +| 단계 | 접근 정책 | 시점 | +|---|---|---| +| 1단계 (이 ADR) | `/api/v1/auth/logout` 만 authenticated, 그 외 permitAll | 지금 | +| 2단계 | `anyRequest().authenticated()` + 공개 경로 목록(`auth/login`·`reissue`·`dev-login`·swagger·h2·actuator·`/inventory`) | FE 가 provider 클라이언트 ID 확보 후 | + +이유는 실 provider 토큰을 만들 주체가 아직 없다는 것이다. 플러터 앱이 나와야 Google·Kakao·Apple SDK 로 ID 토큰을 받을 수 있고, 그전에 전면 잠금을 걸면 **apidog 실호출 검증(#42)이 막힌다.** 코드는 완성돼 있으므로 전환은 matcher 두 줄을 뒤집는 작업이다. + +로그아웃만 예외로 잠그는 건 타협이 아니라 필수다 — 누구의 토큰을 폐기할지 알아야 하므로 permitAll 로 열면 `@LoginUser` 가 null 로 들어와 서버 오류가 된다. 덕분에 **401 공통 래퍼 계약은 1단계에서도 통합 테스트로 검증된다.** + +2단계에서 함께 해야 할 일: 기존 통합 테스트 13개(약 70개 호출)에 `Authorization` 헤더 부착. + +## 로컬 실행성 + +**불변식**: local 프로파일에서 시크릿·외부 인프라 없이 부팅 가능해야 한다. + +OAuth 를 강제하면 FE 가 로컬에서 실 provider 토큰 없이는 **어떤 API 도 못 부른다.** 다음 둘로 해결한다. + +1. **개발용 로그인** — `POST /api/v1/auth/dev-login`, `@Profile("local")` 전용. provider 검증 없이 유저를 만들고 토큰만 발급한다. 빈 자체가 prod 에 존재하지 않아 경로가 아예 안 열린다. +2. **JWT 서명키** — `application-local.properties` 에 개발용 고정값을 박고, prod 는 환경변수 필수. 불변식은 "local 에서 시크릿 없이 부팅"이므로 충족된다. + +provider 클라이언트 ID(audience)가 비어 있어도 부팅은 되고, 해당 provider 로그인만 `USER-002` 로 실패한다. + +## 에러 처리 + +| code | category | HTTP | 상황 | +|---|---|---|---| +| `USER-001` | UNAUTHORIZED | 401 | ID 토큰 검증 실패(서명·만료·issuer 불일치) | +| `USER-002` | BAD_REQUEST | 400 | 지원하지 않거나 설정되지 않은 provider | +| `USER-003` | UNAUTHORIZED | 401 | refresh 토큰 무효·만료·폐기됨 | +| `USER-004` | UNAUTHORIZED | 401 | access 토큰 무효·만료 | +| `USER-005` | EXTERNAL_API | 502 | provider JWKS 조회 실패 | + +`USER-005` 를 분리한 이유: "네 토큰이 틀렸다(401)"와 "구글이 안 뜬다(502)"는 클라이언트가 취할 행동이 완전히 다르다. 전자는 재로그인, 후자는 재시도다. + +`ErrorCategory.UNAUTHORIZED` 는 이미 존재하므로 추가하지 않는다. message 는 전부 사용자 대면 고정 문구로 두고, 검증 실패의 구체 사유는 로그·cause 체인에만 남긴다. + +### FE 매핑 계약 + +모든 실패는 성공과 동일한 `ApiResponseBody` 로 나간다. FE 는 이 JSON 을 보고 유저에게 내릴 문구를 결정한다. + +```jsonc +{ "status": 401, "data": null, "detail": "로그인이 만료되었습니다.", "code": "USER-004", "pageResponse": null } +``` + +**매핑 키는 `code` 다.** `detail` 은 이미 사용자 대면 문구지만 서버가 문구를 다듬으면 FE 분기가 조용히 깨진다. `code` 가 계약(append-only·재사용 금지)이고, `detail` 은 FE 가 자체 문구를 두지 않은 경우의 fallback 으로 본다. + +### 필터에서 나는 401 은 `GlobalExceptionHandler` 를 타지 않는다 + +`JwtAuthenticationFilter` 는 서블릿 필터이므로 `DispatcherServlet` **앞** 에서 동작한다. 여기서 던진 예외는 `@RestControllerAdvice` 가 잡지 못한다. 그대로 두면 Spring Security 기본 401 이 나가고 **body 가 비거나 HTML** 이라 FE 가 매핑할 `code` 가 없다. 가장 자주 마주칠 에러가 하필 래퍼 밖으로 샌다. + +따라서 `SecurityConfig` 에 두 핸들러를 등록해 **같은 JSON 을 직접 써 내린다.** + +| 핸들러 | 상황 | 응답 | +|---|---|---| +| `AuthenticationEntryPoint` | 토큰 없음·무효·만료 | 401 · `USER-004` | +| `AccessDeniedHandler` | 인증됐으나 권한 부족 | 403 · `COMMON` 계열 | + +두 핸들러는 `ObjectMapper` 로 `ApiResponseBody.fail(...)` 를 직렬화하고 `Content-Type: application/json` 을 명시한다. 실패 응답 모양이 컨트롤러 경로와 필터 경로에서 **한 글자도 다르지 않아야** FE 가 분기를 하나로 유지할 수 있다. + +`AccessDeniedHandler` 는 지금 권한·롤이 없어 실질적으로 안 타지만, 등록해두지 않으면 나중에 롤이 생기는 순간 403 만 래퍼 밖으로 새는 같은 문제가 재발한다. + +## 소유 전환 (`guestId` → `userId`) + +`courses.guest_id VARCHAR(64)` → `courses.user_id BINARY(16)`. + +규약대로 add → backfill → drop 3단계로 나누되, **운영 배포 전이라 backfill 대상 데이터가 없어 no-op** 이다. + +1. `V…__add_course_user_id.sql` — 컬럼 + `KEY idx_user_id` 추가 +2. 코드 전환 — `Course.ownedBy(UUID userId, …)` · `CourseRepository` 조회 시그니처 · `CourseStorageController` 헤더 → `@LoginUser` +3. `V…__drop_course_guest_id.sql` — 기존 컬럼 제거 + +`Course.MAX_GUEST_ID_LENGTH` 와 관련 불변식 검증도 함께 제거된다(UUID 는 길이 검증이 불필요). + +## 테스트 전략 + +| 대상 | 분류 | 내용 | +|---|---|---| +| `AuthProvider` · `User` · `RefreshToken` | 단위 | 불변식·만료 판정·상태 전이 | +| 로그인 → 토큰 발급 → 인증 요청 | 통합 | 응답 contract(`status`·`code`·`data`) 포함 | +| refresh 회전 · 재사용 감지 | 통합 | 폐기된 토큰 재사용 시 전체 무효화 확인 | +| 인증 없는 요청 → 401 | 통합 | SecurityConfig 경로 정책 + **응답이 `ApiResponseBody` 규격인지** | +| 신규 가입 vs 기존 로그인 | 통합 | 같은 `sub` 재로그인 시 유저가 늘지 않음 | + +**모킹 정책** — 외부 경계인 `OidcTokenVerifier` 만 stub(`@TestConfiguration` + `@Primary`, default 람다는 throw). 토큰은 실제 `TokenIssuer` 빈으로 발급한다(내부 컴포넌트 모킹 금지). + +**기존 테스트 영향** — `CourseStorageIntegrationTest` 의 `X-Guest-Id` 헤더가 전부 `Authorization: Bearer` 로 교체된다. + +## 범위 경계 + +**포함** +- `users` · `user_identities` · `refresh_tokens` + 마이그레이션 +- 3사 OIDC ID 토큰 검증 · 로그인 · 재발급 · 로그아웃 +- `JwtAuthenticationFilter` · `SecurityConfig` 교체 · `@LoginUser` · 401/403 핸들러 +- local 전용 dev 로그인 +- `courses` 소유 전환 + +**범위 밖 (후속 이슈)** +- **전면 인증 전환(2단계)** — provider 클라이언트 ID 확보 후 +- provider 콘솔 등록 및 audience 값 주입 (코드가 아니라 등록 작업) +- 실 provider 토큰과의 접점 검증 — 실 ID 토큰은 플러터 앱만 만들 수 있어 FE 연동 시점에 확인된다 +- 회원 탈퇴 +- 계정 연결(한 유저에 provider 여러 개) +- 권한·롤 (지금은 전원 동일) +- Redis 세션 +- `HomeResponse` 의 `"게스트"` → 실제 닉네임 교체 (#89 와 함께) + +## 파급 — 문서·이슈 갱신 + +이 결정으로 게스트 전제가 깨지는 곳들. + +| 대상 | 조치 | +|---|---| +| #34 | 제목·본문을 "OAuth 인증 기반 User" 로 전환 | +| #7 (에픽) | 완료기준 "로그인 없이 전체 플로우 진행(게스트)" 폐기 | +| #89 · #90 · #91 | 선행이 "게스트 식별" → "OAuth 인증" 으로 변경, 소유 키가 `userId` | +| `docs/specs/api-spec.md` | 3행(인증 게스트) · 36행(로그인 후순위) · 275행(게스트 토큰 헤더) 갱신 | + +## 작업 순서 + +1. `users` · `user_identities` · `refresh_tokens` 마이그레이션 + 엔티티 + 리포지토리 +2. `AuthProvider` enum + `OidcTokenVerifier` port/adapter (+ stub) +3. `TokenIssuer` (access 서명 · refresh 발급·해시) +4. `AuthService` + `AuthController`/`AuthApi` — 로그인·재발급·로그아웃 +5. `JwtAuthenticationFilter` + `SecurityConfig` 교체 + `@LoginUser` + 401/403 핸들러 +6. local dev 로그인 +7. `courses` 소유 전환 (마이그레이션 → 코드 → drop) +8. 문서·이슈 갱신 + +1~6 과 7 은 PR 을 나눈다 — 7 이 `itinerary` 도메인을 건드리므로 리뷰 단위를 섞지 않는다. + +--- + +## 개정 (2026-08-14) — 앱이 실제로 쏘는 계약에 맞춘다 + +이 ADR 은 2026-07-29 시점의 판단이다. 그 뒤 플러터 앱이 구현되면서 **위 §인증 흐름의 로그인 +계약이 실제와 어긋났다.** 아래가 현재 정본이고, 위 본문 중 충돌하는 부분은 이 절이 이긴다. +나머지(토큰 전략·refresh 회전·재사용 감지·UUID 식별자)는 그대로 유효하다. + +### 바뀐 것 1 — 로그인 엔드포인트 + +| | 이전(ADR 원문) | 지금 | +|---|---|---| +| 주소 | `POST /api/v1/auth/login` | `POST /api/v1/auth/callback/{provider}` | +| provider | 본문 필드 | **경로 변수** (`kakao`·`apple`·`google`, 대소문자 무관) | +| 토큰 필드 | `idToken` | `accessToken` | +| 이름·이메일 | `nickname` | `name` · `email` | +| 응답 | `accessToken`·`refreshToken`·`expiresIn` | + **`isNewUser`** | + +**`/auth/login` 은 남기지 않고 갈아탔다.** 이 계약은 dev 에 올라간 적이 없어(PR #93 이 +머지되지 않았다) 부르는 클라이언트가 존재하지 않는다. 남겨 둘 이유가 "혹시 몰라서" 뿐인데, +같은 일을 하는 입구가 둘이면 인증처럼 틀리면 비싼 곳에서 규칙이 갈린다. + +**`isNewUser` 가 계약의 핵심이다.** 앱이 신규는 온보딩(잔여 연차 입력), 기존은 홈으로 보낸다. +사용자를 만든 그 자리에서 판정해 내린다 — "가입 시각이 방금인가" 같은 사후 비교는 경계값에서 +흔들리고, 재로그인이 느린 날 기존 사용자를 온보딩으로 보낸다. + +**`providerUserId` 는 받되 신원 판단에 쓰지 않는다.** 앱 계약에 있어 받기는 하지만, 그 값을 +믿고 계정을 찾으면 남의 식별자를 적어 그 계정으로 로그인할 수 있다 — 요청 한 번짜리 계정 +탈취다. 식별자는 언제나 서버가 provider 에게서 직접 확인한 값을 쓴다. + +### 바뀐 것 2 — 카카오는 OIDC 경로가 아니다 + +원문은 셋 다 "OIDC ID 토큰을 주므로 하나의 검증 경로로 처리한다" 고 적었다. **틀렸다.** +앱은 카카오에서 **액세스 토큰**을 받아 넘기고, 그 토큰에는 신원 정보가 없다. + +| provider | 앱이 넘기는 것 | 서버가 하는 일 | 외부 호출 | +|---|---|---|---| +| kakao | 액세스 토큰 | `GET https://kapi.kakao.com/v2/user/me` (Bearer) 로 회원번호 조회 | **있다** | +| apple | identityToken(JWT) | `https://appleid.apple.com/auth/keys` 로 서명·`aud` 검증 | 사실상 없음(JWKS 캐시) | +| google | idToken(JWT) | Google 공개키로 서명·`aud`('웹' 클라이언트 ID) 검증 | 사실상 없음(JWKS 캐시) | + +그래서 `OidcTokenVerifier` 단일 port 를 **provider 별 전략**으로 나눴다. + +```text +infrastructure/social/ SocialIdentityResolver(port, 서비스가 의존) + SocialIdentityVerifier(전략) + DelegatingSocialIdentityResolver(supports() 로 위임 — provider 분기 없음) +infrastructure/oidc/ NimbusOidcVerifier — GOOGLE·APPLE (서명 검증) +infrastructure/kakao/ KakaoIdentityVerifier + KakaoProfileClient(port)/Impl(adapter) +``` + +분류는 `AuthProvider.oidc()` 의 유무가 표현한다 — 서명 검증에 필요한 값(issuer·JWKS 주소)과 +그 방식이 쓰이는 조건이 정확히 같아서, boolean 이나 별도 enum 을 또 두지 않는다. + +**카카오 프로필 조회는 캐시하지 않는다.** 붙일 수 없어서가 아니라 붙이면 안 된다. 키가 액세스 +토큰이라 사용자 수만큼 무한히 늘고(캐시 키 공간 규칙), 무엇보다 신원 확인이 stale 이면 +만료·해지된 토큰을 유효하다고 답하게 되어 그게 곧 인증 우회다. 대신 로그인 1회당 호출 1회로 +상한이 잡힌다. + +**timeout 3초.** 실측(2026-08-14, n=12, 인증 거부 경로) p90 27ms · 최대 30ms. 정상 조회 분포는 +실 토큰이 없어 아직 못 쟀으므로 꼬리에 맞춰 좁히는 대신 여유를 크게 잡았다 — 이 호출이 끊기면 +로그인 자체가 실패해 사용자가 앱에 들어오지도 못한다. 앱이 붙으면 p99 로 다시 정한다. + +**client secret 은 쓰지 않는다.** 그 값이 필요한 곳은 인가 코드를 액세스 토큰으로 바꾸는 토큰 +엔드포인트(`POST /oauth/token`) 하나뿐인데, 그 단계는 앱이 SDK 로 이미 끝냈다. 프로필 조회는 +액세스 토큰만 받는다. + +### 바뀐 것 3 — 전면 인증 전환은 이미 끝나 있었다 + +원문의 "2단계 전환"(`anyRequest().authenticated()`)은 **이 PR 을 기다리지 않고 #122 가 먼저 +했다.** 8080 을 외부에 열면서 임시 HTTP Basic 게이트를 세웠기 때문이다. + +그래서 이 PR 은 전환이 아니라 **자격증명을 하나 더 받는 일**이 됐다. 한 체인에서 둘 다 받는다. + +| 수단 | 누가 쓰나 | 실패 시 code | +|---|---|---| +| `Authorization: Bearer ` | 앱 사용자 | `USER-004`(재발급하라) | +| `Authorization: Basic ...` | 팀 · Swagger · apidog | `COMMON-401`(자격증명 제시하라) | + +**Basic 게이트를 이 PR 에서 걷어내지 않았다.** #122 는 "소셜 로그인이 붙으면 걷어낸다" 는 +전제로 들어왔지만, 걷어내는 조건은 로그인이 *존재*하는 것이 아니라 **모든 호출자가 실제 +토큰을 들고 오는 것**이다. 앱 배포 전까지 Swagger·apidog 는 provider 토큰을 만들 수 없다. +지금 걷어내면 8080 이 다시 열려 TMAP 하루 50건이 봇 한 마리에 고갈된다 — #122 가 막으려던 +바로 그 상황이다. + +401 의 code 를 자격증명 종류로 가르는 이유도 여기 있다. 401 하나로 뭉치면 앱이 다음에 뭘 +해야 할지 모른다. 반대로 아무것도 안 들고 온 요청에 `USER-004` 를 주면, 있지도 않은 refresh 로 +재발급을 시도하는 무한 루프가 된다. + +자격증명을 **만들어 주는** 경로(`/auth/callback/*`·`/auth/reissue`·`/auth/dev-login`)만 열려 +있다. `/auth/logout` 은 잠긴 채다 — 누구의 토큰을 폐기할지 알아야 한다. + +### 바뀐 것 4 — 이메일을 보관한다 + +`users.email VARCHAR(255) NULL` 을 더했다. Apple 은 최초 로그인 응답에만 주므로 그때 받지 +못하면 영영 얻을 수 없다. + +NULL 을 허용하고 **UNIQUE 를 걸지 않으며 계정 매칭에도 쓰지 않는다.** 카카오는 이메일 동의를 +거부할 수 있고, Apple Private Relay 는 서비스마다 다른 익명 주소를 준다 — 동일성 판단에 못 쓴다. +매칭 키는 여전히 `user_identity(provider, provider_user_id)` 뿐이다. + +### 그대로인 것 + +- 토큰 전략(access 1h + refresh 60일 회전·DB 해시 저장), 재사용 감지와 그 롤백 함정 +- UUID(BINARY(16), 시간정렬) 식별자 +- `@LoginUser` · `JwtAuthenticationFilter` · 필터 단계 401 을 공통 래퍼로 내리는 처리 +- local 전용 개발 로그인(`@Profile("local")`) +- **`courses` 소유 전환(`guest_id` → `user_id`)은 여전히 안 됐다.** 별도 PR 로 남아 있고, + 그 때문에 회원 탈퇴(#271)가 지울 수 있는 범위가 제한된다. diff --git a/src/main/java/com/offway/core/common/exception/CommonErrorCode.java b/src/main/java/com/offway/core/common/exception/CommonErrorCode.java index 5757cc23..4d76da3b 100644 --- a/src/main/java/com/offway/core/common/exception/CommonErrorCode.java +++ b/src/main/java/com/offway/core/common/exception/CommonErrorCode.java @@ -13,6 +13,9 @@ public enum CommonErrorCode implements ErrorCode { /** 자격증명이 없거나 올바르지 않음. */ UNAUTHORIZED("COMMON-401", ErrorCategory.UNAUTHORIZED, "인증이 필요합니다."), + /** 인증됐으나 이 요청을 수행할 권한이 없음. Spring Security 의 AccessDeniedHandler 가 사용한다. */ + FORBIDDEN("COMMON-403", ErrorCategory.FORBIDDEN, "이 요청을 수행할 권한이 없습니다."), + /** 매핑되는 엔드포인트·리소스가 없음. */ NOT_FOUND("COMMON-404", ErrorCategory.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), diff --git a/src/main/java/com/offway/core/common/logging/SensitiveParams.java b/src/main/java/com/offway/core/common/logging/SensitiveParams.java index 71078ea0..dc19d8a7 100644 --- a/src/main/java/com/offway/core/common/logging/SensitiveParams.java +++ b/src/main/java/com/offway/core/common/logging/SensitiveParams.java @@ -12,13 +12,41 @@ * 로그에 남기기 전에 쿼리스트링의 민감한 값을 가린다. * *

화이트리스트가 아니라 거부 목록으로 간다. 화이트리스트면 파라미터가 늘 때마다 등록을 빠뜨려 - * 조용히 안 찍힌다 — 로그에서 그 사실은 눈에 띄지 않는다. 지금 도메인은 여행 레퍼런스 데이터라 개인정보가 - * 없고 사용자 계정도 임시 Basic 하나뿐이다(#122). OAuth 로 실사용자가 들어오면 이 규칙을 다시 본다. + * 조용히 안 찍힌다 — 로그에서 그 사실은 눈에 띄지 않는다. + * + *

소셜 로그인이 붙으면서 규칙을 다시 봤다(#34). 예전 주석이 "OAuth 로 실사용자가 들어오면 다시 본다" + * 고 남긴 그 시점이다. 이제 요청·예외 메시지에 provider 액세스 토큰·ID 토큰·우리 refresh 토큰이 흐른다. + * {@code token} 만으로는 안 걸린다 — {@code accessToken=...} 은 {@code token} 앞이 단어 문자라 경계가 + * 성립하지 않아 그대로 로그에 박힌다. 그래서 이름 목록과 정규식 양쪽에 {@code *token} 변형을 넣었다. */ public final class SensitiveParams { - /** 이름이 이 중 하나면 값을 가린다. 비교는 소문자로 한다. */ - private static final Set MASKED_NAMES = Set.of("servicekey", "appkey", "password", "token"); + /** + * 이름이 이 중 하나면 값을 가린다. 비교는 소문자로 한다. + * + *

토큰은 {@code token} 하나로 뭉뚱그릴 수 없다 — 실제로 오는 이름이 {@code accessToken}·{@code idToken}· + * {@code refreshToken}·{@code identityToken} 이라 각각 적어야 걸린다. + * + *

snake_case 도 함께 적는다. 우리 앱은 camelCase 로 보내지만 OAuth 규격(RFC 6749)이 쓰는 이름은 + * {@code access_token}·{@code id_token}·{@code refresh_token} 이다. 제공자 쪽 URL 이 예외 메시지에 실려 + * 들어오는 경로가 있어, 한쪽만 적으면 그 경로로 토큰이 그대로 로그에 남는다. + */ + private static final Set MASKED_NAMES = Set.of( + "servicekey", + "appkey", + "password", + "token", + "accesstoken", + "access_token", + "id_token", + "refresh_token", + "identity_token", + "idtoken", + "identitytoken", + "refreshtoken", + "secret", + "client_secret", + "authorization"); private static final String MASK = "***"; @@ -47,9 +75,12 @@ public final class SensitiveParams { * 자유 텍스트에서 {@code 민감이름=값} 을 찾는다 — 값은 {@code &}·공백·따옴표·닫는 괄호 앞까지. * *

쿼리스트링 파서를 쓰지 않는 이유는 입력이 쿼리가 아니라 아무 문장(예외 메시지)이기 때문이다. + * + *

{@code token} 앞에 {@code [\w-]*} 를 붙여 {@code accessToken}·{@code id_token} 같은 변형까지 잡는다. + * 이게 없으면 {@code \btoken=} 이 {@code accessToken=} 을 놓친다 — 앞 글자가 단어 문자라 경계가 없다. */ private static final Pattern SECRET_ASSIGNMENT = - Pattern.compile("(?i)\\b(serviceKey|appKey|password|token)=[^&\\s\"')\\]]*"); + Pattern.compile("(?i)\\b(serviceKey|appKey|password|[\\w-]*token|[\\w-]*secret)=[^&\\s\"')\\]]*"); /** * 디스코드 웹훅 URL 의 token 조각 — {@code .../webhooks/{id}/{token}}(#257). @@ -60,6 +91,15 @@ public final class SensitiveParams { */ private static final Pattern DISCORD_WEBHOOK = Pattern.compile("(?i)(/api/webhooks/\\d+/)[\\w-]+"); + + /** + * {@code Bearer <토큰>} — 헤더 형태라 {@link #SECRET_ASSIGNMENT}({@code 이름=값})로는 안 걸린다(#34). + * + *

소셜 로그인부터 우리 요청에 {@code Authorization: Bearer} 가 실린다. 외부 호출 실패 예외 메시지에 요청 + * 헤더가 섞여 오면 액세스 토큰이 통째로 로그에 남는데, 그 토큰은 그대로 카카오 프로필을 부를 수 있는 값 + * 이다. JWT 는 점을 포함하므로 값 문자 집합에 {@code .} 을 넣는다. + */ + private static final Pattern BEARER_TOKEN = Pattern.compile("(?i)\\b(Bearer)\\s+[\\w.~\\-+/=]+"); private static final String PAIR_DELIMITER = "&"; private static final String NAME_VALUE_DELIMITER = "="; private static final int NAME_VALUE_LIMIT = 2; @@ -151,7 +191,8 @@ public static String maskSecretsInText(String text) { return text; } String assignmentsMasked = SECRET_ASSIGNMENT.matcher(text).replaceAll(secretReplacement()); - return DISCORD_WEBHOOK.matcher(assignmentsMasked).replaceAll("$1" + MASK); + String webhooksMasked = DISCORD_WEBHOOK.matcher(assignmentsMasked).replaceAll("$1" + MASK); + return BEARER_TOKEN.matcher(webhooksMasked).replaceAll("$1 " + MASK); } private static String secretReplacement() { diff --git a/src/main/java/com/offway/core/user/config/ApiAccessDeniedHandler.java b/src/main/java/com/offway/core/user/config/ApiAccessDeniedHandler.java new file mode 100644 index 00000000..083b20f1 --- /dev/null +++ b/src/main/java/com/offway/core/user/config/ApiAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package com.offway.core.user.config; + +import com.offway.core.common.exception.CommonErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +/** + * 인증됐으나 권한이 없을 때의 403 — 공통 래퍼 규격으로 내린다. + * + *

지금은 권한·롤이 없어 실질적으로 타지 않지만, 등록해두지 않으면 롤이 생기는 순간 403 만 래퍼 밖으로 새는 문제가 + * 그대로 재발한다. + */ +@Component +@RequiredArgsConstructor +public class ApiAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) + throws IOException { + SecurityErrorResponder.write(objectMapper, response, CommonErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/com/offway/core/user/config/ApiResponseAuthenticationEntryPoint.java b/src/main/java/com/offway/core/user/config/ApiResponseAuthenticationEntryPoint.java index 2d4d6a86..442537ff 100644 --- a/src/main/java/com/offway/core/user/config/ApiResponseAuthenticationEntryPoint.java +++ b/src/main/java/com/offway/core/user/config/ApiResponseAuthenticationEntryPoint.java @@ -2,14 +2,13 @@ import com.offway.core.common.exception.CommonErrorCode; import com.offway.core.common.response.ApiResponseBody; +import com.offway.core.user.domain.UserErrorCode; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.nio.charset.StandardCharsets; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; @@ -22,13 +21,24 @@ * 본문이 비어 클라이언트가 파싱에 실패한다. 모든 응답이 {@link ApiResponseBody} 라는 계약을 이 경로에서도 * 지킨다(exception-and-response 규약). * + *

제시한 자격증명 종류에 따라 code 를 나눈다. 401 하나로 뭉치면 앱이 다음에 뭘 해야 할지 모른다. + * + * + * + * + * + *
요청이 들고 온 것code클라이언트가 할 일
{@code Bearer} (만료·위조된 access){@code USER-004}refresh 로 재발급
그 외(없음 · Basic){@code COMMON-401}자격증명 제시 — 브라우저는 팝업
+ * + *

Bearer 를 들고 온 요청에 재발급을 시킬 수 있는 건 이 구분 덕이다. 반대로 아무것도 안 들고 온 요청에 + * {@code USER-004} 를 주면, 있지도 않은 refresh 로 재발급을 시도하는 무한 루프가 된다. + * *

{@code WWW-Authenticate} 헤더를 직접 붙인다. 이 엔트리 포인트가 Security 의 기본 Basic * 엔트리 포인트를 대체하므로, 여기서 안 붙이면 헤더가 아예 나가지 않는다 — 그러면 브라우저가 인증 * 팝업을 띄우지 않아 사람이 Swagger 를 열 수단이 사라진다(로그인 화면도 없다). 팝업으로 통과하는 것이 * Basic 을 고른 이유 중 하나라, 헤더가 빠지면 그 선택의 근거가 무너진다. * - *

앱 클라이언트는 이 헤더의 영향을 받지 않는다 — 팝업은 브라우저의 동작이고, 앱은 헤더를 무시한 채 - * 응답 본문만 읽는다. + *

Bearer 로 온 요청에는 이 헤더를 붙이지 않는다. 앱에게 Basic 을 권할 이유가 없고, 앱은 어차피 헤더를 + * 무시한 채 응답 본문만 읽는다. */ @Slf4j @Component @@ -41,29 +51,54 @@ public class ApiResponseAuthenticationEntryPoint implements AuthenticationEntryP /** 이 접두어로 시작하는 경로만 우리 API 다. 나머지 401 은 스캐너 소음으로 본다. */ private static final String API_PATH_PREFIX = "/api/"; + private static final String BEARER_PREFIX = "Bearer "; + private final ObjectMapper objectMapper; @Override public void commence( HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { - // 게이트를 뚫으려는 시도를 나중에라도 파악할 수 있게 흔적을 남긴다. 사용자명·자격증명은 절대 남기지 - // 않는다 — 오타로 비밀번호가 username 자리에 들어오는 일이 흔하고, 그게 그대로 로그에 박힌다. - // 레벨은 info 다: 401 은 클라이언트 계약 위반이라 서버 입장에서는 정상 흐름이다(로깅 규약). - // 우리 API 경로가 아닌 401 은 debug 다. 공인 IP 에 붙은 서버라 /Login·/wp-admin 같은 스캐너가 - // 쉬지 않고 두드리는데, 그걸 info 로 두면 정작 봐야 할 사용자 요청 로그가 그 사이에 묻힌다. - // 우리 엔드포인트를 향한 401 은 진짜 인증 문제일 수 있으므로 그대로 info 로 남긴다. + logAttempt(request); + // 앱이 access 토큰을 들고 왔는데 통과하지 못했다 — 만료됐거나 위조다. 재발급하라는 신호를 준다. + if (bearerPresented(request)) { + SecurityErrorResponder.write(objectMapper, response, UserErrorCode.INVALID_ACCESS_TOKEN); + return; + } + // 헤더는 본문보다 먼저 세팅한다 — 본문을 쓰면 응답이 커밋돼 헤더 변경이 반영되지 않는다. + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, BASIC_CHALLENGE); + // 실패 사유(비밀번호 틀림·계정 없음)는 응답에 담지 않는다 — 계정 존재 여부를 알려주는 셈이 된다. + SecurityErrorResponder.write(objectMapper, response, CommonErrorCode.UNAUTHORIZED); + } + + private static boolean bearerPresented(HttpServletRequest request) { + return hasBearerScheme(request.getHeader(HttpHeaders.AUTHORIZATION)); + } + + /** + * 게이트를 뚫으려는 시도를 나중에라도 파악할 수 있게 흔적을 남긴다. 자격증명은 절대 남기지 않는다 — 오타로 + * 비밀번호가 username 자리에 들어오는 일이 흔하고, 그게 그대로 로그에 박힌다. 토큰도 마찬가지다. + * + *

레벨은 info 다: 401 은 클라이언트 계약 위반이라 서버 입장에서는 정상 흐름이다(로깅 규약). 우리 API + * 경로가 아닌 401 은 debug 다. 공인 IP 에 붙은 서버라 {@code /Login}·{@code /wp-admin} 같은 스캐너가 쉬지 + * 않고 두드리는데, 그걸 info 로 두면 정작 봐야 할 사용자 요청 로그가 그 사이에 묻힌다. + */ + private static void logAttempt(HttpServletRequest request) { String path = request.getRequestURI(); if (path.startsWith(API_PATH_PREFIX)) { log.info("인증 실패 — 401 method={} path={}", request.getMethod(), path); } else { log.debug("인증 실패(비 API 경로) — 401 method={} path={}", request.getMethod(), path); } - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setHeader(HttpHeaders.WWW_AUTHENTICATE, BASIC_CHALLENGE); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - // 실패 사유(비밀번호 틀림·계정 없음)는 응답에 담지 않는다 — 계정 존재 여부를 알려주는 셈이 된다. - objectMapper.writeValue(response.getWriter(), ApiResponseBody.fail(CommonErrorCode.UNAUTHORIZED)); + } + + /** + * {@code Authorization} 이 Bearer 인지 — 대소문자를 구분하지 않는다. + * + *

HTTP 인증 scheme 은 규격상 대소문자를 가리지 않는다(RFC 7235). {@code startsWith("Bearer ")} 로 보면 + * {@code bearer } 을 들고 온 클라이언트가 토큰을 안 보낸 것으로 취급돼, 고칠 데가 없는데 401 을 받는다. + */ + private static boolean hasBearerScheme(String header) { + return header != null && header.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length()); } } diff --git a/src/main/java/com/offway/core/user/config/AuthProperties.java b/src/main/java/com/offway/core/user/config/AuthProperties.java new file mode 100644 index 00000000..fdfc0cd7 --- /dev/null +++ b/src/main/java/com/offway/core/user/config/AuthProperties.java @@ -0,0 +1,70 @@ +package com.offway.core.user.config; + +import com.offway.core.user.domain.AuthProvider; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 인증 설정 — 자체 JWT 서명키·수명과 provider 별 설정. + * + *

provider 설정이 비어 있어도 부팅은 된다(로컬 실행성 규칙). 해당 provider 로그인만 {@code USER-002} 로 실패한다. + * + *

provider 마다 필요한 값이 다르다. Apple·Google 은 ID 토큰의 {@code aud} 를 대조할 audience 가, Kakao 는 + * 앱이 등록됐는지 판별할 REST API 키가 필요하다 — 확인 방식이 다르니 설정도 같을 수 없다. + */ +@ConfigurationProperties(prefix = "offway.auth") +public record AuthProperties(Jwt jwt, Map oidc) { + + public AuthProperties { + if (jwt == null) { + jwt = new Jwt(null, null, null); + } + oidc = oidc == null ? Map.of() : Map.copyOf(oidc); + } + + /** provider 에 설정된 audience 목록. 비어 있으면 그 provider 는 사용 불가다. */ + public List audiencesOf(AuthProvider provider) { + Oidc config = oidc.get(provider); + return config == null ? List.of() : config.audiences(); + } + + /** 카카오 앱이 등록됐는지. REST API 키의 존재로 판별한다. */ + public boolean kakaoConfigured() { + Oidc config = oidc.get(AuthProvider.KAKAO); + return config != null && config.restApiKey() != null && !config.restApiKey().isBlank(); + } + + /** + * @param secret HS256 서명키. 최소 32바이트. local 은 개발용 고정값, prod 는 환경변수 필수 + * @param accessTtl access 토큰 수명 + * @param refreshTtl refresh 토큰 수명 + */ + public record Jwt(String secret, Duration accessTtl, Duration refreshTtl) { + + private static final Duration DEFAULT_ACCESS_TTL = Duration.ofHours(1); + private static final Duration DEFAULT_REFRESH_TTL = Duration.ofDays(60); + + public Jwt { + accessTtl = accessTtl == null ? DEFAULT_ACCESS_TTL : accessTtl; + refreshTtl = refreshTtl == null ? DEFAULT_REFRESH_TTL : refreshTtl; + } + } + + /** + * provider 하나의 설정. 쓰이는 항목이 provider 마다 다르고, 안 쓰는 쪽은 비어 있다. + * + * @param audiences 이 토큰이 우리 앱 것인지 판별하는 값. provider 마다 이름이 다를 뿐 역할은 하나다 — + * Apple·Google 은 ID 토큰의 {@code aud} 와 대조할 클라이언트 ID, Kakao 는 토큰 정보 조회가 돌려주는 + * {@code app_id} 와 대조할 앱 번호다. Google 은 '웹' 클라이언트 ID 다 — iOS 클라이언트 ID 를 넣으면 앱이 + * 보낸 토큰의 {@code aud} 와 어긋나 전부 401 이 된다. 여러 개면 콤마로 나열한다 + * @param restApiKey 카카오 REST API 키. 프로필 조회 호출에 실리지는 않고, 앱 등록 여부 판별에만 쓴다 + */ + public record Oidc(List audiences, String restApiKey) { + + public Oidc { + audiences = audiences == null ? List.of() : List.copyOf(audiences); + } + } +} diff --git a/src/main/java/com/offway/core/user/config/JwtAuthenticationFilter.java b/src/main/java/com/offway/core/user/config/JwtAuthenticationFilter.java new file mode 100644 index 00000000..8b272fc4 --- /dev/null +++ b/src/main/java/com/offway/core/user/config/JwtAuthenticationFilter.java @@ -0,0 +1,79 @@ +package com.offway.core.user.config; + +import com.offway.core.user.domain.UserException; +import com.offway.core.user.service.TokenIssuer; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * {@code Authorization: Bearer } 를 검증해 인증 컨텍스트를 채운다. + * + *

검증에 실패해도 여기서 예외를 던지지 않는다. 서블릿 필터는 {@code DispatcherServlet} 앞이라 던진 예외를 + * {@code GlobalExceptionHandler} 가 잡지 못해 응답이 공통 래퍼 밖으로 새기 때문이다. 컨텍스트를 비우고 통과시키면 + * 인가 단계에서 {@link ApiAuthenticationEntryPoint} 가 규격에 맞는 401 을 만든다. + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + /** {@code SecurityConfig.APP_USER_ROLE} 에 대응하는 권한 이름. Spring 이 역할 앞에 붙이는 접두어를 포함한다. */ + private static final String APP_USER_AUTHORITY = "ROLE_USER"; + + private final TokenIssuer tokenIssuer; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String accessToken = resolveToken(request); + if (accessToken != null) { + authenticate(accessToken); + } + filterChain.doFilter(request, response); + } + + private void authenticate(String accessToken) { + try { + UUID userId = tokenIssuer.parseAccessToken(accessToken); + SecurityContextHolder.getContext() + // 역할을 여기서 준다 — 상태를 바꾸는 요청은 이것을 요구해 Basic 으로는 닿지 못한다(CSRF). + .setAuthentication(new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority(APP_USER_AUTHORITY)))); + } catch (UserException exception) { + SecurityContextHolder.clearContext(); + } + } + + private static String resolveToken(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (!hasBearerScheme(header)) { + return null; + } + String token = header.substring(BEARER_PREFIX.length()).trim(); + return token.isEmpty() ? null : token; + } + + /** + * {@code Authorization} 이 Bearer 인지 — 대소문자를 구분하지 않는다. + * + *

HTTP 인증 scheme 은 규격상 대소문자를 가리지 않는다(RFC 7235). {@code startsWith("Bearer ")} 로 보면 + * {@code bearer } 을 들고 온 클라이언트가 토큰을 안 보낸 것으로 취급돼, 고칠 데가 없는데 401 을 받는다. + */ + private static boolean hasBearerScheme(String header) { + return header != null && header.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length()); + } +} diff --git a/src/main/java/com/offway/core/user/config/LoginUser.java b/src/main/java/com/offway/core/user/config/LoginUser.java new file mode 100644 index 00000000..08db49da --- /dev/null +++ b/src/main/java/com/offway/core/user/config/LoginUser.java @@ -0,0 +1,24 @@ +package com.offway.core.user.config; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +/** + * 인증된 사용자 식별자(UUID)를 컨트롤러 파라미터로 받는다. + * + *

{@link AuthenticationPrincipal} 메타 애노테이션이라 별도 ArgumentResolver 가 필요 없다. principal 은 + * {@link JwtAuthenticationFilter} 가 넣은 {@code UUID} 다. + * + *

{@code
+ * public ApiResponseBody logout(@LoginUser UUID userId) { ... }
+ * }
+ */ +@Documented +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@AuthenticationPrincipal +public @interface LoginUser {} diff --git a/src/main/java/com/offway/core/user/config/SecurityConfig.java b/src/main/java/com/offway/core/user/config/SecurityConfig.java index 1f5ec75d..4208a487 100644 --- a/src/main/java/com/offway/core/user/config/SecurityConfig.java +++ b/src/main/java/com/offway/core/user/config/SecurityConfig.java @@ -2,6 +2,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -13,33 +14,63 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; /** - * 임시 인증 게이트(#122) — 8080 을 외부에 열 때 아무나 우리 외부 API 키를 태우지 못하게 막는다. - * TMAP 경유지 최적화는 하루 50건이라 봇 한 마리로 고갈된다. + * 접근 제어 — 자격증명 두 가지를 한 체인에서 받는다. * - *

HTTP Basic 을 고른 이유: FE 에 로그인 화면이 없고 소셜 로그인은 provider 클라이언트 ID 가 없어 - * 붙일 수 없다(#93 이 draft 인 이유). Basic 은 로그인 페이지·토큰 저장·만료 관리가 전부 필요 없다 — 앱은 - * 헤더 하나, 브라우저(Swagger)는 기본 인증 팝업으로 통과한다. 무엇보다 #93 이 머지되면 통째로 걷어내기 - * 쉽다. 임시 장치는 제거 비용이 낮은 게 중요하다. + *

소셜 로그인(ADR 0002)이 붙으면서 자격증명이 둘이 됐다. 하나를 다른 하나로 갈아치우지 않고 둘 다 받는다. * - *

세션을 만들지 않는다(STATELESS). 매 요청이 자격증명을 들고 오므로 세션이 없어도 되고, 없어야 앱 - * 클라이언트가 쿠키를 관리하지 않는다. CSRF 도 같은 이유로 끈다 — 브라우저 폼 세션이 없으면 공격면이 없다. + * + * + * + * + *
수단누가 쓰나왜 남기나
{@code Authorization: Bearer }앱 사용자목표 상태. 요청 주체가 누구인지 알 수 있는 유일한 수단
{@code Authorization: Basic ...}팀 · Swagger · apidog#122 의 임시 게이트. 사람이 브라우저로 API 를 여는 유일한 수단
* - *

계정은 하나뿐이라 인메모리다. 팀 내부용 임시 게이트라 가입·비밀번호 재설정 같은 사용자 관리가 필요 없다. - * 값은 {@link BasicAuthProperties} 가 소유하고, 비어 있으면 부팅을 막는다. + *

Basic 으로는 읽기만 할 수 있다. 브라우저는 캐시된 Basic 자격증명을 교차 출처 쓰기 요청에도 붙이고, + * 공개 GET 경로의 CORS 제한은 그 전송을 막지 못한다(CSRF). 이 서비스는 CSRF 토큰을 쓰지 않는 무상태 API 라, 막는 방법은 + * 자격증명의 힘을 줄이는 쪽이다 — Basic 에는 안전한 메서드(GET·HEAD)만 허용하고 상태를 바꾸는 요청은 Bearer 만 받는다. + * + *

Basic 을 통째로 걷어내지 않는 이유는 그것이 사람이 서버를 들여다보는 유일한 수단이기 때문이다. Swagger 로 + * 명세를 보는 것도, 배포 스모크가 "적재가 실제로 채워졌는지" 를 확인하는 것도 이 경로를 탄다 — 그 스모크는 89곳 중 + * 42곳에서 멈춘 배포를 실제로 잡아냈다. 걷어내면 그 안전망이 함께 사라진다. 앱이 토큰을 들고 오게 된 뒤 별도 PR 에서 + * {@code httpBasic} 한 줄과 {@link BasicAuthProperties} 를 함께 지운다. + * + *

인증 실패 응답은 {@link ApiResponseAuthenticationEntryPoint}·{@link ApiAccessDeniedHandler} 가 공통 래퍼 + * 규격으로 만든다 — 이 경로는 {@code GlobalExceptionHandler} 가 닿지 못한다. */ @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - /** 인증 없이 열리는 유일한 접두어(#143). 여기 아래는 보기 전용이고 소유자 식별자를 반환하지 않는다. */ + /** 인증 없이 열리는 공개 조회 접두어(#143). 여기 아래는 보기 전용이고 소유자 식별자를 반환하지 않는다. */ private static final String PUBLIC_PATH_PATTERN = "/api/v1/public/**"; + /** + * 자격증명을 만들어 주는 경로들. 여기를 잠그면 아무도 토큰을 얻을 수 없어 닭이 먼저냐 달걀이 먼저냐가 된다. + * + *

{@code /api/v1/auth/**} 로 뭉뚱그리지 않는다 — 로그아웃은 누구의 토큰을 폐기할지 알아야 하므로 반드시 + * 인증이 필요하다. 열 것만 적는 allowlist 라, 나중에 {@code /auth} 아래 새 엔드포인트가 생겨도 기본이 잠김이다. + */ + private static final String[] CREDENTIAL_ISSUING_PATHS = { + "/api/v1/auth/callback/*", "/api/v1/auth/reissue", "/api/v1/auth/dev-login" + }; + + /** + * 상태를 바꾸는 요청에 요구하는 역할 — Bearer 로 온 요청만 갖는다. + * + *

Basic 사용자에게는 주지 않는다. 브라우저가 자동으로 붙이는 자격증명으로는 쓰기를 못 하게 하려는 것이고, + * 그것이 CSRF 토큰 없이 무상태 API 를 지키는 방법이다. + */ + private static final String APP_USER_ROLE = "USER"; + + /** 사람이 서버를 들여다보는 수단. Basic 이 남아 있는 이유이자, 여기까지가 Basic 의 한계다. */ + private static final String[] DOCS_PATHS = {"/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**"}; + /** 공유 웹앱은 브라우저에서 직접 부른다 — 읽기만 하므로 GET 만 연다. */ private static final String CORS_ALLOWED_METHOD = "GET"; @@ -47,7 +78,9 @@ public class SecurityConfig { private static final String CORS_ALLOWED_ORIGIN = "*"; private final BasicAuthProperties basicAuthProperties; + private final JwtAuthenticationFilter jwtAuthenticationFilter; private final ApiResponseAuthenticationEntryPoint authenticationEntryPoint; + private final ApiAccessDeniedHandler apiAccessDeniedHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -55,17 +88,24 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .cors(cors -> cors.configurationSource(corsConfigurationSource())) - // 게이트의 첫 예외(#143). 공유 링크를 받은 사람에게는 우리 계정이 없다. - // - // **경로 접두어 하나로 좁힌다.** 엔드포인트마다 예외를 흩으면 어느 것이 열려 있는지 한눈에 - // 안 보이고, 나중에 추가되는 엔드포인트가 실수로 열린다. 이 접두어 아래에는 보기 전용이면서 - // 소유자 식별자를 반환하지 않는 것만 둔다. .authorizeHttpRequests(auth -> auth.requestMatchers(PUBLIC_PATH_PATTERN) .permitAll() + .requestMatchers(CREDENTIAL_ISSUING_PATHS) + .permitAll() + // 읽기는 두 수단 다 받는다 — Swagger 로 명세를 보고, 스모크가 적재를 확인한다. + .requestMatchers(HttpMethod.GET, "/**") + .authenticated() + .requestMatchers(HttpMethod.HEAD, "/**") + .authenticated() + // 쓰기는 Bearer 만. Basic 사용자는 이 역할이 없어 여기서 403 이 된다. .anyRequest() - .authenticated()) + .hasRole(APP_USER_ROLE)) .httpBasic(basic -> basic.authenticationEntryPoint(authenticationEntryPoint)) - .exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint)); + .exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(apiAccessDeniedHandler)) + // Basic 보다 앞에 둔다. Bearer 를 먼저 해석해 컨텍스트를 채우면 Basic 필터는 자기 헤더가 아니라 + // 그냥 통과하므로, 두 수단이 서로를 막지 않는다. + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -108,8 +148,10 @@ public PasswordEncoder passwordEncoder() { @Bean public UserDetailsService userDetailsService() { + // 역할을 주지 않는다 — 쓰기는 APP_USER_ROLE 을 요구하므로 이 계정으로는 상태를 바꿀 수 없다. return new InMemoryUserDetailsManager(User.withUsername(basicAuthProperties.username()) .password(basicAuthProperties.password()) + .authorities(List.of()) .build()); } } diff --git a/src/main/java/com/offway/core/user/config/SecurityErrorResponder.java b/src/main/java/com/offway/core/user/config/SecurityErrorResponder.java new file mode 100644 index 00000000..0bb3b06f --- /dev/null +++ b/src/main/java/com/offway/core/user/config/SecurityErrorResponder.java @@ -0,0 +1,28 @@ +package com.offway.core.user.config; + +import com.offway.core.common.exception.ErrorCode; +import com.offway.core.common.response.ApiResponseBody; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.springframework.http.MediaType; +import tools.jackson.databind.ObjectMapper; + +/** + * Spring Security 단계의 실패를 공통 응답 래퍼로 직접 써 내린다. + * + *

이 경로는 {@code DispatcherServlet} 밖이라 {@code GlobalExceptionHandler} 가 관여하지 못한다. 컨트롤러 + * 경로와 응답 모양이 한 글자도 다르지 않아야 FE 가 실패 처리 분기를 하나로 유지할 수 있다. + */ +final class SecurityErrorResponder { + + private SecurityErrorResponder() {} + + static void write(ObjectMapper objectMapper, HttpServletResponse response, ErrorCode errorCode) + throws IOException { + response.setStatus(errorCode.category().httpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getOutputStream(), ApiResponseBody.fail(errorCode)); + } +} diff --git a/src/main/java/com/offway/core/user/controller/AuthApi.java b/src/main/java/com/offway/core/user/controller/AuthApi.java new file mode 100644 index 00000000..00a90065 --- /dev/null +++ b/src/main/java/com/offway/core/user/controller/AuthApi.java @@ -0,0 +1,76 @@ +package com.offway.core.user.controller; + +import com.offway.core.common.response.ApiResponseBody; +import com.offway.core.user.controller.dto.ReissueRequest; +import com.offway.core.user.controller.dto.SocialLoginRequest; +import com.offway.core.user.controller.dto.TokenResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.UUID; + +/** 인증 API 문서 계약. 매핑은 구현체({@link AuthController})가 소유한다. */ +@Tag(name = "인증", description = "소셜 로그인 · 토큰 재발급 · 로그아웃") +public interface AuthApi { + + @Operation( + summary = "소셜 로그인 콜백", + description = + """ + 앱이 provider SDK 로 받은 토큰을 검증하고 서비스 토큰을 발급한다. 처음 보는 신원이면 그대로 가입되고 + `isNewUser` 가 true 로 내려간다 — 앱은 이 값으로 온보딩(잔여 연차 입력)과 홈을 가른다. + + provider 별로 넘길 토큰과 서버의 확인 방식이 다르다. + - `kakao` — 액세스 토큰. 서버가 카카오 프로필 API 를 조회해 회원번호를 확인한다 + - `apple` — identityToken(JWT). Apple 공개키로 서명과 aud 를 검증한다 + - `google` — idToken(JWT). Google 공개키로 서명과 aud('웹' 클라이언트 ID)를 검증한다 + + `email`·`name` 은 Apple 이 최초 로그인 응답에만 주므로 그때만 실린다. `providerUserId` 는 받지만 + 신원 판단에는 쓰지 않는다 — 서버가 provider 에게 직접 확인한 식별자만 믿는다. + + 인증 없이 호출할 수 있다(토큰을 받으러 오는 경로다). + """) + @ApiResponse(responseCode = "200", description = "로그인 성공(신규 가입 포함)") + @ApiResponse(responseCode = "400", description = "토큰 누락 · 지원하지 않는 provider 경로값(USER-002)") + @ApiResponse( + responseCode = "401", + description = "토큰이 무효 — 서명·만료·issuer/audience 불일치, 카카오가 액세스 토큰을 거부(USER-001)") + @ApiResponse( + responseCode = "502", + description = "provider 를 부르지 못함 — 공개키(JWKS) 조회 실패 · 카카오 프로필 API 실패·타임아웃(USER-005)") + @Parameter( + name = "X-Guest-Id", + in = ParameterIn.HEADER, + required = false, + description = "이 기기의 게스트 키(#34). 코스·연차가 쓰는 그 값과 같다. 로그인할 때 함께 보내면 " + + "서버가 이 기기를 사용자에게 이어 두고, 나중에 탈퇴가 그 데이터를 찾아 지운다. " + + "안 보내도 로그인은 되지만 그 사용자의 코스·연차는 주인 없이 남는다") + ApiResponseBody callback( + @Parameter(description = "소셜 provider — kakao · apple · google (대소문자 무관)", example = "kakao") + String provider, + String guestId, + SocialLoginRequest request); + + @Operation( + summary = "토큰 재발급", + description = + """ + refresh 토큰을 회전시켜 새 토큰 쌍을 발급한다. 이미 사용된 refresh 를 다시 보내면 탈취로 보고 해당 + 사용자의 토큰을 모두 폐기한다. 응답의 `isNewUser` 는 항상 false 다. + + 인증 없이 호출할 수 있다(access 가 만료됐을 때 부르는 경로다). + """) + @ApiResponse(responseCode = "200", description = "재발급 성공") + @ApiResponse(responseCode = "400", description = "refresh 토큰 누락") + @ApiResponse(responseCode = "401", description = "refresh 토큰이 없거나 만료·폐기됨(USER-003)") + ApiResponseBody reissue(ReissueRequest request); + + @Operation( + summary = "로그아웃", + description = "이 사용자의 refresh 토큰을 모두 폐기한다. 이미 발급된 access 토큰은 만료(기본 1시간)까지 유효하다.") + @ApiResponse(responseCode = "200", description = "로그아웃 성공") + @ApiResponse(responseCode = "401", description = "access 토큰이 없거나 무효·만료(USER-004) · 자격증명 없음(COMMON-401)") + ApiResponseBody logout(UUID userId); +} diff --git a/src/main/java/com/offway/core/user/controller/AuthController.java b/src/main/java/com/offway/core/user/controller/AuthController.java new file mode 100644 index 00000000..dc8ee623 --- /dev/null +++ b/src/main/java/com/offway/core/user/controller/AuthController.java @@ -0,0 +1,56 @@ +package com.offway.core.user.controller; + +import com.offway.core.common.response.ApiResponseBody; +import com.offway.core.user.config.LoginUser; +import com.offway.core.user.controller.dto.ReissueRequest; +import com.offway.core.user.controller.dto.SocialLoginRequest; +import com.offway.core.user.controller.dto.TokenResponse; +import com.offway.core.user.service.AuthService; +import jakarta.validation.Valid; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController implements AuthApi { + + /** 코스·연차가 쓰는 그 헤더다(#34). 로그인할 때 이 기기를 사용자에게 이어 두려고 함께 받는다. */ + private static final String GUEST_ID_HEADER = "X-Guest-Id"; + + private final AuthService authService; + + /** + * 신규 가입도 201 이 아니라 200 — 생기는 건 세션이지 클라이언트가 URL 로 가리킬 리소스가 아니다. + * + *

provider 는 본문이 아니라 경로에 둔다. 앱이 provider 별로 다른 화면·다른 SDK 를 타고 들어오므로 경로가 + * 갈리는 편이 호출부에서 읽히고, 서버도 라우팅만 보고 어느 provider 인지 안다. + */ + @Override + @PostMapping("/callback/{provider}") + public ApiResponseBody callback( + @PathVariable String provider, + @RequestHeader(value = GUEST_ID_HEADER, required = false) String guestId, + @Valid @RequestBody SocialLoginRequest request) { + return ApiResponseBody.ok(TokenResponse.from(authService.login(request.toCommand(provider, guestId)))); + } + + @Override + @PostMapping("/reissue") + public ApiResponseBody reissue(@Valid @RequestBody ReissueRequest request) { + return ApiResponseBody.ok(TokenResponse.from(authService.reissue(request.refreshToken()))); + } + + @Override + @PostMapping("/logout") + public ApiResponseBody logout(@LoginUser UUID userId) { + authService.logout(userId); + return ApiResponseBody.ok(); + } +} diff --git a/src/main/java/com/offway/core/user/controller/DevAuthApi.java b/src/main/java/com/offway/core/user/controller/DevAuthApi.java new file mode 100644 index 00000000..44169aee --- /dev/null +++ b/src/main/java/com/offway/core/user/controller/DevAuthApi.java @@ -0,0 +1,20 @@ +package com.offway.core.user.controller; + +import com.offway.core.common.response.ApiResponseBody; +import com.offway.core.user.controller.dto.DevLoginRequest; +import com.offway.core.user.controller.dto.TokenResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** 개발용 인증 API 문서 계약. 매핑은 구현체({@link DevAuthController})가 소유한다. */ +@Tag(name = "인증(개발용)", description = "local 프로파일에서만 노출된다") +public interface DevAuthApi { + + @Operation( + summary = "개발용 로그인", + description = + "provider 검증 없이 사용자를 만들고 토큰을 발급한다. local 프로파일 전용이라 prod 에는 이 빈이 존재하지 않아 경로 자체가 열리지 않는다.") + @ApiResponse(responseCode = "200", description = "발급 성공") + ApiResponseBody devLogin(DevLoginRequest request); +} diff --git a/src/main/java/com/offway/core/user/controller/DevAuthController.java b/src/main/java/com/offway/core/user/controller/DevAuthController.java new file mode 100644 index 00000000..4ceabc35 --- /dev/null +++ b/src/main/java/com/offway/core/user/controller/DevAuthController.java @@ -0,0 +1,33 @@ +package com.offway.core.user.controller; + +import com.offway.core.common.response.ApiResponseBody; +import com.offway.core.user.controller.dto.DevLoginRequest; +import com.offway.core.user.controller.dto.TokenResponse; +import com.offway.core.user.service.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 개발용 로그인 — OAuth 를 강제하면 FE 가 로컬에서 실 provider 토큰 없이는 어떤 API 도 부를 수 없기 때문에 둔다. + * + *

{@code @Profile("local")} 이라 prod 에는 빈이 아예 없다. 경로가 열려 있는데 막는 방식이 아니라, 존재하지 않는 방식이다. + */ +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Profile("local") +public class DevAuthController implements DevAuthApi { + + private final AuthService authService; + + @Override + @PostMapping("/dev-login") + public ApiResponseBody devLogin(@Valid @RequestBody DevLoginRequest request) { + return ApiResponseBody.ok(TokenResponse.from(authService.devLogin(request.nickname()))); + } +} diff --git a/src/main/java/com/offway/core/user/controller/dto/DevLoginRequest.java b/src/main/java/com/offway/core/user/controller/dto/DevLoginRequest.java new file mode 100644 index 00000000..77713f04 --- /dev/null +++ b/src/main/java/com/offway/core/user/controller/dto/DevLoginRequest.java @@ -0,0 +1,11 @@ +package com.offway.core.user.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 개발용 로그인 요청(local 전용). + * + * @param nickname 표시 이름(선택). 없으면 기본 이름이 붙는다 + */ +public record DevLoginRequest( + @Schema(description = "표시 이름(선택)", example = "테스터", nullable = true) String nickname) {} diff --git a/src/main/java/com/offway/core/user/controller/dto/ReissueRequest.java b/src/main/java/com/offway/core/user/controller/dto/ReissueRequest.java new file mode 100644 index 00000000..f6d9badd --- /dev/null +++ b/src/main/java/com/offway/core/user/controller/dto/ReissueRequest.java @@ -0,0 +1,12 @@ +package com.offway.core.user.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 토큰 재발급 요청. + * + * @param refreshToken 로그인·직전 재발급 때 받은 refresh 토큰 원문 + */ +public record ReissueRequest( + @NotBlank @Schema(description = "refresh 토큰 원문") String refreshToken) {} diff --git a/src/main/java/com/offway/core/user/controller/dto/SocialLoginRequest.java b/src/main/java/com/offway/core/user/controller/dto/SocialLoginRequest.java new file mode 100644 index 00000000..e1c96a31 --- /dev/null +++ b/src/main/java/com/offway/core/user/controller/dto/SocialLoginRequest.java @@ -0,0 +1,47 @@ +package com.offway.core.user.controller.dto; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.service.dto.SocialLoginCommand; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 소셜 로그인 요청 — 앱이 provider SDK 로 받아 온 토큰을 넘긴다. provider 는 경로 변수로 온다. + * + *

{@code accessToken} 에 담기는 것이 provider 마다 다르다. 필드 이름을 하나로 둔 것은 앱이 provider 별로 + * 다른 본문을 만들지 않게 하기 위해서고, 서버는 provider 를 알고 있으므로 무엇이 왔는지 헷갈리지 않는다. + * + * + * + * + * + * + *
provideraccessToken 에 담기는 것서버가 하는 일
kakao액세스 토큰프로필 API 조회로 회원번호 확인
appleidentityToken(JWT)Apple 공개키로 서명·{@code aud} 검증
googleidToken(JWT)Google 공개키로 서명·{@code aud} 검증
+ * + * @param accessToken provider SDK 가 발급한 토큰 + * @param email 표시용 이메일(선택). Apple 은 최초 로그인 응답에만 주므로 그때 받아 넘기지 않으면 영영 얻을 수 없다 + * @param name 표시 이름(선택). 위와 같은 이유로 Apple 최초 로그인에서만 값이 온다 + * @param providerUserId 받지만 신원 판단에 쓰지 않는다. 앱 편의를 위해 계약에 남겨 둔 필드다. 이 값을 믿고 + * 계정을 찾으면 아무나 남의 식별자를 적어 그 계정으로 로그인할 수 있다 — 요청 한 번짜리 계정 탈취가 된다. + * 식별자는 언제나 서버가 provider 에게서 직접 확인한 값을 쓴다 + */ +public record SocialLoginRequest( + @NotBlank @Schema(description = "provider SDK 가 발급한 토큰 (kakao=액세스 토큰, apple/google=ID 토큰)") + String accessToken, + @Schema(description = "이메일(선택). Apple 최초 로그인에서만 온다", example = "user@example.com", nullable = true) + String email, + @Schema(description = "표시 이름(선택). Apple 최초 로그인에서만 온다", example = "홍길동", nullable = true) + String name, + @Schema( + description = "제공자별 사용자 식별자(선택). 서버는 신원 판단에 쓰지 않고 provider 에게 직접 확인한다", + nullable = true) + String providerUserId) { + + /** + * @param guestId 이 기기의 게스트 키(#34). 본문이 아니라 헤더로 온다 — 코스·연차가 쓰는 그 값과 같은 것이라 + * 계약을 둘로 만들지 않는다. 안 보내는 클라이언트도 있어 null 일 수 있다 + */ + public SocialLoginCommand toCommand(String provider, String guestId) { + return new SocialLoginCommand(AuthProvider.from(provider), accessToken, name, email, guestId); + } +} diff --git a/src/main/java/com/offway/core/user/controller/dto/TokenResponse.java b/src/main/java/com/offway/core/user/controller/dto/TokenResponse.java new file mode 100644 index 00000000..2bb65e5a --- /dev/null +++ b/src/main/java/com/offway/core/user/controller/dto/TokenResponse.java @@ -0,0 +1,30 @@ +package com.offway.core.user.controller.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.offway.core.user.service.dto.IssuedToken; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 발급된 토큰 쌍. + * + * @param accessToken 이후 요청에 {@code Authorization: Bearer} 로 싣는다 + * @param refreshToken access 만료 시 재발급에 쓴다. 재발급하면 이 값은 폐기되고 새 값이 내려온다 + * @param expiresIn access 토큰 잔여 수명(초) + * @param isNewUser 이 로그인이 가입이었는지. 앱은 {@code true} 면 온보딩(잔여 연차 입력), {@code false} 면 홈으로 + * 보낸다. 재발급 응답에서는 항상 {@code false} 다 — 재발급은 가입일 수 없다 + */ +public record TokenResponse( + @Schema(description = "access 토큰") String accessToken, + @Schema(description = "refresh 토큰(재발급 시 회전됨)") String refreshToken, + @Schema(description = "access 토큰 수명(초)", example = "3600") long expiresIn, + // 이름을 못박는다. record 접근자 isNewUser() 는 bean 규약으로 읽으면 속성명이 newUser 라, 그대로 두면 + // 직렬화 이름이 Jackson 설정에 좌우된다. FE 계약이 isNewUser 이므로 흔들릴 여지를 없앤다. + @JsonProperty("isNewUser") + @Schema(description = "이번 로그인이 가입이었는지 — true 면 온보딩으로 보낸다", example = "true") + boolean isNewUser) { + + public static TokenResponse from(IssuedToken token) { + return new TokenResponse( + token.accessToken(), token.refreshToken(), token.expiresInSeconds(), token.newUser()); + } +} diff --git a/src/main/java/com/offway/core/user/domain/AuthProvider.java b/src/main/java/com/offway/core/user/domain/AuthProvider.java new file mode 100644 index 00000000..a0686e24 --- /dev/null +++ b/src/main/java/com/offway/core/user/domain/AuthProvider.java @@ -0,0 +1,87 @@ +package com.offway.core.user.domain; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * 지원하는 소셜 로그인 provider. + * + *

셋이 같은 방식으로 확인되지 않는다. Apple·Google 이 주는 것은 provider 가 서명한 ID 토큰이라 공개키만 + * 있으면 서버 안에서 신원이 확정된다. Kakao 가 주는 것은 정보가 담기지 않은 액세스 토큰이라, 그 토큰으로 + * 프로필 API 를 한 번 더 불러야 누구인지 알 수 있다 — 로그인 경로에 외부 호출이 낀다는 뜻이다. + * + *

그 차이를 {@link #oidc()} 의 유무가 표현한다. 값이 있으면 서명 검증으로, 없으면 프로필 조회로 확인한다. + * 분기를 boolean 이나 별도 enum 으로 또 두지 않는 이유는, 서명 검증에 필요한 값(issuer·JWKS 주소)과 그 방식이 + * 쓰이는 조건이 정확히 같기 때문이다. + */ +public enum AuthProvider { + + /** + * Google 은 {@code iss} 를 두 가지 표기로 낸다 — {@code https://accounts.google.com} 과 스킴 없는 + * {@code accounts.google.com}. 어느 쪽이 오는지는 토큰을 만든 SDK·흐름에 달렸다. 하나만 허용하면 다른 표기를 + * 받은 사용자가 전부 401 이 되므로 둘 다 받는다(Google 자신의 검증 라이브러리도 그렇게 한다). + */ + GOOGLE(new Oidc( + List.of("https://accounts.google.com", "accounts.google.com"), + "https://www.googleapis.com/oauth2/v3/certs", + "name")), + + /** Apple 은 ID 토큰에 이름을 담지 않는다 — 최초 인증 응답에만, 그것도 사용자가 제공을 선택했을 때만 온다. */ + APPLE(new Oidc(List.of("https://appleid.apple.com"), "https://appleid.apple.com/auth/keys", null)), + + /** 액세스 토큰에 신원 정보가 없어 프로필 API 조회로 확인한다. */ + KAKAO(null); + + /** OIDC 표준 이메일 클레임. Apple·Google 이 같은 이름을 쓴다. */ + public static final String EMAIL_CLAIM = "email"; + + private final Oidc oidc; + + AuthProvider(Oidc oidc) { + this.oidc = oidc; + } + + /** 서명 검증으로 확인하는 provider 면 그 설정. 비어 있으면 프로필 조회로 확인한다. */ + public Optional oidc() { + return Optional.ofNullable(oidc); + } + + /** + * 경로 변수({@code /auth/callback/kakao})를 provider 로 해석한다. 대소문자를 가리지 않는다. + * + *

모르는 값에 Spring 기본 변환 실패(형식 오류)를 맡기지 않고 여기서 {@code USER-002} 로 끊는다 — 앱이 + * 문구가 아니라 code 로 분기하므로, "지원하지 않는 로그인 방식" 이라는 사유가 code 로 전달돼야 한다. + */ + public static AuthProvider from(String value) { + if (value == null || value.isBlank()) { + throw UserException.unsupportedProvider(); + } + try { + return valueOf(value.strip().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw UserException.unsupportedProvider(); + } + } + + /** + * 서명된 ID 토큰을 검증하는 데 필요한 값. + * + *

audience(클라이언트 ID)는 여기 없다 — 환경별로 다르고 비밀에 가까워 설정({@code offway.auth.oidc.*})이 소유한다. + * + * @param issuers 토큰의 {@code iss} 가 이 중 하나여야 한다. 표기를 여러 개 쓰는 provider(Google)가 있어 목록이다 + * @param jwksUri 서명 검증용 공개키 주소 + * @param nicknameClaim 표시 이름이 담긴 클레임. 주지 않는 provider 는 {@code null} + */ + public record Oidc(List issuers, String jwksUri, String nicknameClaim) { + + public Oidc { + issuers = List.copyOf(issuers); + } + + /** ID 토큰에서 닉네임을 담고 있는 클레임 이름. Apple 처럼 주지 않는 provider 는 비어 있다. */ + public Optional nicknameClaimIfPresent() { + return Optional.ofNullable(nicknameClaim); + } + } +} diff --git a/src/main/java/com/offway/core/user/domain/RefreshToken.java b/src/main/java/com/offway/core/user/domain/RefreshToken.java new file mode 100644 index 00000000..4ced394d --- /dev/null +++ b/src/main/java/com/offway/core/user/domain/RefreshToken.java @@ -0,0 +1,117 @@ +package com.offway.core.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UuidGenerator; +import org.hibernate.type.SqlTypes; + +/** + * refresh 토큰. 원문이 아니라 SHA-256 해시만 저장한다 — DB 가 유출돼도 토큰이 그대로 쓰이지 않게. + * + *

회전할 때 행을 지우지 않고 {@code revokedAt} 을 채운다. 지워버리면 "폐기된 토큰 재사용"과 "처음부터 없는 토큰"이 + * 똑같이 "조회 결과 없음"이 되어 탈취를 감지할 방법이 사라진다. + */ +@Entity +@Table(name = "refresh_token") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + + /** + * 회전 직후 유예 창 — 이 안에 같은 토큰이 다시 오면 탈취가 아니라 재시도로 본다. + * + *

정상 앱도 같은 refresh 를 두 번 쏜다. 401 을 받은 요청 둘이 동시에 재발급을 걸거나, 응답을 못 받고 + * 타임아웃 재시도를 하면 그렇다. 그때마다 탈취 경보가 울려 세션 전체를 끊으면 이긴 요청이 방금 받아 간 + * 정상 토큰까지 죽어 사용자가 멀쩡한 토큰을 들고 로그아웃된다. + * + *

10초인 근거: 이 서비스의 외부 호출 timeout 상한이 3초라, 한 번 실패하고 재시도하는 데 걸리는 시간이 + * 그 몇 배를 넘지 않는다. 대가는 이 창 안에서는 탈취된 토큰이 다시 와도 경보가 울리지 않는다는 것이라 + * (요청 자체는 거절된다) 짧을수록 좋다. + */ + public static final Duration ROTATION_GRACE = Duration.ofSeconds(10); + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @JdbcTypeCode(SqlTypes.BINARY) + @Column(name = "id", columnDefinition = "BINARY(16)") + private UUID id; + + @JdbcTypeCode(SqlTypes.BINARY) + @Column(name = "user_id", nullable = false, columnDefinition = "BINARY(16)") + private UUID userId; + + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + private RefreshToken(UUID userId, String tokenHash, Instant expiresAt) { + this.userId = userId; + this.tokenHash = tokenHash; + this.expiresAt = expiresAt; + } + + /** 사용자에게 refresh 토큰을 발급한다. 인자는 이미 해시된 값이어야 한다(원문은 응답으로만 나간다). */ + public static RefreshToken issue(UUID userId, String tokenHash, Instant expiresAt) { + Objects.requireNonNull(userId, "사용자 ID는 필수입니다"); + Objects.requireNonNull(tokenHash, "토큰 해시는 필수입니다"); + Objects.requireNonNull(expiresAt, "만료 시각은 필수입니다"); + if (tokenHash.isBlank()) { + throw new IllegalArgumentException("토큰 해시는 비어 있을 수 없습니다"); + } + return new RefreshToken(userId, tokenHash, expiresAt); + } + + /** 회전·로그아웃으로 폐기한다. 이미 폐기됐으면 최초 폐기 시각을 유지한다(재사용 감지의 근거). */ + public void revoke(Instant now) { + if (revokedAt == null) { + this.revokedAt = now; + } + } + + public boolean isRevoked() { + return revokedAt != null; + } + + /** + * 방금 폐기됐는가 — 회전 직후 유예 창 안인지. + * + *

같은 refresh 가 다시 온 이유를 가른다. 창 안이면 정상 앱의 재시도·동시 요청이고, 창 밖이면 탈취 정황이다. + * 판정을 서비스가 아니라 여기서 하는 이유는 {@code revokedAt} 이 이 객체의 상태이기 때문이다. + */ + public boolean revokedWithin(Duration grace, Instant now) { + return revokedAt != null && !revokedAt.isBefore(now.minus(grace)); + } + + public boolean isExpired(Instant now) { + return !expiresAt.isAfter(now); + } + + /** 재발급에 쓸 수 있는 상태인지 — 폐기되지 않았고 만료되지도 않았을 때만. */ + public boolean isUsableAt(Instant now) { + return !isRevoked() && !isExpired(now); + } + + @PrePersist + void onCreate() { + this.createdAt = Instant.now(); + } +} diff --git a/src/main/java/com/offway/core/user/domain/SocialIdentity.java b/src/main/java/com/offway/core/user/domain/SocialIdentity.java new file mode 100644 index 00000000..07e6af53 --- /dev/null +++ b/src/main/java/com/offway/core/user/domain/SocialIdentity.java @@ -0,0 +1,43 @@ +package com.offway.core.user.domain; + +import java.util.Objects; +import java.util.Optional; + +/** + * provider 가 확인해 준 신원 — 서버가 스스로 확인한 값만 담는다. + * + *

어댑터 DTO 가 아니라 도메인 타입이라, 확인 방식(서명 검증·프로필 조회·stub)이 바뀌어도 서비스가 흔들리지 않는다. + * + *

클라이언트가 보낸 값은 여기에 들어오지 않는다. 앱도 요청 본문에 {@code providerUserId} 를 실어 보내지만, + * 그 값을 그대로 쓰면 아무나 남의 식별자를 적어 그 계정으로 로그인할 수 있다 — 계정 탈취가 요청 한 번이 된다. + * {@link #providerUserId} 는 Apple·Google 은 검증된 ID 토큰의 {@code sub}, Kakao 는 프로필 API 가 돌려준 + * {@code id} 에서만 온다. + * + * @param provider 어느 provider 가 확인해 줬는지 + * @param providerUserId provider 안에서 유일하고 변하지 않는 식별자. 계정 매칭 키다 + * @param nickname provider 가 준 표시 이름. Apple 은 주지 않으므로 비어 있을 수 있다 + * @param email provider 가 준 이메일. Kakao 는 동의를 거부할 수 있고 Apple 은 Private Relay 익명 주소를 줄 수 있어 + * 비어 있거나 실제 주소가 아닐 수 있다. 계정 매칭에는 쓰지 않는다 + */ +public record SocialIdentity(AuthProvider provider, String providerUserId, String nickname, String email) { + + public SocialIdentity { + Objects.requireNonNull(provider, "provider 는 필수입니다"); + Objects.requireNonNull(providerUserId, "provider 사용자 식별자는 필수입니다"); + if (providerUserId.isBlank()) { + throw new IllegalArgumentException("provider 사용자 식별자는 비어 있을 수 없습니다"); + } + } + + public Optional nicknameIfPresent() { + return blankToEmpty(nickname); + } + + public Optional emailIfPresent() { + return blankToEmpty(email); + } + + private static Optional blankToEmpty(String value) { + return Optional.ofNullable(value).filter(candidate -> !candidate.isBlank()); + } +} diff --git a/src/main/java/com/offway/core/user/domain/User.java b/src/main/java/com/offway/core/user/domain/User.java new file mode 100644 index 00000000..21d6dd7a --- /dev/null +++ b/src/main/java/com/offway/core/user/domain/User.java @@ -0,0 +1,116 @@ +package com.offway.core.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UuidGenerator; +import org.hibernate.type.SqlTypes; + +/** + * 서비스 사용자. 인증 수단은 {@link UserIdentity} 가 따로 들고, 이 엔티티는 신원과 표시 정보만 갖는다. + * + *

식별자가 UUID 라 순번 노출·열거 문제가 없고, 내부 PK 와 외부 노출 식별자를 하나로 쓴다. 랜덤 v4 대신 시간정렬 + * UUID({@code Style.TIME})를 쓰는 이유는 InnoDB 클러스터드 인덱스 파편화를 피하기 위해서다. + */ +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User { + + /** 닉네임 최대 길이 — {@code nickname} 컬럼 폭과 일치시켜, 초과 입력이 저장 단계 서버 오류로 새지 않게 경계에서 자른다. */ + public static final int MAX_NICKNAME_LENGTH = 50; + + /** 이메일 최대 길이 — 닉네임과 같은 이유로 컬럼 폭과 맞춘다. */ + public static final int MAX_EMAIL_LENGTH = 255; + + /** provider 도 요청도 이름을 주지 않았을 때의 표시 이름. Apple 로그인에서 실제로 발생한다. */ + private static final String DEFAULT_NICKNAME = "여행자"; + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @JdbcTypeCode(SqlTypes.BINARY) + @Column(name = "id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false, length = MAX_NICKNAME_LENGTH) + private String nickname; + + /** + * provider 가 준 이메일. 없을 수 있다. + * + *

Kakao 는 동의를 거부할 수 있고, Apple 은 Private Relay 익명 주소를 주거나 최초 로그인에만 준다. 그래서 NULL + * 을 허용하고 계정 매칭에도 쓰지 않는다 — 매칭 키는 {@link UserIdentity} 의 provider 식별자뿐이다. + */ + @Column(length = MAX_EMAIL_LENGTH) + private String email; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + private User(String nickname, String email) { + this.nickname = nickname; + this.email = email; + } + + /** + * 표시 이름과 이메일로 사용자를 만든다. 닉네임이 비었으면 기본 표시 이름으로, 길면 컬럼 폭에 맞게 자른다 — 닉네임 + * 하나 때문에 가입 자체가 실패하면 안 된다(provider 가 주는 값이라 우리가 통제하지 못한다). 이메일도 같은 이유로 + * 길이만 맞추고 형식을 강제하지 않는다. + */ + public static User of(String nickname, String email) { + return new User(normalizeNickname(nickname), normalizeEmail(email)); + } + + /** 표시 이름만으로 만든다 — 이메일을 주지 않는 경로(개발 로그인)용. */ + public static User withNickname(String nickname) { + return of(nickname, null); + } + + /** 표시 이름 변경. 정규화 규칙은 생성과 같다. */ + public void rename(String nickname) { + this.nickname = normalizeNickname(nickname); + } + + private static String normalizeNickname(String nickname) { + if (nickname == null || nickname.isBlank()) { + return DEFAULT_NICKNAME; + } + return truncate(nickname.strip(), MAX_NICKNAME_LENGTH); + } + + private static String normalizeEmail(String email) { + if (email == null || email.isBlank()) { + return null; + } + return truncate(email.strip(), MAX_EMAIL_LENGTH); + } + + private static String truncate(String value, int maxLength) { + return value.length() > maxLength ? value.substring(0, maxLength) : value; + } + + @PrePersist + void onCreate() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/offway/core/user/domain/UserErrorCode.java b/src/main/java/com/offway/core/user/domain/UserErrorCode.java new file mode 100644 index 00000000..a43f3388 --- /dev/null +++ b/src/main/java/com/offway/core/user/domain/UserErrorCode.java @@ -0,0 +1,55 @@ +package com.offway.core.user.domain; + +import com.offway.core.common.exception.ErrorCategory; +import com.offway.core.common.exception.ErrorCode; + +/** + * 사용자·인증 관련 에러 사유. + * + *

번호는 append-only — 재사용·재배치하지 않고 결번을 유지한다. + */ +public enum UserErrorCode implements ErrorCode { + + /** provider ID 토큰 검증 실패(서명·만료·issuer/audience 불일치). 재로그인해야 풀린다. */ + INVALID_ID_TOKEN("USER-001", ErrorCategory.UNAUTHORIZED, "로그인 정보를 확인할 수 없습니다. 다시 로그인해 주세요."), + + /** 지원하지 않거나 서버에 설정되지 않은 provider. 클라이언트 입력이라 400. */ + UNSUPPORTED_PROVIDER("USER-002", ErrorCategory.BAD_REQUEST, "지원하지 않는 로그인 방식입니다."), + + /** refresh 토큰이 없거나 만료·폐기됨. 재로그인 유도. */ + INVALID_REFRESH_TOKEN("USER-003", ErrorCategory.UNAUTHORIZED, "로그인이 만료되었습니다. 다시 로그인해 주세요."), + + /** access 토큰이 없거나 무효·만료. 클라이언트는 재발급을 시도해야 한다. */ + INVALID_ACCESS_TOKEN("USER-004", ErrorCategory.UNAUTHORIZED, "로그인이 필요합니다."), + + /** + * provider 의 공개키(JWKS) 조회 실패. "네 토큰이 틀렸다(401)"와 구분해 502 로 내린다 — 전자는 재로그인, + * 후자는 재시도로 클라이언트가 취할 행동이 다르다. + */ + OIDC_PROVIDER_UNAVAILABLE("USER-005", ErrorCategory.EXTERNAL_API, "로그인 서비스에 일시적인 문제가 있습니다. 잠시 후 다시 시도해 주세요."); + + private final String code; + private final ErrorCategory category; + private final String message; + + UserErrorCode(String code, ErrorCategory category, String message) { + this.code = code; + this.category = category; + this.message = message; + } + + @Override + public String code() { + return code; + } + + @Override + public ErrorCategory category() { + return category; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/offway/core/user/domain/UserException.java b/src/main/java/com/offway/core/user/domain/UserException.java new file mode 100644 index 00000000..0366c81c --- /dev/null +++ b/src/main/java/com/offway/core/user/domain/UserException.java @@ -0,0 +1,41 @@ +package com.offway.core.user.domain; + +import com.offway.core.common.exception.BaseException; +import com.offway.core.common.exception.ErrorCode; + +/** 사용자·인증 관련 예외. */ +public final class UserException extends BaseException { + + private UserException(ErrorCode errorCode) { + super(errorCode); + } + + private UserException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + /** provider ID 토큰 검증 실패 — 서명·만료·issuer/audience 불일치. 원인은 로그·cause 로만 남긴다. */ + public static UserException invalidIdToken(Throwable cause) { + return new UserException(UserErrorCode.INVALID_ID_TOKEN, cause); + } + + /** 지원하지 않거나 서버에 audience 가 설정되지 않은 provider. */ + public static UserException unsupportedProvider() { + return new UserException(UserErrorCode.UNSUPPORTED_PROVIDER); + } + + /** refresh 토큰이 없거나 만료·폐기됨. */ + public static UserException invalidRefreshToken() { + return new UserException(UserErrorCode.INVALID_REFRESH_TOKEN); + } + + /** access 토큰이 없거나 무효·만료. */ + public static UserException invalidAccessToken() { + return new UserException(UserErrorCode.INVALID_ACCESS_TOKEN); + } + + /** provider JWKS 조회 실패 — 외부 의존성 장애라 502. */ + public static UserException oidcProviderUnavailable(Throwable cause) { + return new UserException(UserErrorCode.OIDC_PROVIDER_UNAVAILABLE, cause); + } +} diff --git a/src/main/java/com/offway/core/user/domain/UserGuestLink.java b/src/main/java/com/offway/core/user/domain/UserGuestLink.java new file mode 100644 index 00000000..50254c80 --- /dev/null +++ b/src/main/java/com/offway/core/user/domain/UserGuestLink.java @@ -0,0 +1,78 @@ +package com.offway.core.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * 로그인한 사용자와 그 기기의 게스트 키를 잇는 기록(#34). + * + *

왜 필요한가. 코스·연차는 아직 {@code guest_id} 로 묶여 있고 사용자 식별이 그리로 옮겨가지 않았다. + * 그래서 서버는 "이 사용자의 데이터가 무엇인가" 를 스스로 알지 못하고 요청 헤더가 들고 오는 값에 의존한다. + * 그 상태에서는 탈퇴가 헤더 없이 오면 데이터가 주인 없이 남고, 헤더를 바꿔 보내면 남의 것을 지울 수 있다. + * + *

로그인 시점에 한 줄 적어 두면 둘 다 닫히고, 나중에 소유를 {@code user_id} 로 옮길 때 그 backfill 키가 된다. + * + *

한 게스트 키는 한 사용자에게만 붙는다({@code uk_user_guest_link_guest}). 한 기기에서 두 사람이 + * 로그인해도 그 기기의 옛 데이터는 먼저 로그인한 사용자의 것으로 고정한다 — 뒤에 온 사람에게 상속시키면 남의 + * 데이터를 넘기는 셈이라, 안 넘기는 쪽이 낫다. + */ +@Entity +@Table(name = "user_guest_link") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserGuestLink { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JdbcTypeCode(SqlTypes.BINARY) + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "guest_id", nullable = false, length = 64) + private String guestId; + + @Column(name = "linked_at", nullable = false) + private Instant linkedAt; + + private UserGuestLink(UUID userId, String guestId, Instant linkedAt) { + this.userId = Objects.requireNonNull(userId, "사용자 식별자는 필수입니다"); + this.guestId = requireGuestId(guestId); + this.linkedAt = Objects.requireNonNull(linkedAt, "연결 시각은 필수입니다"); + } + + /** + * 기기 하나를 사용자에게 잇는다. + * + *

시각이 입력에서 도출되므로 빌더가 아니라 팩토리다(조립이면 빌더, 계산이면 팩토리). + */ + public static UserGuestLink of(UUID userId, String guestId, Instant now) { + return new UserGuestLink(userId, guestId, now); + } + + /** + * 게스트 키 형식 검증. + * + *

{@code X-Guest-Id: " "} 처럼 빈 헤더는 {@code @RequestHeader} 를 통과해 여기까지 온다. 그대로 적으면 + * 아무 데이터도 안 가리키는 행이 남고, 나중에 그것으로 지울 대상을 찾는 쪽이 빈 값을 만난다. + */ + private static String requireGuestId(String guestId) { + if (guestId == null || guestId.isBlank()) { + throw new IllegalArgumentException("게스트 식별자가 비어 있습니다"); + } + return guestId; + } +} diff --git a/src/main/java/com/offway/core/user/domain/UserIdentity.java b/src/main/java/com/offway/core/user/domain/UserIdentity.java new file mode 100644 index 00000000..5f771636 --- /dev/null +++ b/src/main/java/com/offway/core/user/domain/UserIdentity.java @@ -0,0 +1,76 @@ +package com.offway.core.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UuidGenerator; +import org.hibernate.type.SqlTypes; + +/** + * provider 계정 ↔ 우리 사용자 매핑. + * + *

매칭 키는 ID 토큰의 {@code sub} 뿐이다. 이메일로 매칭하면 안 된다 — Apple Private Relay 는 익명 주소를 주고, + * Kakao 는 이메일 동의를 거부할 수 있어 값이 아예 없을 수 있다. + * + *

{@link User} 와 생명주기를 공유하지만 항상 같이 로드되지는 않아(로그인은 identity → user 단방향 조회가 주 경로) + * JPA 연관관계 대신 raw {@code userId} 로 참조한다. + */ +@Entity +@Table(name = "user_identity") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserIdentity { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @JdbcTypeCode(SqlTypes.BINARY) + @Column(name = "id", columnDefinition = "BINARY(16)") + private UUID id; + + @JdbcTypeCode(SqlTypes.BINARY) + @Column(name = "user_id", nullable = false, columnDefinition = "BINARY(16)") + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private AuthProvider provider; + + @Column(name = "provider_user_id", nullable = false) + private String providerUserId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + private UserIdentity(UUID userId, AuthProvider provider, String providerUserId) { + this.userId = userId; + this.provider = provider; + this.providerUserId = providerUserId; + } + + /** 검증된 provider 신원을 우리 사용자에 연결한다. */ + public static UserIdentity link(UUID userId, AuthProvider provider, String providerUserId) { + Objects.requireNonNull(userId, "사용자 ID는 필수입니다"); + Objects.requireNonNull(provider, "provider 는 필수입니다"); + Objects.requireNonNull(providerUserId, "provider 사용자 ID는 필수입니다"); + if (providerUserId.isBlank()) { + throw new IllegalArgumentException("provider 사용자 ID는 비어 있을 수 없습니다"); + } + return new UserIdentity(userId, provider, providerUserId); + } + + @PrePersist + void onCreate() { + this.createdAt = Instant.now(); + } +} diff --git a/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoIdentityVerifier.java b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoIdentityVerifier.java new file mode 100644 index 00000000..ac78e6af --- /dev/null +++ b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoIdentityVerifier.java @@ -0,0 +1,72 @@ +package com.offway.core.user.infrastructure.kakao; + +import com.offway.core.user.config.AuthProperties; +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.SocialIdentity; +import com.offway.core.user.domain.UserException; +import com.offway.core.user.infrastructure.social.SocialIdentityVerifier; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 카카오 신원 확인 전략 — 액세스 토큰으로 프로필을 조회해 회원번호를 얻는다. + * + *

Apple·Google 과 달리 로그인 경로에 외부 호출이 낀다. 그래서 이 호출은 트랜잭션 밖에서 끝나야 하는데, + * {@code AuthService} 가 검증을 먼저 하고 DB 작업만 {@code UserPersistenceService} 에 위임하는 구조라 그 조건이 이미 + * 지켜진다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoIdentityVerifier implements SocialIdentityVerifier { + + private final AuthProperties authProperties; + private final KakaoProfileClient kakaoProfileClient; + + @Override + public boolean supports(AuthProvider provider) { + return provider == AuthProvider.KAKAO; + } + + @Override + public SocialIdentity verify(AuthProvider provider, String credential) { + // REST API 키가 없으면 카카오 앱이 등록되지 않았다는 뜻이라 어차피 토큰이 우리 것일 수 없다. + // 호출 자체에 키가 들어가지는 않지만, 미설정을 여기서 끊어야 "왜 안 되는지" 가 code 로 전달된다. + if (!authProperties.kakaoConfigured()) { + log.info("REST API 키가 설정되지 않아 카카오 로그인을 받지 않는다"); + throw UserException.unsupportedProvider(); + } + List allowedAppIds = authProperties.audiencesOf(AuthProvider.KAKAO); + // 앱 번호가 없으면 "우리 앱 토큰인가" 를 물을 수가 없다 — 남의 앱 토큰을 받아주느니 provider 를 닫는다. + // Apple·Google 에서 audience 가 비었을 때와 같은 판단이다(NimbusOidcVerifier). + if (allowedAppIds.isEmpty()) { + log.info("앱 번호가 설정되지 않아 카카오 로그인을 받지 않는다"); + throw UserException.unsupportedProvider(); + } + verifyIssuedToUs(credential, allowedAppIds); + return kakaoProfileClient.fetchProfile(credential).toSocialIdentity(); + } + + /** + * 이 액세스 토큰이 우리 카카오 앱에서 발급된 것인지 확인한다. + * + *

프로필 조회만으로는 답할 수 없는 질문이다. {@code /v2/user/me} 는 토큰이 유효하기만 하면 그 주인을 + * 돌려주므로, 이 확인이 없으면 다른 카카오 앱에서 발급된 토큰을 그대로 우리 서버에 던져 그 사용자로 + * 로그인할 수 있다. 남의 앱 토큰을 손에 넣을 수 있는 사람(그 앱 개발자·그 앱과 연동된 서버)이 우리 + * 서비스의 아무 계정이나 가져가는 경로가 된다. + * + *

Apple·Google 에서 {@code aud} 가 막는 것과 정확히 같은 자리다. 그래서 실패도 같은 code 로 내린다 — + * 토큰 자체는 진짜지만 우리 것이 아니라 무효다. + */ + private void verifyIssuedToUs(String credential, List allowedAppIds) { + KakaoTokenInfo tokenInfo = kakaoProfileClient.fetchTokenInfo(credential); + if (tokenInfo.issuedByAnyOf(allowedAppIds)) { + return; + } + // 앱 번호는 비밀이 아니고, 어느 앱에서 온 토큰인지가 이 경고의 전부다. 토큰은 남기지 않는다. + log.warn("다른 카카오 앱에서 발급된 액세스 토큰으로 로그인 시도 appId={}", tokenInfo.appId()); + throw UserException.invalidIdToken(null); + } +} diff --git a/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoProfile.java b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoProfile.java new file mode 100644 index 00000000..b1ca6685 --- /dev/null +++ b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoProfile.java @@ -0,0 +1,18 @@ +package com.offway.core.user.infrastructure.kakao; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.SocialIdentity; + +/** + * 카카오 {@code /v2/user/me} 응답에서 우리가 쓰는 것만 추린 결과. + * + * @param id 카카오 회원번호. provider 안에서 유일하고 변하지 않아 계정 매칭 키로 쓴다 + * @param nickname 프로필 닉네임. 동의를 거부하면 비어 있다 + * @param email 카카오 계정 이메일. 동의를 거부하면 비어 있다 + */ +public record KakaoProfile(String id, String nickname, String email) { + + public SocialIdentity toSocialIdentity() { + return new SocialIdentity(AuthProvider.KAKAO, id, nickname, email); + } +} diff --git a/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoProfileClient.java b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoProfileClient.java new file mode 100644 index 00000000..603d11f5 --- /dev/null +++ b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoProfileClient.java @@ -0,0 +1,30 @@ +package com.offway.core.user.infrastructure.kakao; + +/** + * 카카오 프로필 조회 port. + * + *

Kakao 액세스 토큰에는 신원 정보가 없어, 토큰만으로는 누구인지 알 수 없다. 이 port 가 그 한 번의 외부 호출을 감싼다. + */ +public interface KakaoProfileClient { + + /** + * 액세스 토큰의 주인을 조회한다. + * + * @param accessToken 앱이 카카오 SDK 에서 받아 넘긴 액세스 토큰 + * @throws com.offway.core.user.domain.UserException 토큰이 무효({@code USER-001})거나 카카오를 부르지 못했을 때 + * ({@code USER-005}) + */ + KakaoProfile fetchProfile(String accessToken); + + /** + * 액세스 토큰을 발급한 앱이 어디인지 조회한다. + * + *

프로필 조회로는 답할 수 없는 질문이라 호출이 따로 필요하다. 이 값을 확인하지 않으면 남의 카카오 앱에서 + * 발급된 토큰이 우리 로그인을 통과한다({@link KakaoTokenInfo} 참고). + * + * @param accessToken 앱이 카카오 SDK 에서 받아 넘긴 액세스 토큰 + * @throws com.offway.core.user.domain.UserException 토큰이 무효({@code USER-001})거나 카카오를 부르지 못했을 때 + * ({@code USER-005}) + */ + KakaoTokenInfo fetchTokenInfo(String accessToken); +} diff --git a/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoProfileClientImpl.java b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoProfileClientImpl.java new file mode 100644 index 00000000..8530fead --- /dev/null +++ b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoProfileClientImpl.java @@ -0,0 +1,156 @@ +package com.offway.core.user.infrastructure.kakao; + +import com.offway.core.user.domain.UserException; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * 카카오 프로필 조회 adapter — {@code GET /v2/user/me}, 인증은 {@code Authorization: Bearer <액세스 토큰>}. + * + *

이 호출은 캐시하지 않는다. 캐시를 붙일 수 없는 게 아니라 붙이면 안 된다. 키가 액세스 토큰이라 사용자 수만큼 + * 무한히 늘고(캐시 키 공간 규칙), 무엇보다 신원 확인은 stale 이면 안 된다 — 이미 만료·해지된 토큰을 캐시가 유효하다고 + * 답하면 그게 곧 인증 우회다. 대신 로그인 1회당 호출 1회로 상한이 잡힌다. + * + *

client secret 은 쓰지 않는다. 그 값이 필요한 곳은 인가 코드를 액세스 토큰으로 바꾸는 토큰 엔드포인트 + * ({@code POST /oauth/token}) 하나뿐인데, 그 단계는 앱이 SDK 로 이미 끝냈다. 이 호출은 액세스 토큰만 받는다. + */ +@Slf4j +@Component +class KakaoProfileClientImpl implements KakaoProfileClient { + + private static final String PROFILE_URL = "https://kapi.kakao.com/v2/user/me"; + + /** + * 토큰 정보 조회 — 이 토큰을 어느 앱이 발급했는지를 알려주는 유일한 경로. + * + *

프로필 조회로는 답할 수 없다. {@code /v2/user/me} 는 토큰이 유효하기만 하면 그 주인을 돌려주므로, 그것만 + * 믿으면 다른 카카오 앱의 토큰이 우리 로그인을 통과한다. + */ + private static final String TOKEN_INFO_URL = "https://kapi.kakao.com/v1/user/access_token_info"; + + /** + * 호출 상한. + * + *

실측(2026-08-14, n=12, 인증 거부 경로): p90 27ms · 최대 30ms. 정상 프로필 조회는 카카오 쪽 저장소를 읽으므로 + * 이보다 느리고, 그 분포는 실 토큰이 없어 아직 못 쟀다. 그래서 실측 꼬리에 맞춰 좁히는 대신 여유를 크게 + * 잡았다 — 이 호출이 끊기면 로그인 자체가 실패해 사용자가 앱에 들어오지도 못하므로, 간헐 실패의 대가가 다른 + * 외부 호출(코스 품질 degrade)보다 훨씬 크다. + * + *

앱이 붙어 실 토큰으로 정상 응답 분포를 재면 p99 기준으로 다시 정하고 {@code docs/external-api-inventory.md} + * 에 남긴다. + * + *

호출 하나의 상한이다. 카카오 로그인은 토큰 정보 조회 + 프로필 조회 두 번을 순차로 부르므로 로그인 + * 하나의 최대 대기는 이 값의 두 배다. 둘 다 로그인 1회당 1번으로 상한이 잡혀 있어 팬아웃으로 곱해지지는 않는다. + */ + private static final Duration TIMEOUT = Duration.ofSeconds(3); + + private static final String ID_FIELD = "id"; + private static final String APP_ID_FIELD = "app_id"; + private static final String ACCOUNT_FIELD = "kakao_account"; + private static final String PROFILE_FIELD = "profile"; + private static final String NICKNAME_FIELD = "nickname"; + private static final String EMAIL_FIELD = "email"; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + + KakaoProfileClientImpl(WebClient externalWebClient, ObjectMapper objectMapper) { + this.webClient = externalWebClient; + this.objectMapper = objectMapper; + } + + @Override + public KakaoProfile fetchProfile(String accessToken) { + return parse(request(PROFILE_URL, accessToken)); + } + + /** + * 토큰을 발급한 앱 번호를 조회한다. + * + *

{@code app_id} 가 없는 200 응답은 성공으로 넘기지 않는다 — 그 값이 없으면 "우리 앱 토큰인가" 에 답할 수 + * 없는데, 없는 것을 통과시키면 검증이 있으나 마나가 된다. + */ + @Override + public KakaoTokenInfo fetchTokenInfo(String accessToken) { + return parseTokenInfo(request(TOKEN_INFO_URL, accessToken)); + } + + private String request(String url, String accessToken) { + try { + return webClient + .get() + .uri(url) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .bodyToMono(String.class) + .timeout(TIMEOUT) + .block(); + } catch (WebClientResponseException.Unauthorized | WebClientResponseException.Forbidden exception) { + // 카카오가 토큰을 거절했다 — 만료·해지·위조. 클라이언트가 가진 토큰의 문제라 401 로 내린다. + // 카카오 응답 본문은 로그·예외 어디에도 싣지 않는다(토큰 조각이 섞여 올 수 있다). + log.info("카카오 액세스 토큰 거부 status={}", exception.getStatusCode().value()); + throw UserException.invalidIdToken(exception); + } catch (Exception exception) { + // 그 밖의 실패(타임아웃·5xx·네트워크)는 재시도로 풀릴 수 있어 502 로 구분한다. + // "네 토큰이 틀렸다"와 "카카오가 안 뜬다"는 앱이 취할 행동이 정반대다. + // 어느 호출이 깨졌는지 알아야 하므로 주소를 남긴다 — 상수라 사용자 입력이 섞이지 않는다. + log.warn("카카오 호출 실패 url={} cause={}", url, exception.getClass().getSimpleName()); + throw UserException.oidcProviderUnavailable(exception); + } + } + + /** + * 응답에서 회원번호·닉네임·이메일을 꺼낸다. + * + *

회원번호가 없으면 성공으로 넘기지 않는다. 200 인데 신원이 없는 응답은 예외보다 위험하다 — 그대로 두면 + * 식별자 없이 가입이 진행되거나 엉뚱한 계정에 붙는데, 로그에는 아무 흔적이 남지 않는다. + */ + private KakaoProfile parse(String body) { + if (body == null || body.isBlank()) { + log.warn("카카오 프로필 응답이 비었다 — 200 이지만 신원을 확인할 수 없다"); + throw UserException.oidcProviderUnavailable(null); + } + JsonNode root = readTree(body); + String id = root.path(ID_FIELD).asString(null); + if (id == null || id.isBlank()) { + log.warn("카카오 프로필 응답에 회원번호가 없다 — 신원을 확인할 수 없다"); + throw UserException.oidcProviderUnavailable(null); + } + JsonNode account = root.path(ACCOUNT_FIELD); + return new KakaoProfile( + id, + account.path(PROFILE_FIELD).path(NICKNAME_FIELD).asString(null), + account.path(EMAIL_FIELD).asString(null)); + } + + private KakaoTokenInfo parseTokenInfo(String body) { + if (body == null || body.isBlank()) { + log.warn("카카오 토큰 정보 응답이 비었다 — 200 이지만 발급 앱을 확인할 수 없다"); + throw UserException.oidcProviderUnavailable(null); + } + JsonNode root = readTree(body); + String appId = root.path(APP_ID_FIELD).asString(null); + if (appId == null || appId.isBlank()) { + log.warn("카카오 토큰 정보 응답에 app_id 가 없다 — 우리 앱 토큰인지 확인할 수 없다"); + throw UserException.oidcProviderUnavailable(null); + } + return new KakaoTokenInfo(root.path(ID_FIELD).asString(null), appId); + } + + private JsonNode readTree(String body) { + try { + return objectMapper.readTree(body); + } catch (JacksonException exception) { + // 응답 본문은 로그에 남기지 않는다 — 파싱에 실패한 문자열에 무엇이 섞여 있는지 알 수 없다. + log.warn("카카오 프로필 응답 파싱 실패 cause={}", exception.getClass().getSimpleName()); + throw UserException.oidcProviderUnavailable(exception); + } + } +} diff --git a/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoTokenInfo.java b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoTokenInfo.java new file mode 100644 index 00000000..f6e27098 --- /dev/null +++ b/src/main/java/com/offway/core/user/infrastructure/kakao/KakaoTokenInfo.java @@ -0,0 +1,22 @@ +package com.offway.core.user.infrastructure.kakao; + +import java.util.List; + +/** + * 카카오 {@code /v1/user/access_token_info} 응답에서 우리가 쓰는 것만 추린 결과. + * + *

이 호출이 있어야 "우리 앱 토큰인가" 에 답할 수 있다. 프로필 조회({@code /v2/user/me})는 토큰의 주인이 + * 누구인지만 알려줄 뿐, 그 토큰을 어느 앱이 발급했는지는 알려주지 않는다. 그래서 프로필 응답만 믿으면 다른 + * 카카오 앱에서 발급된 액세스 토큰을 그대로 우리 서버에 던져 그 사용자로 로그인할 수 있다 — Apple·Google 에서 + * {@code aud} 가 막는 바로 그 자리다. + * + * @param id 카카오 회원번호. 프로필 조회 결과와 교차 확인해 같은 사람인지 본다 + * @param appId 이 토큰을 발급한 카카오 앱의 번호. 우리 앱 번호와 같아야 한다 + */ +public record KakaoTokenInfo(String id, String appId) { + + /** 이 토큰이 주어진 앱 번호 중 하나에서 발급됐는지. */ + public boolean issuedByAnyOf(List allowedAppIds) { + return appId != null && allowedAppIds.contains(appId); + } +} diff --git a/src/main/java/com/offway/core/user/infrastructure/oidc/NimbusOidcVerifier.java b/src/main/java/com/offway/core/user/infrastructure/oidc/NimbusOidcVerifier.java new file mode 100644 index 00000000..25a2f50d --- /dev/null +++ b/src/main/java/com/offway/core/user/infrastructure/oidc/NimbusOidcVerifier.java @@ -0,0 +1,265 @@ +package com.offway.core.user.infrastructure.oidc; + +import com.offway.core.user.config.AuthProperties; +import com.offway.core.common.logging.RootCause; +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.SocialIdentity; +import com.offway.core.user.domain.UserException; +import com.offway.core.user.infrastructure.social.SocialIdentityVerifier; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestOperations; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.DefaultResourceRetriever; +import java.net.MalformedURLException; +import java.net.URI; +import java.time.Duration; +import org.springframework.stereotype.Component; + +/** + * 서명된 ID 토큰(Apple · Google)을 provider 공개키(JWKS)로 검증하는 어댑터. + * + *

요청 경로에 외부 호출이 없다. provider 별 {@link JwtDecoder} 를 만들어 캐시하고, 디코더가 JWKS 를 내부 + * 캐시하므로 매 로그인마다 provider 를 부르지 않는다. 키가 회전됐을 때만(kid 불일치) 다시 가져온다. + * + *

새 의존성이 없다 — 이미 있던 {@code spring-security-oauth2-jose}(Nimbus)가 JWKS 검증을 제공한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class NimbusOidcVerifier implements SocialIdentityVerifier { + + /** JWKS 조회 상한 — 로그인 경로라 오래 물릴 수 없다. 기본값(30초)을 그대로 두지 않는 이유는 jwksClient() 주석에. */ + private static final Duration JWKS_TIMEOUT = Duration.ofSeconds(3); + + /** + * 공개키 캐시 수명. + * + *

10분이다. 백그라운드 갱신은 한도 없는 정적 문서라 사실상 공짜인데(provider 당 5분 주기여도 하루 + * 수백 회), 수명을 늘리면 회전된 키를 모르는 채로 있는 창이 그만큼 길어진다. 그 창에서 정상 + * 토큰이 강제 갱신을 유발하고 rate limit 에 걸려 502 를 받는다. 공짜인 쪽을 아끼고 비싼 쪽을 늘릴 이유가 없다. + */ + private static final Duration JWKS_CACHE_TTL = Duration.ofMinutes(10); + + /** 캐시가 비었을 때 다른 스레드가 적재를 기다릴 상한. 호출 상한(3초)보다 길 이유가 없다. */ + private static final Duration JWKS_CACHE_REFRESH_TIMEOUT = JWKS_TIMEOUT; + + /** 만료 이 시간 전부터 미리 받아 둔다 — 만료 직후 요청이 조회를 뒤집어쓰지 않게. */ + private static final Duration JWKS_REFRESH_AHEAD = Duration.ofMinutes(5); + + /** + * JWKS 응답 크기 상한 — Nimbus 기본값(50KB)을 그대로 둔다. + * + *

2인자 생성자를 쓰면 상한이 사라진다. provider 가 잘못 응답하거나 중간에서 가로챈 응답이 크면 + * 그만큼 메모리로 읽어 들인다. 이 레포는 외부 응답에 상한을 두는 것을 규약으로 삼는다 + * ({@code WebClientConfig} 의 {@code maxInMemorySize}). + */ + private static final int JWKS_MAX_BYTES = 50 * 1024; + + /** + * 강제 갱신 사이 최소 간격 — 위조 토큰이 조회를 유발해도 이 간격을 넘지 못한다. + * + *

10초다. 이 값이 곧 키 회전 직후 정상 토큰이 거절될 수 있는 최악의 시간이다(실측으로 확인했다). + * 30초로 두면 증폭이 분당 4회, 10초면 12회인데 — 요청당 1회였던 것에서 이미 두 자릿수 배 줄어든 뒤라 + * 그 차이는 무의미하다. 반면 지연은 사용자가 그대로 겪는다. 이득이 포화한 쪽을 더 조이지 않는다. + */ + private static final Duration JWKS_MIN_REFRESH_INTERVAL = Duration.ofSeconds(10); + + static { + // Nimbus 는 (선갱신 + 적재 대기) 가 수명을 넘으면 예외를 던지는데, 그 예외는 디코더를 처음 만드는 + // 시점 — 즉 첫 로그인 — 에 터진다. 상수를 잘못 조정하면 부팅은 멀쩡하고 사용자가 500 을 받는다. + // 여기서 먼저 끊어 부팅에서 드러나게 한다. + if (JWKS_REFRESH_AHEAD.plus(JWKS_CACHE_REFRESH_TIMEOUT).compareTo(JWKS_CACHE_TTL) >= 0) { + throw new IllegalStateException( + "JWKS 캐시 상수가 어긋납니다 — 선갱신 + 적재 대기는 수명보다 짧아야 합니다"); + } + } + + + private static final String AUDIENCE_MISMATCH = "audience 가 일치하지 않습니다."; + + private static final String ISSUER_MISMATCH = "issuer 가 일치하지 않습니다."; + + private final AuthProperties authProperties; + private final Map decoders = new ConcurrentHashMap<>(); + + /** 서명된 ID 토큰을 주는 provider 전부를 맡는다 — 검증 절차가 완전히 같다. */ + @Override + public boolean supports(AuthProvider provider) { + return provider.oidc().isPresent(); + } + + @Override + public SocialIdentity verify(AuthProvider provider, String credential) { + AuthProvider.Oidc oidc = + provider.oidc().orElseThrow(() -> new IllegalStateException("서명 검증 대상이 아닌 provider: " + provider)); + List audiences = authProperties.audiencesOf(provider); + // 설정이 비면 audience 검증이 무력화된다 — 남의 앱 토큰을 받아주느니 그 provider 를 닫는다. + if (audiences.isEmpty()) { + log.info("audience 가 설정되지 않은 provider 로그인 시도 provider={}", provider); + throw UserException.unsupportedProvider(); + } + Jwt jwt = decode(provider, oidc, credential, audiences); + return new SocialIdentity(provider, jwt.getSubject(), nicknameOf(oidc, jwt), emailOf(jwt)); + } + + private Jwt decode(AuthProvider provider, AuthProvider.Oidc oidc, String idToken, List audiences) { + try { + return decoders + .computeIfAbsent(provider, key -> buildDecoder(oidc, audiences)) + .decode(idToken); + } catch (BadJwtException exception) { + // 서명 불일치·만료·형식 오류·클레임 검증 실패 — 클라이언트가 가진 토큰의 문제라 401. + // 구체 사유는 남기지 않는다. 토큰 원문은 물론이고 "어디까지 맞았는지"도 공격자에게 줄 이유가 없다. + log.info("ID 토큰 검증 실패 provider={}", provider); + throw UserException.invalidIdToken(exception); + } catch (JwtException exception) { + // JWKS 조회 실패 등 provider 측 문제 — 재시도로 풀릴 수 있으므로 502 로 구분한다. + // + // 사유를 원인 체인에서 꺼내 남긴다. 클래스명만 찍으면 전부 JwtException 이라, 봇이 위조 토큰을 + // 뿌려 rate limit 에 걸린 것과 provider 가 실제로 죽은 것이 로그에서 같아 보인다 — 밤새 쌓인 + // 경고를 보고 "Google 이 죽었다" 로 읽게 된다. + log.warn("provider 공개키 조회 실패 provider={} cause={}", provider, RootCause.label(exception)); + throw UserException.oidcProviderUnavailable(exception); + } + } + + /** + * provider 전용 디코더를 만든다. + * + *

{@code createDefaultWithValidators} 로 감싸 Spring 이 기본으로 거는 검증(토큰 타입 · {@code exp}/{@code nbf} + * · 인증서 thumbprint)을 그대로 살린 채 issuer·audience 검증을 얹는다. 기본 검증을 직접 조립하면 라이브러리가 + * 나중에 추가하는 것을 놓친다. + */ + private static JwtDecoder buildDecoder(AuthProvider.Oidc oidc, List audiences) { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(oidc.jwksUri()) + .restOperations(jwksClient()) + .jwtProcessorCustomizer(processor -> processor.setJWSKeySelector(keySelector(oidc.jwksUri()))) + .build(); + decoder.setJwtValidator(tokenValidator(oidc.issuers(), audiences)); + return decoder; + } + + /** + * 서명을 뺀 나머지 검증 전부 — 기본 검증(토큰 타입·{@code exp}/{@code nbf})에 issuer·audience 를 얹는다. + * + *

서명은 {@link NimbusJwtDecoder} 가 JWKS 로 확인한다. 그 앞단 검증만 여기 모여 있어, 테스트가 네트워크 + * 없이 {@code aud}·{@code iss}·만료를 직접 확인할 수 있다 — 이 셋이 뚫리면 남의 앱 토큰으로 로그인이 된다. + */ + static OAuth2TokenValidator tokenValidator(List issuers, List audiences) { + return JwtValidators.createDefaultWithValidators(issuerValidator(issuers), audienceValidator(audiences)); + } + + /** + * 공개키를 고르는 자리 — 기본 조립을 그대로 쓰지 않는다. + * + *

기본값은 요청이 아는 키를 못 찾으면 그때마다 JWKS 를 다시 받는다. 키 선택은 서명 검증보다 + * 먼저라, 서명이 가짜인 토큰도 그 경로를 탄다. 이 엔드포인트는 인증 없이 열려 있어(로그인 전이니 당연하다) + * 아무나 쓰레기 토큰을 던지면 던진 수만큼 우리가 provider 를 두드리고, 요청마다 톰캣 스레드가 물린다. + * 실측으로 확인했다 — 모르는 {@code kid} 10회에 JWKS 10회, 위조 서명 10회에도 10회. + * + *

세 가지를 함께 건다. + * + *

    + *
  • rate limit — 강제 갱신 사이 최소 간격. 위조 토큰이 조회를 유발해도 이 간격을 넘지 못한다. + *
  • 선갱신 — 만료 전에 미리 받아 둔다. TTL 만 두면 만료 직후 요청이 조회를 뒤집어쓴다. + *
  • 캐시 TTL — 키 회전을 따라갈 만큼 짧게. 공개키는 자주 바뀌지 않지만 회전 자체는 일어난다. + *
+ */ + private static JWSKeySelector keySelector(String jwksUri) { + try { + // 상한을 여기서도 명시한다. create(URL) 만 쓰면 Nimbus 기본 retriever(각 500ms)가 붙어, + // restOperations 에 준 3초가 이 경로에서는 죽은 설정이 된다 — 재측정 없이 8배 좁아지는 셈이다. + DefaultResourceRetriever retriever = new DefaultResourceRetriever( + (int) JWKS_TIMEOUT.toMillis(), (int) JWKS_TIMEOUT.toMillis(), JWKS_MAX_BYTES); + JWKSource source = JWKSourceBuilder.create( + URI.create(jwksUri).toURL(), retriever) + .cache(JWKS_CACHE_TTL.toMillis(), JWKS_CACHE_REFRESH_TIMEOUT.toMillis()) + .refreshAheadCache(JWKS_REFRESH_AHEAD.toMillis(), true) + .rateLimited(JWKS_MIN_REFRESH_INTERVAL.toMillis()) + .build(); + // RS256 하나로 좁혀 둔다. Family.RSA 는 PS 계열까지 열리는데, 두 provider 가 쓰는 것은 + // RS256 이고 받을 알고리즘을 넓히는 것은 검증을 느슨하게 하는 쪽이다. + return new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, source); + } catch (MalformedURLException e) { + // provider 상수라 여기 닿으면 코드 버그다 — 부팅 시점에 드러나는 편이 낫다. + throw new IllegalStateException("JWKS 주소가 올바르지 않습니다 provider 설정을 확인하세요", e); + } + } + + /** + * JWKS 를 받아올 때 쓰는 클라이언트 — 기본값을 그대로 두지 않는다. + * + *

Spring Security 7 의 기본 연결·읽기 timeout 은 30초다. 이 조회는 로그인 요청 경로에서 일어나므로, + * 제공자의 JWKS 엔드포인트가 멎으면 사용자가 30초를 기다린 뒤 실패한다. 그 사이 요청 스레드도 물려 있다. + * + *

3초로 잡는다 — 이 서비스가 외부 호출에 두는 상한과 같다. JWKS 는 정적 문서라 정상이면 수백 ms 에 온다. + * 못 받으면 {@code USER-005}(502)로 끊고, 다음 요청이 다시 시도한다. + */ + private static RestOperations jwksClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(JWKS_TIMEOUT); + factory.setReadTimeout(JWKS_TIMEOUT); + return new RestTemplate(factory); + } + + /** + * {@code iss} 가 이 provider 의 표기 중 하나여야 한다. + * + *

{@code JwtIssuerValidator} 를 쓰지 않는 이유는 그것이 값 하나만 받기 때문이다. Google 이 스킴 있는 표기와 + * 없는 표기를 모두 쓰는데, 하나만 허용하면 다른 표기를 받은 사용자가 전부 401 이 된다. + * + *

{@code getIssuer()}(URL) 가 아니라 클레임 문자열로 비교한다 — 스킴 없는 {@code accounts.google.com} 은 + * URL 로 해석되지 않아 비교 자체가 성립하지 않는다. + */ + private static OAuth2TokenValidator issuerValidator(List issuers) { + return token -> { + // iss 가 없으면 여기서 끝낸다. List.of 로 만든 불변 목록은 contains(null) 에서 NPE 를 던져, + // 클레임을 비운 토큰 하나가 401 이 아니라 500 이 된다. + String issuer = token.getClaimAsString(JwtClaimNames.ISS); + if (issuer != null && issuers.contains(issuer)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", ISSUER_MISMATCH, null)); + }; + } + + /** aud 가 우리 클라이언트 ID 중 하나여야 한다 — 남의 앱용 토큰을 그대로 받아주면 계정 탈취가 된다. */ + private static OAuth2TokenValidator audienceValidator(List audiences) { + return token -> { + List tokenAudiences = token.getAudience(); + if (tokenAudiences != null && tokenAudiences.stream().anyMatch(audiences::contains)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", AUDIENCE_MISMATCH, null)); + }; + } + + private static String nicknameOf(AuthProvider.Oidc oidc, Jwt jwt) { + return oidc.nicknameClaimIfPresent().map(jwt::getClaimAsString).orElse(null); + } + + private static String emailOf(Jwt jwt) { + return jwt.getClaimAsString(AuthProvider.EMAIL_CLAIM); + } +} diff --git a/src/main/java/com/offway/core/user/infrastructure/social/DelegatingSocialIdentityResolver.java b/src/main/java/com/offway/core/user/infrastructure/social/DelegatingSocialIdentityResolver.java new file mode 100644 index 00000000..968476d9 --- /dev/null +++ b/src/main/java/com/offway/core/user/infrastructure/social/DelegatingSocialIdentityResolver.java @@ -0,0 +1,37 @@ +package com.offway.core.user.infrastructure.social; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.SocialIdentity; +import com.offway.core.user.domain.UserException; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 등록된 전략 중 해당 provider 를 맡는 것에 위임한다. + * + *

맡는 전략이 없으면 {@code USER-002} — 지원하지 않는 로그인 방식이다. 여기서 조용히 넘어가면 "왜 로그인이 안 되지" + * 가 로그 없이 끝나므로 사유를 남긴다. + */ +@Slf4j +@Component +public class DelegatingSocialIdentityResolver implements SocialIdentityResolver { + + private final List verifiers; + + public DelegatingSocialIdentityResolver(List verifiers) { + this.verifiers = List.copyOf(verifiers); + } + + @Override + public SocialIdentity resolve(AuthProvider provider, String credential) { + return verifiers.stream() + .filter(verifier -> verifier.supports(provider)) + .findFirst() + .orElseThrow(() -> { + log.info("맡는 검증 전략이 없는 provider 로그인 시도 provider={}", provider); + return UserException.unsupportedProvider(); + }) + .verify(provider, credential); + } +} diff --git a/src/main/java/com/offway/core/user/infrastructure/social/SocialIdentityResolver.java b/src/main/java/com/offway/core/user/infrastructure/social/SocialIdentityResolver.java new file mode 100644 index 00000000..6988a308 --- /dev/null +++ b/src/main/java/com/offway/core/user/infrastructure/social/SocialIdentityResolver.java @@ -0,0 +1,23 @@ +package com.offway.core.user.infrastructure.social; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.SocialIdentity; + +/** + * 앱이 넘긴 provider 토큰으로 신원을 확인하는 port — 서비스가 의존하는 유일한 외부 경계. + * + *

provider 별로 확인 방식이 다르지만({@link SocialIdentityVerifier}) 서비스는 그것을 몰라야 한다. 통합 테스트는 + * 이 port 하나를 stub 으로 갈아끼운다. + */ +public interface SocialIdentityResolver { + + /** + * provider 토큰을 확인하고 신원을 돌려준다. + * + * @param provider 어느 provider 로 로그인하는지 + * @param credential 앱이 provider SDK 에서 받아 넘긴 토큰. Apple·Google 은 ID 토큰(JWT), Kakao 는 액세스 토큰 + * @throws com.offway.core.user.domain.UserException 토큰이 무효({@code USER-001})거나, provider 가 설정되지 + * 않았거나({@code USER-002}), provider 를 부르지 못했을 때({@code USER-005}) + */ + SocialIdentity resolve(AuthProvider provider, String credential); +} diff --git a/src/main/java/com/offway/core/user/infrastructure/social/SocialIdentityVerifier.java b/src/main/java/com/offway/core/user/infrastructure/social/SocialIdentityVerifier.java new file mode 100644 index 00000000..065d5ef0 --- /dev/null +++ b/src/main/java/com/offway/core/user/infrastructure/social/SocialIdentityVerifier.java @@ -0,0 +1,19 @@ +package com.offway.core.user.infrastructure.social; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.SocialIdentity; + +/** + * provider 하나(또는 같은 방식으로 확인되는 묶음)의 신원 확인 전략. + * + *

구현이 자기가 맡는 provider 를 스스로 밝히므로({@link #supports}) 어디에도 provider 분기(switch·if)가 없다. + * 새 provider 는 이 인터페이스 구현을 하나 더 등록하는 것으로 끝난다. + */ +public interface SocialIdentityVerifier { + + /** 이 전략이 맡는 provider 인지. */ + boolean supports(AuthProvider provider); + + /** @see SocialIdentityResolver#resolve(AuthProvider, String) */ + SocialIdentity verify(AuthProvider provider, String credential); +} diff --git a/src/main/java/com/offway/core/user/repository/RefreshTokenJpaRepository.java b/src/main/java/com/offway/core/user/repository/RefreshTokenJpaRepository.java new file mode 100644 index 00000000..993e285d --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/RefreshTokenJpaRepository.java @@ -0,0 +1,40 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.RefreshToken; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** Spring Data JPA — 어댑터가 위임하는 실제 구현. */ +public interface RefreshTokenJpaRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); + + List findByUserIdAndRevokedAtIsNull(UUID userId); + + /** + * 조건부 UPDATE 로 회전 권리를 선점한다 — 살아 있고 만료되지 않은 토큰을 이번 호출이 폐기했을 때만 1을 돌려준다. + * + *

파생 쿼리로는 표현할 수 없어 JPQL 을 직접 쓴다. {@code clearAutomatically} 가 필요한 이유는 이 벌크 UPDATE 가 + * 영속성 컨텍스트를 우회하기 때문이다 — 지우지 않으면 뒤이어 읽는 엔티티가 UPDATE 이전 상태로 나온다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update RefreshToken t set t.revokedAt = :now" + + " where t.tokenHash = :tokenHash and t.revokedAt is null and t.expiresAt > :now") + int claimRotation(@Param("tokenHash") String tokenHash, @Param("now") Instant now); + + /** + * 이 사용자의 살아 있는 토큰을 한 문장으로 폐기한다 — 로그아웃·재사용 감지. + * + *

읽어서 하나씩 고치면 행 수만큼 UPDATE 가 나가고, 바뀌지 않은 {@code token_hash} 까지 다시 써서 + * UNIQUE 인덱스가 함께 갱신된다. 이 표는 삭제 경로가 없어 사용자당 행이 계속 쌓이는 자리라 그 차이가 크다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update RefreshToken t set t.revokedAt = :now where t.userId = :userId and t.revokedAt is null") + int revokeActiveByUserId(@Param("userId") UUID userId, @Param("now") Instant now); +} diff --git a/src/main/java/com/offway/core/user/repository/RefreshTokenRepository.java b/src/main/java/com/offway/core/user/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..bb39ed9e --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/RefreshTokenRepository.java @@ -0,0 +1,35 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.RefreshToken; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** refresh 토큰 영속 port. 구현은 {@link RefreshTokenRepositoryImpl}. */ +public interface RefreshTokenRepository { + + RefreshToken save(RefreshToken refreshToken); + + /** 해시로 찾는다. 폐기된 것도 돌려줘야 재사용(탈취) 감지가 가능하다. */ + Optional findByTokenHash(String tokenHash); + + /** 아직 살아 있는 토큰들 — 로그아웃·재사용 감지 시 일괄 폐기 대상. */ + List findActiveByUserId(UUID userId); + + /** + * 회전 권리를 선점한다 — 살아 있고 만료되지 않은 토큰을 이번 호출이 폐기했을 때만 1. + * + *

읽고 검사하고 폐기하면 그 사이에 다른 요청이 같은 스냅샷을 읽어 둘 다 회전에 성공한다. + * 토큰 하나에서 살아 있는 refresh 가 둘 나오고, 그 순간 재사용 감지의 보장이 무너진다. 판정과 기록을 + * 한 문장으로 합쳐 DB 가 갈라주게 한다({@code ExternalApiCallRepository.claimNotifyStep} 과 같은 방식). + */ + int claimRotation(String tokenHash, Instant now); + + /** + * 이 사용자의 살아 있는 토큰을 전부 폐기한다 — 로그아웃·재사용 감지. + * + * @return 폐기한 행 수 + */ + int revokeActive(UUID userId, Instant now); +} diff --git a/src/main/java/com/offway/core/user/repository/RefreshTokenRepositoryImpl.java b/src/main/java/com/offway/core/user/repository/RefreshTokenRepositoryImpl.java new file mode 100644 index 00000000..fdc52be1 --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/RefreshTokenRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.RefreshToken; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +/** port 구현(adapter) — Spring Data 에 위임. */ +@Repository +@RequiredArgsConstructor +public class RefreshTokenRepositoryImpl implements RefreshTokenRepository { + + private final RefreshTokenJpaRepository refreshTokenJpaRepository; + + @Override + public RefreshToken save(RefreshToken refreshToken) { + return refreshTokenJpaRepository.save(refreshToken); + } + + @Override + public Optional findByTokenHash(String tokenHash) { + return refreshTokenJpaRepository.findByTokenHash(tokenHash); + } + + @Override + public List findActiveByUserId(UUID userId) { + return refreshTokenJpaRepository.findByUserIdAndRevokedAtIsNull(userId); + } + + @Override + public int claimRotation(String tokenHash, Instant now) { + return refreshTokenJpaRepository.claimRotation(tokenHash, now); + } + + @Override + public int revokeActive(UUID userId, Instant now) { + return refreshTokenJpaRepository.revokeActiveByUserId(userId, now); + } +} diff --git a/src/main/java/com/offway/core/user/repository/UserGuestLinkJpaRepository.java b/src/main/java/com/offway/core/user/repository/UserGuestLinkJpaRepository.java new file mode 100644 index 00000000..21c22158 --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/UserGuestLinkJpaRepository.java @@ -0,0 +1,15 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.UserGuestLink; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Spring Data JPA — 어댑터가 위임하는 실제 구현. */ +interface UserGuestLinkJpaRepository extends JpaRepository { + + Optional findByGuestId(String guestId); + + List findByUserId(UUID userId); +} diff --git a/src/main/java/com/offway/core/user/repository/UserGuestLinkRepository.java b/src/main/java/com/offway/core/user/repository/UserGuestLinkRepository.java new file mode 100644 index 00000000..6762ac1d --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/UserGuestLinkRepository.java @@ -0,0 +1,23 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.UserGuestLink; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** 사용자-기기 연결 영속 port(#34). 구현은 {@link UserGuestLinkRepositoryImpl}. */ +public interface UserGuestLinkRepository { + + UserGuestLink save(UserGuestLink link); + + /** + * 이 기기가 이미 누군가에게 붙었는가. + * + *

{@code boolean} 이 아니라 주인을 돌려준다 — "내 것" 과 "남의 것" 은 다르다. 남의 것이면 이 사용자의 + * 데이터가 이어지지 않는다는 뜻이라 흔적을 남겨야 한다. + */ + Optional findByGuestId(String guestId); + + /** 이 사용자에게 붙은 기기들. 탈퇴가 지울 대상을 여기서 얻는다. */ + List findByUserId(UUID userId); +} diff --git a/src/main/java/com/offway/core/user/repository/UserGuestLinkRepositoryImpl.java b/src/main/java/com/offway/core/user/repository/UserGuestLinkRepositoryImpl.java new file mode 100644 index 00000000..f0f9e610 --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/UserGuestLinkRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.UserGuestLink; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UserGuestLinkRepositoryImpl implements UserGuestLinkRepository { + + private final UserGuestLinkJpaRepository userGuestLinkJpaRepository; + + @Override + public UserGuestLink save(UserGuestLink link) { + return userGuestLinkJpaRepository.save(link); + } + + @Override + public Optional findByGuestId(String guestId) { + return userGuestLinkJpaRepository.findByGuestId(guestId); + } + + @Override + public List findByUserId(UUID userId) { + return userGuestLinkJpaRepository.findByUserId(userId); + } +} diff --git a/src/main/java/com/offway/core/user/repository/UserIdentityJpaRepository.java b/src/main/java/com/offway/core/user/repository/UserIdentityJpaRepository.java new file mode 100644 index 00000000..fef22fd8 --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/UserIdentityJpaRepository.java @@ -0,0 +1,13 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.UserIdentity; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Spring Data JPA — 어댑터가 위임하는 실제 구현. */ +public interface UserIdentityJpaRepository extends JpaRepository { + + Optional findByProviderAndProviderUserId(AuthProvider provider, String providerUserId); +} diff --git a/src/main/java/com/offway/core/user/repository/UserIdentityRepository.java b/src/main/java/com/offway/core/user/repository/UserIdentityRepository.java new file mode 100644 index 00000000..3771802b --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/UserIdentityRepository.java @@ -0,0 +1,14 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.UserIdentity; +import java.util.Optional; + +/** provider 신원 매핑 영속 port. 구현은 {@link UserIdentityRepositoryImpl}. */ +public interface UserIdentityRepository { + + UserIdentity save(UserIdentity identity); + + /** provider + sub 로 기존 연결을 찾는다. 이메일이 아니라 sub 이 매칭 키다. */ + Optional findByProviderAndSubject(AuthProvider provider, String subject); +} diff --git a/src/main/java/com/offway/core/user/repository/UserIdentityRepositoryImpl.java b/src/main/java/com/offway/core/user/repository/UserIdentityRepositoryImpl.java new file mode 100644 index 00000000..54a4f532 --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/UserIdentityRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.UserIdentity; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +/** port 구현(adapter) — Spring Data 에 위임. */ +@Repository +@RequiredArgsConstructor +public class UserIdentityRepositoryImpl implements UserIdentityRepository { + + private final UserIdentityJpaRepository userIdentityJpaRepository; + + @Override + public UserIdentity save(UserIdentity identity) { + return userIdentityJpaRepository.save(identity); + } + + @Override + public Optional findByProviderAndSubject(AuthProvider provider, String subject) { + return userIdentityJpaRepository.findByProviderAndProviderUserId(provider, subject); + } +} diff --git a/src/main/java/com/offway/core/user/repository/UserJpaRepository.java b/src/main/java/com/offway/core/user/repository/UserJpaRepository.java new file mode 100644 index 00000000..4b3f11d7 --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/UserJpaRepository.java @@ -0,0 +1,8 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.User; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Spring Data JPA — 어댑터가 위임하는 실제 구현. */ +public interface UserJpaRepository extends JpaRepository {} diff --git a/src/main/java/com/offway/core/user/repository/UserRepository.java b/src/main/java/com/offway/core/user/repository/UserRepository.java new file mode 100644 index 00000000..c06c46c9 --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.User; +import java.util.Optional; +import java.util.UUID; + +/** 사용자 영속 port. 구현은 {@link UserRepositoryImpl}. */ +public interface UserRepository { + + User save(User user); + + Optional findById(UUID id); +} diff --git a/src/main/java/com/offway/core/user/repository/UserRepositoryImpl.java b/src/main/java/com/offway/core/user/repository/UserRepositoryImpl.java new file mode 100644 index 00000000..14992591 --- /dev/null +++ b/src/main/java/com/offway/core/user/repository/UserRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.offway.core.user.repository; + +import com.offway.core.user.domain.User; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +/** port 구현(adapter) — Spring Data 에 위임. */ +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findById(UUID id) { + return userJpaRepository.findById(id); + } +} diff --git a/src/main/java/com/offway/core/user/service/AuthService.java b/src/main/java/com/offway/core/user/service/AuthService.java new file mode 100644 index 00000000..58d5fd69 --- /dev/null +++ b/src/main/java/com/offway/core/user/service/AuthService.java @@ -0,0 +1,178 @@ +package com.offway.core.user.service; + +import com.offway.core.common.logging.RootCause; +import com.offway.core.leave.domain.LeaveBalance; +import com.offway.core.user.domain.SocialIdentity; +import com.offway.core.user.domain.UserException; +import com.offway.core.user.infrastructure.social.SocialIdentityResolver; +import com.offway.core.user.service.dto.AuthenticatedUser; +import com.offway.core.user.service.dto.IssuedToken; +import com.offway.core.user.service.dto.SocialLoginCommand; +import com.offway.core.user.service.dto.TokenRotation; +import java.time.Instant; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 로그인·재발급·로그아웃 조율. + * + *

provider 신원 확인(Kakao 프로필 조회 · JWKS 갱신)은 외부 호출이라 트랜잭션 밖에서 끝내고, DB 작업만 + * {@link UserPersistenceService} 에 위임한다. 그래서 이 클래스에는 {@code @Transactional} 이 없다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final SocialIdentityResolver socialIdentityResolver; + private final UserPersistenceService userPersistenceService; + private final TokenIssuer tokenIssuer; + + /** + * provider 토큰으로 신원을 확인해 로그인시킨다. 처음 보는 신원이면 그대로 가입 처리된다. + * + *

확인(외부 호출일 수 있다)을 먼저 끝내고 DB 작업만 위임하는 순서를 지킨다. Kakao 는 프로필 조회가 끼는데, + * 그것이 트랜잭션 안에 들어가면 read-timeout 동안 DB 커넥션을 잡아 풀이 마른다. + */ + public IssuedToken login(SocialLoginCommand command) { + SocialIdentity identity = socialIdentityResolver.resolve(command.provider(), command.credential()); + AuthenticatedUser user = findOrCreateUser(identity, command); + linkDevice(user.userId(), command.guestId()); + return issueTokens(user); + } + + /** + * refresh 토큰을 회전시켜 새 토큰 쌍을 발급한다. + * + *

이미 회전된 토큰이 다시 오면 탈취로 보고 이 사용자의 토큰을 전부 끊는다. 폐기는 회전 트랜잭션과 분리해야 한다 — + * 같은 트랜잭션에서 폐기하고 예외를 던지면 그 폐기까지 롤백돼 탈취된 토큰이 살아남는다. + */ + public IssuedToken reissue(String refreshToken) { + Instant now = Instant.now(); + String nextRefreshToken = tokenIssuer.generateRefreshToken(); + TokenRotation rotation = userPersistenceService.rotateRefreshToken( + tokenIssuer.hashRefreshToken(refreshToken), + tokenIssuer.hashRefreshToken(nextRefreshToken), + tokenIssuer.refreshTokenExpiry(now), + now); + return switch (rotation) { + case TokenRotation.Rotated(UUID userId) -> new IssuedToken( + tokenIssuer.issueAccessToken(userId), + nextRefreshToken, + tokenIssuer.accessTokenSeconds(), + false); + case TokenRotation.Reused(UUID userId) -> { + log.warn("폐기된 refresh 토큰 재사용 — 사용자 토큰 전체 폐기 userId={}", userId); + userPersistenceService.revokeAllRefreshTokens(userId, now); + throw UserException.invalidRefreshToken(); + } + case TokenRotation.Raced ignored -> { + // 세션을 끊지 않는다 — 이긴 요청이 방금 받아 간 정상 토큰까지 죽으면 사용자가 멀쩡한 토큰을 + // 들고 로그아웃된다. 이 요청만 거절하고 클라이언트가 새 토큰으로 다시 오게 둔다. + log.info("회전 직후 같은 refresh 가 다시 왔습니다 — 재시도로 보고 이 요청만 거절합니다"); + throw UserException.invalidRefreshToken(); + } + case TokenRotation.Invalid ignored -> throw UserException.invalidRefreshToken(); + }; + } + + public void logout(UUID userId) { + // Basic 으로 들어온 요청은 principal 이 UUID 가 아니라 null 로 온다(@LoginUser 가 JWT 가 넣은 것만 푼다). + // 그대로 두면 폐기할 대상이 없는데 200 이 나가, 클라이언트는 로그아웃됐다고 믿고 토큰은 살아 있다 — + // 규약이 막는 '조용한 실패' 다. 애초에 Basic 은 앱의 로그인 수단이 아니므로 401 로 끊는다. + if (userId == null) { + log.info("로그아웃 요청에 사용자 식별자가 없습니다 — Bearer 로 온 요청이 아닙니다"); + throw UserException.invalidAccessToken(); + } + userPersistenceService.revokeAllRefreshTokens(userId, Instant.now()); + } + + /** + * local 전용 개발 로그인 — provider 검증 없이 사용자를 만들고 토큰을 발급한다. + * + *

호출자는 {@code DevAuthController}({@code @Profile("local")}) 뿐이다. prod 에는 그 빈이 존재하지 않아 + * 경로 자체가 열리지 않는다. + */ + public IssuedToken devLogin(String nickname) { + return issueTokens(new AuthenticatedUser(userPersistenceService.createUser(nickname), true)); + } + + private IssuedToken issueTokens(AuthenticatedUser user) { + Instant now = Instant.now(); + String refreshToken = tokenIssuer.generateRefreshToken(); + userPersistenceService.saveRefreshToken( + user.userId(), tokenIssuer.hashRefreshToken(refreshToken), tokenIssuer.refreshTokenExpiry(now)); + return new IssuedToken( + tokenIssuer.issueAccessToken(user.userId()), + refreshToken, + tokenIssuer.accessTokenSeconds(), + user.newUser()); + } + + /** + * 이 기기를 사용자에게 이어 둔다(#34) — 실패해도 로그인을 막지 않는다. + * + *

코스·연차가 아직 {@code guest_id} 로 묶여 있어, 서버가 "이 사용자의 데이터가 무엇인가" 를 알 수 있는 + * 유일한 근거다. 탈퇴가 이것으로 대상을 찾는다. 다만 기록은 나중을 위한 것이지 로그인의 조건이 아니다 — + * 여기서 터지면 계정은 만들어졌는데 토큰을 못 받아, 재시도해도 같은 자리에서 계속 실패하는 락아웃이 된다. + * + *

그래서 두 가지를 트랜잭션 에서 처리한다. + * + *

    + *
  • 형식 — 헤더는 아무나 아무 값이나 보낼 수 있다. 길이를 넘기면 DB 가 잘라내며 터지는데, + * 그 값으로는 코스·연차도 저장되지 않으므로 이어 둘 이유 자체가 없다. 넘어가고 흔적만 남긴다. + *
  • 경합 — 유니크 제약 위반은 그 트랜잭션을 rollback-only 로 만들어 안에서 삼켜도 소용없다. + * 커밋에서 {@code UnexpectedRollbackException} 으로 끝난다. 밖에서 잡아야 한다. + *
+ */ + private void linkDevice(UUID userId, String guestId) { + if (guestId == null || guestId.isBlank()) { + log.info("기기 식별자 없이 로그인했습니다 — 이 사용자의 코스·연차는 이어지지 않습니다 userId={}", userId); + return; + } + if (guestId.length() > LeaveBalance.MAX_OWNER_ID_LENGTH) { + log.warn("기기 식별자가 너무 깁니다 — 이어 두지 않습니다 userId={} length={}", userId, guestId.length()); + return; + } + try { + userPersistenceService.linkGuest(userId, guestId, Instant.now()); + } catch (RuntimeException e) { + // **좁게 잡지 않는다.** 제약 위반은 그 트랜잭션을 rollback-only 로 만들어, 커밋 시점에 + // DataIntegrityViolationException 이 아니라 UnexpectedRollbackException 으로 올라온다. + // 타입을 골라 잡으면 그중 하나가 새어 나가 로그인 전체가 500 이 되고, 계정은 만들어졌는데 + // 토큰을 못 받아 재시도해도 같은 자리에서 실패하는 락아웃이 된다. + // + // 무엇으로 실패했는지는 남긴다 — 경합이면 정상이고(먼저 넣은 쪽이 이겼다) 그 밖이면 봐야 한다. + log.warn("기기를 잇지 못했습니다 — 로그인은 계속합니다 userId={} cause={}", userId, RootCause.label(e)); + } + } + + /** + * 신원으로 사용자를 찾거나 만든다 — 동시 가입을 흡수한다. + * + *

같은 계정으로 동시에 로그인하면(로그인 버튼 더블탭이면 그대로 일어난다) 두 요청이 모두 "없다" 를 + * 읽고 둘 다 만들려 해서 하나가 {@code uk_user_identity_provider} 에 걸린다. 그대로 두면 진 쪽이 500 을 + * 받는데, 사용자가 원한 상태(계정이 하나 있다)는 이미 이뤄져 있다. + * + *

먼저 만든 쪽의 사용자를 다시 읽어 돌려준다. 그때 {@code isNewUser} 는 false 다 — 이 요청이 + * 만든 것이 아니고, 진 쪽에도 true 를 주면 앱이 온보딩을 두 번 띄운다. + * + *

다시 읽어도 없으면 중복이 아닌 다른 제약 위반이다. 그건 삼키지 않는다 — 확인 없이 넘기면 계정이 + * 없는데 로그인에 성공한 것처럼 보인다. + */ + private AuthenticatedUser findOrCreateUser(SocialIdentity identity, SocialLoginCommand command) { + try { + return userPersistenceService.findOrCreateUser(identity, command.nickname(), command.email()); + } catch (RuntimeException e) { + return userPersistenceService + .findExistingUser(identity) + .map(existing -> { + log.info("가입 경합 — 먼저 만들어진 계정을 그대로 씁니다 provider={}", identity.provider()); + return existing; + }) + .orElseThrow(() -> e); + } + } +} diff --git a/src/main/java/com/offway/core/user/service/TokenIssuer.java b/src/main/java/com/offway/core/user/service/TokenIssuer.java new file mode 100644 index 00000000..712af524 --- /dev/null +++ b/src/main/java/com/offway/core/user/service/TokenIssuer.java @@ -0,0 +1,129 @@ +package com.offway.core.user.service; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.offway.core.user.config.AuthProperties; +import com.offway.core.user.domain.UserException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; +import java.util.UUID; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.stereotype.Component; + +/** + * 자체 토큰 발급·검증. access 는 HS256 서명 JWT(무상태), refresh 는 난수 문자열이고 DB 에는 해시만 남는다. + * + *

서명키는 최소 32바이트여야 한다(HS256). local 은 개발용 고정값을 쓰고 prod 는 환경변수로 주입한다 — 키 없이 뜨면 + * 아무 토큰이나 위조 가능해지므로 부팅 단계에서 막는다. + */ +@Component +public class TokenIssuer { + + /** 자체 토큰의 issuer — provider 토큰과 섞이지 않게 구분한다. */ + private static final String ISSUER = "offway"; + + /** HS256 최소 키 길이(바이트). */ + private static final int MIN_SECRET_BYTES = 32; + + /** refresh 토큰 난수 길이(바이트). */ + private static final int REFRESH_TOKEN_BYTES = 32; + + private static final String HASH_ALGORITHM = "SHA-256"; + + private final JwtEncoder encoder; + private final JwtDecoder decoder; + private final Duration accessTtl; + private final Duration refreshTtl; + private final SecureRandom secureRandom = new SecureRandom(); + + public TokenIssuer(AuthProperties authProperties) { + SecretKey key = secretKey(authProperties.jwt().secret()); + this.encoder = new NimbusJwtEncoder(new ImmutableSecret<>(key)); + this.decoder = NimbusJwtDecoder.withSecretKey(key) + .macAlgorithm(MacAlgorithm.HS256) + .build(); + this.accessTtl = authProperties.jwt().accessTtl(); + this.refreshTtl = authProperties.jwt().refreshTtl(); + } + + /** 사용자 식별자를 subject 로 하는 access 토큰. */ + public String issueAccessToken(UUID userId) { + Instant now = Instant.now(); + JwtClaimsSet claims = JwtClaimsSet.builder() + .issuer(ISSUER) + .subject(userId.toString()) + .issuedAt(now) + .expiresAt(now.plus(accessTtl)) + .build(); + return encoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)) + .getTokenValue(); + } + + /** + * access 토큰에서 사용자 식별자를 꺼낸다. 서명·만료·형식 중 하나라도 어긋나면 {@code USER-004}. + * + *

구체 사유는 응답에 담지 않는다 — 공격자에게 어디까지 맞췄는지 알려줄 이유가 없다. + */ + public UUID parseAccessToken(String accessToken) { + try { + Jwt jwt = decoder.decode(accessToken); + return UUID.fromString(jwt.getSubject()); + } catch (JwtException | IllegalArgumentException | NullPointerException exception) { + throw UserException.invalidAccessToken(); + } + } + + /** refresh 토큰 원문. 클라이언트에만 나가고 서버는 해시만 보관한다. */ + public String generateRefreshToken() { + byte[] bytes = new byte[REFRESH_TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** refresh 토큰 원문 → 저장·조회용 SHA-256 hex(64자). */ + public String hashRefreshToken(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance(HASH_ALGORITHM); + return HexFormat.of().formatHex(digest.digest(rawToken.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 을 사용할 수 없습니다.", exception); + } + } + + public Instant refreshTokenExpiry(Instant from) { + return from.plus(refreshTtl); + } + + public long accessTokenSeconds() { + return accessTtl.toSeconds(); + } + + private static SecretKey secretKey(String secret) { + if (secret == null || secret.isBlank()) { + throw new IllegalStateException( + "offway.auth.jwt.secret 이 비어 있습니다. prod 는 환경변수로 주입해야 합니다(local 은 기본값 제공)."); + } + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < MIN_SECRET_BYTES) { + throw new IllegalStateException( + "offway.auth.jwt.secret 은 최소 " + MIN_SECRET_BYTES + "바이트여야 합니다: " + bytes.length); + } + return new SecretKeySpec(bytes, "HmacSHA256"); + } +} diff --git a/src/main/java/com/offway/core/user/service/UserPersistenceService.java b/src/main/java/com/offway/core/user/service/UserPersistenceService.java new file mode 100644 index 00000000..3f969541 --- /dev/null +++ b/src/main/java/com/offway/core/user/service/UserPersistenceService.java @@ -0,0 +1,171 @@ +package com.offway.core.user.service; + +import com.offway.core.user.domain.SocialIdentity; +import com.offway.core.user.domain.RefreshToken; +import com.offway.core.user.domain.User; +import com.offway.core.user.domain.UserIdentity; +import com.offway.core.user.domain.UserGuestLink; +import com.offway.core.user.repository.RefreshTokenRepository; +import com.offway.core.user.repository.UserGuestLinkRepository; +import com.offway.core.user.repository.UserIdentityRepository; +import com.offway.core.user.repository.UserRepository; +import com.offway.core.user.service.dto.AuthenticatedUser; +import com.offway.core.user.service.dto.TokenRotation; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 인증의 영속 경계. + * + *

{@link AuthService} 가 외부 호출(provider JWKS)을 트랜잭션 밖에서 끝낸 뒤 DB 작업만 이 빈에 위임한다 — + * 외부 read-timeout 이 DB 커넥션을 오래 잡지 않게(persistence-convention). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserPersistenceService { + + private final UserRepository userRepository; + private final UserIdentityRepository userIdentityRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final UserGuestLinkRepository userGuestLinkRepository; + + /** + * 검증된 provider 신원으로 사용자를 찾거나 만든다. 최초 로그인이 곧 가입이다. + * + *

가입이었는지를 {@link AuthenticatedUser#newUser()} 로 함께 돌려준다 — 앱이 온보딩과 홈을 가르는 값이라, + * 만든 그 자리에서만 정확히 알 수 있다. + */ + @Transactional + public AuthenticatedUser findOrCreateUser(SocialIdentity identity, String requestedNickname, String requestedEmail) { + return userIdentityRepository + .findByProviderAndSubject(identity.provider(), identity.providerUserId()) + .map(found -> new AuthenticatedUser(found.getUserId(), false)) + .orElseGet(() -> new AuthenticatedUser(register(identity, requestedNickname, requestedEmail), true)); + } + + /** + * 이미 있는 신원만 찾는다 — 만들지 않는다. + * + *

{@link #findOrCreateUser} 가 유니크 제약에 걸렸을 때 먼저 만든 쪽의 사용자를 다시 읽으려고 있다. + * 제약 위반은 그 트랜잭션을 rollback-only 로 만들어 같은 트랜잭션에서는 다시 읽을 수 없다 — 별도 빈의 + * 새 트랜잭션이어야 한다({@code CoursePersistenceService.findShare} 와 같은 이유). + */ + @Transactional(readOnly = true) + public Optional findExistingUser(SocialIdentity identity) { + return userIdentityRepository + .findByProviderAndSubject(identity.provider(), identity.providerUserId()) + .map(found -> new AuthenticatedUser(found.getUserId(), false)); + } + + /** local 개발 로그인용 — provider 연결 없이 사용자만 만든다. */ + @Transactional + public UUID createUser(String nickname) { + return userRepository.save(User.withNickname(nickname)).getId(); + } + + @Transactional + public void saveRefreshToken(UUID userId, String tokenHash, Instant expiresAt) { + refreshTokenRepository.save(RefreshToken.issue(userId, tokenHash, expiresAt)); + } + + /** + * refresh 토큰 회전을 시도하고 결과를 돌려준다. + * + *

실패를 예외가 아니라 {@link TokenRotation} 으로 돌려준다. 재사용 감지 시 해야 할 "사용자 토큰 전체 폐기"를 + * 이 트랜잭션 안에서 하고 예외를 던지면 그 폐기까지 롤백돼, 탈취된 토큰이 그대로 살아남기 때문이다. 후속 조치는 + * 호출자가 별도 트랜잭션으로 끝낸다. + */ + @Transactional + public TokenRotation rotateRefreshToken(String currentHash, String nextHash, Instant nextExpiry, Instant now) { + // 판정과 폐기를 한 문장으로 합친다. 읽고 검사하고 폐기하면 그 사이에 다른 요청이 같은 스냅샷을 읽어 + // 둘 다 회전에 성공한다 — 토큰 하나에서 살아 있는 refresh 가 둘 나오고 재사용 감지가 무의미해진다. + boolean won = refreshTokenRepository.claimRotation(currentHash, now) > 0; + Optional found = refreshTokenRepository.findByTokenHash(currentHash); + if (found.isEmpty()) { + return new TokenRotation.Invalid(); + } + RefreshToken current = found.get(); + if (won) { + refreshTokenRepository.save(RefreshToken.issue(current.getUserId(), nextHash, nextExpiry)); + return new TokenRotation.Rotated(current.getUserId()); + } + if (current.isExpired(now)) { + return new TokenRotation.Invalid(); + } + // 선점에 졌다. 방금 회전된 것이면 정상 앱의 재시도·동시 요청이고, 오래전에 폐기된 것이면 탈취 정황이다. + return current.revokedWithin(RefreshToken.ROTATION_GRACE, now) + ? new TokenRotation.Raced() + : new TokenRotation.Reused(current.getUserId()); + } + + /** 로그아웃 — 살아 있는 refresh 를 모두 폐기한다. access 는 만료까지 유효하다(무상태 JWT 의 대가). */ + @Transactional + public void revokeAllRefreshTokens(UUID userId, Instant now) { + revokeActive(userId, now); + } + + /** + * 새 사용자와 provider 신원을 함께 만든다. + * + *

닉네임·이메일은 요청 값 → provider 가 확인해 준 값 순으로 채운다. Apple 은 ID 토큰에 이름을 담지 않고 + * 최초 인증 응답에만 주므로, 그 경로에서는 요청 값이 유일한 출처다. 반대로 Kakao 는 프로필 조회로 우리가 직접 + * 받으므로 요청 값이 없어도 채워진다. + * + *

표시용 값이라 요청 값을 먼저 쓰는 것이 안전하다 — 신원(누구인가)은 요청 값을 절대 믿지 않지만 + * ({@link SocialIdentity}), 표시 이름은 틀려도 사용자가 고칠 수 있는 정보다. + */ + private UUID register(SocialIdentity identity, String requestedNickname, String requestedEmail) { + String nickname = firstPresent(requestedNickname, identity.nicknameIfPresent().orElse(null)); + String email = firstPresent(requestedEmail, identity.emailIfPresent().orElse(null)); + User user = userRepository.save(User.of(nickname, email)); + userIdentityRepository.save(UserIdentity.link(user.getId(), identity.provider(), identity.providerUserId())); + // 이메일·닉네임은 개인정보라 로그에 남기지 않는다. 어느 provider 로 몇 명이 들어오는지만 남긴다. + log.info("신규 가입 provider={} userId={}", identity.provider(), user.getId()); + return user.getId(); + } + + private static String firstPresent(String preferred, String fallback) { + return preferred != null && !preferred.isBlank() ? preferred : fallback; + } + + /** 트랜잭션 안에서만 호출된다 — 관리 상태 엔티티라 dirty checking 으로 반영된다. self-invocation 을 피하려 private. */ + private void revokeActive(UUID userId, Instant now) { + // 읽어서 하나씩 고치면 행 수만큼 UPDATE 가 나간다. 이 표는 삭제 경로가 없어 계속 쌓이는 자리다. + refreshTokenRepository.revokeActive(userId, now); + } + + /** + * 이 기기를 사용자에게 잇는다(#34) — 이미 누군가의 것이면 그대로 둔다. + * + *

코스·연차가 아직 {@code guest_id} 로 묶여 있어, 서버가 "이 사용자의 데이터가 무엇인가" 를 알 수 있는 + * 유일한 근거다. 탈퇴가 이것으로 대상을 찾고, 나중에 소유를 옮길 때 backfill 키가 된다. + * + *

덮어쓰지 않는다. 한 기기에서 두 사람이 로그인하면 그 기기의 옛 데이터는 먼저 로그인한 사용자의 + * 것이다. 뒤에 온 사람에게 넘기면 남의 코스·연차를 넘기는 셈이라, 안 넘기는 쪽이 낫다. + * + *

실패해도 로그인을 막지 않는다. 이 기록은 나중을 위한 것이지 로그인의 조건이 아니다. 다만 조용히 + * 넘어가지는 않는다 — 없으면 그 사용자의 탈퇴가 데이터를 못 찾으므로 사유를 warn 으로 남긴다. + */ + @Transactional + public void linkGuest(UUID userId, String guestId, Instant now) { + Optional existing = userGuestLinkRepository.findByGuestId(guestId); + if (existing.isPresent()) { + if (!existing.get().getUserId().equals(userId)) { + // 한 기기를 두 사람이 썼다. 먼저 로그인한 쪽의 것으로 두고, 이 사용자의 코스·연차는 이어지지 + // 않는다 — 조용히 넘어가면 나중에 "왜 이 계정만 탈퇴해도 데이터가 남나" 를 추적할 수 없다. + log.warn("이미 다른 사용자에게 이어진 기기입니다 — 이 사용자의 데이터는 이어지지 않습니다 userId={}", userId); + } + return; + } + // 여기서 잡지 않는다. 제약 위반은 이 트랜잭션을 rollback-only 로 만들어, 삼켜도 커밋에서 + // UnexpectedRollbackException 으로 끝난다. 경합 처리는 트랜잭션 밖(호출자)이 한다. + userGuestLinkRepository.save(UserGuestLink.of(userId, guestId, now)); + } +} diff --git a/src/main/java/com/offway/core/user/service/dto/AuthenticatedUser.java b/src/main/java/com/offway/core/user/service/dto/AuthenticatedUser.java new file mode 100644 index 00000000..90ffe585 --- /dev/null +++ b/src/main/java/com/offway/core/user/service/dto/AuthenticatedUser.java @@ -0,0 +1,15 @@ +package com.offway.core.user.service.dto; + +import java.util.UUID; + +/** + * 로그인이 확정한 사용자(내부용). + * + *

{@code newUser} 는 이 로그인이 가입이었는지다. 앱은 이 값으로 온보딩(잔여 연차 입력)과 홈을 가른다. + * "가입 시각이 방금인가" 같은 시간 비교로 나중에 되묻지 않는다 — 그 방식은 경계값에서 흔들리고, 재로그인이 느린 날 + * 기존 사용자를 온보딩으로 보낸다. 판정은 신원을 새로 만든 그 자리에서만 할 수 있다. + * + * @param userId 우리 서비스의 사용자 식별자 + * @param newUser 이 요청으로 새 사용자가 만들어졌으면 {@code true} + */ +public record AuthenticatedUser(UUID userId, boolean newUser) {} diff --git a/src/main/java/com/offway/core/user/service/dto/IssuedToken.java b/src/main/java/com/offway/core/user/service/dto/IssuedToken.java new file mode 100644 index 00000000..e690b809 --- /dev/null +++ b/src/main/java/com/offway/core/user/service/dto/IssuedToken.java @@ -0,0 +1,11 @@ +package com.offway.core.user.service.dto; + +/** + * 발급된 토큰 쌍(내부용). + * + * @param accessToken 요청 인증용 JWT + * @param refreshToken 재발급용 원문. 서버에는 해시만 남는다 + * @param expiresInSeconds access 토큰 잔여 수명(초) + * @param newUser 이 발급이 가입이었는지. 재발급·개발 로그인에서는 의미가 없어 {@code false} + */ +public record IssuedToken(String accessToken, String refreshToken, long expiresInSeconds, boolean newUser) {} diff --git a/src/main/java/com/offway/core/user/service/dto/SocialLoginCommand.java b/src/main/java/com/offway/core/user/service/dto/SocialLoginCommand.java new file mode 100644 index 00000000..2b53cd59 --- /dev/null +++ b/src/main/java/com/offway/core/user/service/dto/SocialLoginCommand.java @@ -0,0 +1,17 @@ +package com.offway.core.user.service.dto; + +import com.offway.core.user.domain.AuthProvider; + +/** + * 소셜 로그인 커맨드(내부용). + * + * @param provider 어느 provider 로 로그인하는지 + * @param credential 앱이 provider SDK 에서 받아 넘긴 토큰. Apple·Google 은 ID 토큰(JWT), Kakao 는 액세스 토큰 + * @param nickname 앱이 함께 넘긴 표시 이름. Apple 은 최초 인증 응답에만 이름을 주므로 그때 받아 넘기지 않으면 영영 + * 얻을 수 없다. 없을 수 있다 + * @param email 앱이 함께 넘긴 이메일. 위와 같은 이유로 Apple 최초 로그인에서만 온다. 없을 수 있다 + * @param guestId 이 기기의 게스트 키(#34). 코스·연차가 아직 이 값으로 묶여 있어, 로그인할 때 사용자에게 이어 둔다. + * 헤더를 안 보내는 클라이언트도 있으므로 없을 수 있다 + */ +public record SocialLoginCommand( + AuthProvider provider, String credential, String nickname, String email, String guestId) {} diff --git a/src/main/java/com/offway/core/user/service/dto/TokenRotation.java b/src/main/java/com/offway/core/user/service/dto/TokenRotation.java new file mode 100644 index 00000000..82e75fdb --- /dev/null +++ b/src/main/java/com/offway/core/user/service/dto/TokenRotation.java @@ -0,0 +1,31 @@ +package com.offway.core.user.service.dto; + +import java.util.UUID; + +/** + * refresh 회전 시도의 결과(내부용). + * + *

영속 계층이 예외를 던지지 않고 결과로 돌려주는 이유가 있다. 재사용(탈취) 감지 시 해야 할 "사용자 토큰 전체 폐기"를 + * 같은 트랜잭션 안에서 하고 예외를 던지면, 그 폐기까지 함께 롤백돼 탈취된 토큰이 그대로 살아남는다. 결과를 받은 조율 계층이 + * 폐기를 별도 트랜잭션으로 끝낸 뒤 예외를 던진다. + */ +public sealed interface TokenRotation { + + /** 정상 회전 — 기존 토큰은 폐기됐고 새 토큰이 저장됐다. */ + record Rotated(UUID userId) implements TokenRotation {} + + /** 이미 폐기된 토큰이 다시 왔다 — 탈취 정황이라 이 사용자의 토큰을 전부 끊어야 한다. */ + record Reused(UUID userId) implements TokenRotation {} + + /** + * 방금 회전된 토큰이 다시 왔다 — 탈취가 아니라 정상 앱의 재시도·동시 요청으로 본다. + * + *

정상 앱도 같은 refresh 를 두 번 쏜다. 401 을 받은 요청 둘이 동시에 재발급을 걸거나, 응답을 못 받고 + * 타임아웃 재시도를 하면 그렇다. 이것을 {@link Reused} 로 다루면 이긴 요청이 방금 받아 간 정상 토큰까지 + * 끊겨 사용자가 멀쩡한 토큰을 들고 로그아웃된다. 요청 자체는 거절하되 세션은 살린다. + */ + record Raced() implements TokenRotation {} + + /** 없는 토큰이거나 만료됨. 끊을 대상이 없다. */ + record Invalid() implements TokenRotation {} +} diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 80b4a924..1c8952af 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -22,6 +22,10 @@ spring.jpa.hibernate.ddl-auto=validate offway.security.basic.username=${OFFWAY_BASIC_USERNAME:dev} offway.security.basic.password=${OFFWAY_BASIC_PASSWORD:{noop}dev} +# 인증 — 개발용 고정 서명키(HS256 최소 32바이트). prod 는 JWT_SECRET 환경변수로 주입한다. +# 이 값이 있어야 시크릿 없이도 로컬 부팅이 된다(로컬 실행성 불변식). 실제 비밀이 아니므로 커밋해도 된다. +offway.auth.jwt.secret=offway-local-development-only-signing-key-do-not-use-in-production + # Flyway (마이그레이션 없어도 no-op으로 부팅) spring.flyway.enabled=true spring.flyway.out-of-order=true diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 444c8816..28908a17 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -32,9 +32,36 @@ offway.external.tmap.app-key=${TMAP_APP_KEY:} # 외부 API 키와 달리 **비면 부팅이 실패한다**. 키가 없으면 그 호출만 죽지만, 계정이 없으면 서버가 # 통째로 열린 채 뜬다 — 조용히 뜨는 쪽이 훨씬 위험하다. local 은 application-local.properties 가 채운다. # 비밀번호는 인코더 접두어를 포함한다: local {noop}평문 · 운영 {bcrypt}$2a$... +# +# 소셜 로그인이 붙은 뒤에도 남긴다 — 걷어내는 조건은 로그인의 존재가 아니라 모든 호출자가 실제 토큰을 +# 들고 오는 것이다(SecurityConfig 주석). offway.security.basic.username=${OFFWAY_BASIC_USERNAME:} offway.security.basic.password=${OFFWAY_BASIC_PASSWORD:} +# ── 인증(ADR 0002) ─────────────────────────────────────────── +# JWT 서명키는 prod 에서 환경변수 필수 — 없으면 아무 토큰이나 위조 가능해지므로 부팅 단계에서 막는다. +# local 은 application-local.properties 가 개발용 고정값을 제공한다(로컬 실행성 규칙). +offway.auth.jwt.secret=${JWT_SECRET:} +offway.auth.jwt.access-ttl=1h +offway.auth.jwt.refresh-ttl=60d + +# provider 별 설정. 비어 있어도 부팅되고, 해당 provider 로그인만 USER-002 로 실패한다(로컬 실행성 규칙). +# +# audience 는 ID 토큰의 aud 가 우리 앱 것인지 확인하는 데 쓴다 — 남의 앱용 토큰을 받아주면 그게 곧 계정 탈취다. +# Google 은 '웹' 클라이언트 ID 다(iOS 클라이언트 ID 가 아니다). 콤마로 여러 개를 넣을 수 있다. +offway.auth.oidc.google.audiences=${GOOGLE_WEB_CLIENT_ID:} +offway.auth.oidc.apple.audiences=${APPLE_SERVICE_ID:} + +# 카카오는 액세스 토큰에 정보가 없어 프로필 API 호출로 신원을 확인한다. +# REST API 키는 호출에 실리지 않고 미설정 provider 판별에만 쓴다. +offway.auth.oidc.kakao.rest-api-key=${KAKAO_REST_API_KEY:} + +# 카카오 앱 번호 — Apple·Google 의 aud 와 같은 역할이다. +# 액세스 토큰 정보 조회(/v1/user/access_token_info)가 돌려주는 app_id 가 이 값이어야 한다. +# 이 검증이 없으면 **다른 카카오 앱에서 발급된 토큰으로 우리 계정에 로그인**할 수 있다. +# 비어 있으면 카카오 로그인을 받지 않는다(남의 앱 토큰을 받아주느니 provider 를 닫는다). +offway.auth.oidc.kakao.audiences=${KAKAO_APP_ID:} + # 로컬 시크릿 파일 — 없으면 그냥 건너뜀(optional). 이게 "키 없어도 부팅"의 핵심. spring.config.import=optional:file:./application-secret.properties diff --git a/src/main/resources/db/migration/V20260729154300__create_user_auth.sql b/src/main/resources/db/migration/V20260729154300__create_user_auth.sql new file mode 100644 index 00000000..f3ddacfd --- /dev/null +++ b/src/main/resources/db/migration/V20260729154300__create_user_auth.sql @@ -0,0 +1,42 @@ +-- OAuth 인증 기반 User(#34) — 게스트 식별 폐기(ADR 0002). +-- FK 제약은 두지 않는다(persistence-convention). 조회 인덱스와 UNIQUE 제약만 유지. +-- MySQL / H2(MODE=MySQL) 양쪽 호환. UUID 는 BINARY(16) 로 저장한다. +-- +-- 테이블명이 users(복수)인 이유: USER 는 MySQL·H2 양쪽에서 예약어라 단수형을 쓸 수 없다. +-- 나머지 테이블(course·region·policy)의 단수 규칙에서 이것만 벗어난다. + +CREATE TABLE users ( + id BINARY(16) NOT NULL, + nickname VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (id) +); + +-- provider 계정 ↔ 우리 유저 매핑. 매칭 키는 ID 토큰의 sub 뿐이다. +-- 이메일로 매칭하지 않는다 — Apple Private Relay 는 익명 주소를 주고, Kakao 는 이메일 동의를 거부할 수 있다. +CREATE TABLE user_identity ( + id BINARY(16) NOT NULL, + user_id BINARY(16) NOT NULL, + provider VARCHAR(20) NOT NULL, + provider_user_id VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_user_identity_provider UNIQUE (provider, provider_user_id) +); +CREATE INDEX idx_user_identity_user ON user_identity (user_id); + +-- refresh 토큰. 원문이 아니라 SHA-256 해시만 저장한다(DB 유출 시 그대로 쓰이는 걸 막는다). +-- 회전 시 행을 지우지 않고 revoked_at 을 채운다 — 지우면 "폐기된 토큰 재사용"과 "없는 토큰"을 구분할 수 없어 +-- 탈취 감지가 불가능해진다. +CREATE TABLE refresh_token ( + id BINARY(16) NOT NULL, + user_id BINARY(16) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_refresh_token_hash UNIQUE (token_hash) +); +CREATE INDEX idx_refresh_token_user ON refresh_token (user_id); diff --git a/src/main/resources/db/migration/V20260814014743__add_user_email.sql b/src/main/resources/db/migration/V20260814014743__add_user_email.sql new file mode 100644 index 00000000..411f7f79 --- /dev/null +++ b/src/main/resources/db/migration/V20260814014743__add_user_email.sql @@ -0,0 +1,12 @@ +-- 소셜 로그인이 주는 이메일을 보관한다(#34). +-- +-- NULL 을 허용한다. Kakao 는 이메일 동의를 거부할 수 있고, Apple 은 Private Relay 익명 주소를 주거나 +-- 최초 로그인 응답에만 준다 — 값이 없는 것이 정상 경로다. +-- +-- UNIQUE 를 걸지 않는다. 한 사람이 provider 를 바꿔 다시 가입하면 같은 이메일이 두 행에 생길 수 있고, +-- Apple 익명 주소는 서비스마다 달라 동일성 판단에도 못 쓴다. 계정 매칭 키는 user_identity 의 +-- (provider, provider_user_id) 뿐이다. +-- +-- ADD COLUMN 이라 순서 무관하다(out-of-order 안전). + +ALTER TABLE users ADD COLUMN email VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V20260816133309__create_user_guest_link.sql b/src/main/resources/db/migration/V20260816133309__create_user_guest_link.sql new file mode 100644 index 00000000..4ec05f0c --- /dev/null +++ b/src/main/resources/db/migration/V20260816133309__create_user_guest_link.sql @@ -0,0 +1,24 @@ +-- 로그인한 사용자와 그 기기의 게스트 키를 잇는다(#34). +-- +-- 지금 코스·연차는 guest_id 로 묶여 있고 사용자 식별이 아직 그리로 옮겨가지 않았다. 그래서 서버는 +-- "이 사용자의 데이터가 무엇인가" 를 스스로 알지 못하고, 헤더로 들고 오는 값에 의존한다. 그 상태에서는 +-- +-- * 탈퇴가 헤더 없이 오면 코스·연차가 주인 없이 영영 남고, +-- * 헤더를 바꿔 보내면 남의 연차·후기를 지울 수 있으며, +-- * 나중에 소유를 user_id 로 옮길 때 무엇을 누구에게 줄지 판단할 근거가 없다. +-- +-- 로그인할 때 한 줄 적어 두면 셋 다 닫힌다. 이 표가 그때의 backfill 키가 된다. +-- +-- guest_id 에 UNIQUE 를 건다 — 한 기기에서 두 사람이 로그인해도 그 기기의 옛 데이터는 먼저 로그인한 +-- 사용자의 것으로 고정한다. 뒤에 온 사람에게 상속시키면 남의 데이터를 넘기는 셈이라, 안 넘기는 쪽을 택했다. +-- +-- FK 는 두지 않는다(persistence-convention). 조회 인덱스와 UNIQUE 제약만 유지한다. +CREATE TABLE user_guest_link ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BINARY(16) NOT NULL, + guest_id VARCHAR(64) NOT NULL, + linked_at DATETIME NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_user_guest_link_guest UNIQUE (guest_id), + KEY idx_user_guest_link_user (user_id) +); diff --git a/src/main/resources/db/migration/V20260816133905__index_refresh_token_active.sql b/src/main/resources/db/migration/V20260816133905__index_refresh_token_active.sql new file mode 100644 index 00000000..6d33edd3 --- /dev/null +++ b/src/main/resources/db/migration/V20260816133905__index_refresh_token_active.sql @@ -0,0 +1,10 @@ +-- 살아 있는 refresh 만 골라내는 조회의 인덱스(#34). +-- +-- 기존 인덱스는 (user_id) 뿐이라 revoked_at 조건을 인덱스로 못 거른다. access 1시간 / refresh 60일에 +-- 삭제 경로가 없어 사용자당 행이 계속 쌓이는데(1년이면 1,400행 남짓), 그중 살아 있는 것은 1~2개다. +-- 로그아웃과 재사용 감지가 그때마다 그 사용자의 전 행을 읽는다. +-- +-- 선두를 user_id 로 둔다 — 기존 인덱스가 하던 "이 사용자의 토큰" 조회를 그대로 덮고, 뒤에 revoked_at 을 +-- 붙여 살아 있는 것만 바로 짚는다. 기존 인덱스는 이 인덱스의 접두어라 남겨 둘 이유가 없지만, DROP 은 +-- 순서 의존이라 이 마이그레이션에서 함께 하지 않는다(persistence-convention: add → backfill → drop). +CREATE INDEX idx_refresh_token_user_active ON refresh_token (user_id, revoked_at); diff --git a/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java b/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java index 3bd1de3c..80036304 100644 --- a/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java +++ b/src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java @@ -117,6 +117,38 @@ class SensitiveParamsTest { assertTrue(masked.contains("areaCode=34"), "민감하지 않은 값은 남아야 한다. 실제=" + masked); } + @Test + void 접두어가_붙은_토큰_이름도_가린다() { + // \btoken= 만으로는 못 잡는다 — accessToken 은 token 앞이 단어 문자라 경계가 없다(#34). + // 소셜 로그인부터 이 이름들이 실제로 흐르므로, 놓치면 provider 액세스 토큰이 그대로 로그에 박힌다. + String message = "401 from POST /auth?accessToken=aaa&idToken=bbb&refreshToken=ccc&client_secret=ddd"; + + String masked = SensitiveParams.maskSecretsInText(message); + + assertFalse(masked.contains("aaa"), "실제=" + masked); + assertFalse(masked.contains("bbb"), "실제=" + masked); + assertFalse(masked.contains("ccc"), "실제=" + masked); + assertFalse(masked.contains("ddd"), "실제=" + masked); + } + + @Test + void Bearer_토큰을_가린다() { + // 헤더 형태라 이름=값 규칙으로는 안 걸린다. 이 값은 그대로 카카오 프로필을 부를 수 있는 토큰이다(#34). + String message = "401 from GET https://kapi.kakao.com/v2/user/me [Authorization: Bearer aBc123.dEf-456_ghi]"; + + String masked = SensitiveParams.maskSecretsInText(message); + + assertFalse(masked.contains("aBc123.dEf-456_ghi"), "실제=" + masked); + assertTrue(masked.contains("Bearer ***"), "실제=" + masked); + assertTrue(masked.contains("kapi.kakao.com"), "비밀이 아닌 주소는 남아야 한다. 실제=" + masked); + } + + @Test + void 토큰_이름을_쿼리에서도_가린다() { + assertEquals("accessToken=***", SensitiveParams.readableParams("accessToken=abc123")); + assertEquals("refreshToken=***", SensitiveParams.readableParams("refreshToken=abc123")); + } + @Test void 디스코드_웹훅_토큰을_가린다() { // 이름=값 규칙으로는 안 걸린다 — 토큰이 경로 조각이라 이름이 없다. 그런데 이 URL 끝을 아는 사람은 @@ -168,4 +200,21 @@ class SensitiveParamsTest { void 값이_null_이어도_깨지지_않는다() { assertEquals("", SensitiveParams.forLog(null)); } + + @ParameterizedTest + @ValueSource(strings = {"access_token", "id_token", "refresh_token", "identity_token"}) + void OAuth_규격_이름의_토큰도_가린다(String name) { + // 우리 앱은 camelCase 로 보내지만 OAuth 규격(RFC 6749)이 쓰는 이름은 snake_case 다. 제공자 쪽 URL 이 + // 예외 메시지에 실려 들어오는 경로가 있어, 한쪽만 적으면 그 경로로 토큰이 그대로 로그에 남는다. + assertEquals(name + "=***", SensitiveParams.readableParams(name + "=secret")); + } + + @Test + void 물결이_섞인_Bearer_토큰도_통째로_가린다() { + // base64url·RFC 6750 이 허용하는 문자다. 문자 집합에서 빠지면 거기서 끊겨 뒷부분이 로그에 남는다. + String masked = SensitiveParams.maskSecretsInText("401 from GET /me Authorization: Bearer abc~def.ghi"); + + assertFalse(masked.contains("def"), "실제=" + masked); + assertTrue(masked.contains("Bearer ***"), "실제=" + masked); + } } diff --git a/src/test/java/com/offway/core/user/controller/AuthIntegrationTest.java b/src/test/java/com/offway/core/user/controller/AuthIntegrationTest.java new file mode 100644 index 00000000..07d45696 --- /dev/null +++ b/src/test/java/com/offway/core/user/controller/AuthIntegrationTest.java @@ -0,0 +1,669 @@ +package com.offway.core.user.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import com.offway.core.common.exception.BaseException; +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.RefreshToken; +import com.offway.core.user.domain.UserException; +import com.offway.core.user.infrastructure.kakao.StubKakaoProfileClient; +import com.offway.core.user.infrastructure.social.StubSocialIdentityVerifier; +import com.offway.core.user.repository.UserJpaRepository; +import com.offway.core.user.service.AuthService; +import com.offway.core.user.service.TokenIssuer; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +// DB 격리: 롤백 대신 테스트마다 고유한 provider 식별자를 써서 계정이 섞이지 않게 한다. +@SpringBootTest +@AutoConfigureMockMvc +class AuthIntegrationTest { + + private static final String GUEST_HEADER = "X-Guest-Id"; + + private static final String CALLBACK_URL = "/api/v1/auth/callback/%s"; + private static final String REISSUE_URL = "/api/v1/auth/reissue"; + private static final String LOGOUT_URL = "/api/v1/auth/logout"; + private static final String BEARER = "Bearer "; + + /** 동시 재발급 결과에서 실패를 표시하는 접두사. 성공은 새 refresh 토큰 원문이라 겹칠 수 없다. */ + private static final String FAILED = "FAILED:"; + + private static final long CONCURRENCY_TIMEOUT_SECONDS = 30; + + @TestConfiguration + static class SocialStubConfiguration { + + @Bean + StubSocialIdentityVerifier stubSocialIdentityVerifier() { + return new StubSocialIdentityVerifier(); + } + + @Bean + @Primary + StubKakaoProfileClient stubKakaoProfileClient() { + return new StubKakaoProfileClient(); + } + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private StubSocialIdentityVerifier socialIdentityVerifier; + + @Autowired + private StubKakaoProfileClient kakaoProfileClient; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private AuthService authService; + + @Autowired + private TokenIssuer tokenIssuer; + + @Autowired + private JdbcTemplate jdbcTemplate; + + /** 테스트마다 고유한 provider 신원 — 롤백 없이 이전 실행과 계정이 섞이지 않게. */ + private static String uniqueProviderUserId() { + return "sub-" + UUID.randomUUID(); + } + + // ── 로그인 계약 ──────────────────────────────────────────── + + @Test + void 처음_로그인하면_가입되고_isNewUser가_true다() throws Exception { + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", "user@example.com"); + + mockMvc.perform(callback("google", "any-id-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()) + .andExpect(jsonPath("$.data.expiresIn").value(3600)) + // 필드 이름이 isNewUser 여야 한다. record 접근자를 bean 규약으로 읽으면 newUser 가 되는데, + // 그러면 앱이 온보딩 분기를 못 한다 — 계약이라 이름까지 단언한다. + .andExpect(jsonPath("$.data.isNewUser").value(true)); + } + + @Test + void 같은_신원으로_다시_로그인하면_사용자는_하나고_isNewUser가_false다() throws Exception { + // 매칭 키는 provider 식별자다. 재로그인이 계정을 늘리면 "내 코스"·연차가 매번 초기화된다. + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + long before = userJpaRepository.count(); + + mockMvc.perform(callback("google", "token-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isNewUser").value(true)); + mockMvc.perform(callback("google", "token-2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isNewUser").value(false)); + + assertEquals(before + 1, userJpaRepository.count()); + } + + @Test + void provider_경로값은_대소문자를_가리지_않는다() throws Exception { + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + + mockMvc.perform(callback("GOOGLE", "any-id-token")).andExpect(status().isOk()); + } + + @Test + void 지원하지_않는_provider_경로값은_400_USER_002() throws Exception { + mockMvc.perform(callback("naver", "any-token")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("USER-002")) + .andExpect(jsonPath("$.detail").isNotEmpty()); + } + + @Test + void 토큰이_비면_400이다() throws Exception { + mockMvc.perform(post(CALLBACK_URL.formatted("google")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"accessToken\": \"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-400")); + } + + // ── Apple — 이름·이메일이 최초 로그인 요청에만 실린다 ────────── + + @Test + void 애플은_토큰에_이름이_없어_요청_값이_반영된다() throws Exception { + socialIdentityVerifier.respondWith(AuthProvider.APPLE, uniqueProviderUserId(), null, null); + + String response = bodyOf(callbackWithProfile("apple", "any-id-token", "홍길동", "user@example.com")); + + UUID userId = UUID.fromString(subjectOf(JsonPath.read(response, "$.data.accessToken"))); + var saved = userJpaRepository.findById(userId).orElseThrow(); + assertEquals("홍길동", saved.getNickname()); + assertEquals("user@example.com", saved.getEmail()); + } + + @Test + void 이름이_아무데서도_안_오면_기본_표시이름이_붙는다() throws Exception { + // Apple 사용자가 이름 제공을 거부한 경우. 닉네임 하나 때문에 가입이 실패하면 안 된다. + socialIdentityVerifier.respondWith(AuthProvider.APPLE, uniqueProviderUserId(), null, null); + + String response = bodyOf(callback("apple", "any-id-token")); + + UUID userId = UUID.fromString(subjectOf(JsonPath.read(response, "$.data.accessToken"))); + assertEquals("여행자", userJpaRepository.findById(userId).orElseThrow().getNickname()); + } + + @Test + void 토큰_검증에_실패하면_401_USER_001() throws Exception { + // 서명 불일치·만료·aud 불일치가 전부 여기로 모인다 — 앱은 재로그인으로 반응한다. + socialIdentityVerifier.respond((provider, credential) -> { + throw UserException.invalidIdToken(new IllegalStateException("audience 불일치")); + }); + + mockMvc.perform(callback("google", "someone-elses-token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER-001")); + } + + @Test + void provider_공개키를_못_가져오면_502_USER_005() throws Exception { + // "네 토큰이 틀렸다"와 "구글이 안 뜬다"는 앱이 취할 행동이 정반대라 code 를 나눈다. + socialIdentityVerifier.respond((provider, credential) -> { + throw UserException.oidcProviderUnavailable(new IllegalStateException("JWKS 조회 실패")); + }); + + mockMvc.perform(callback("google", "any-id-token")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.code").value("USER-005")); + } + + // ── Kakao — 유일하게 로그인 경로에 외부 호출이 낀다 ──────────── + + @Test + void 카카오는_프로필_조회_결과로_가입된다() throws Exception { + String kakaoId = uniqueProviderUserId(); + kakaoProfileClient.respondWith(kakaoId, "카카오세빈", "kakao@example.com"); + + String response = bodyOf(callback("kakao", "kakao-access-token")); + + UUID userId = UUID.fromString(subjectOf(JsonPath.read(response, "$.data.accessToken"))); + var saved = userJpaRepository.findById(userId).orElseThrow(); + assertEquals("카카오세빈", saved.getNickname()); + assertEquals("kakao@example.com", saved.getEmail()); + } + + @Test + void 카카오가_동의를_거부해_닉네임_이메일이_없어도_가입된다() throws Exception { + // 카카오는 프로필·이메일 동의를 각각 거부할 수 있다. 그때도 회원번호만 있으면 로그인은 성립한다. + kakaoProfileClient.respondWith(uniqueProviderUserId(), null, null); + + String response = bodyOf(callback("kakao", "kakao-access-token")); + + UUID userId = UUID.fromString(subjectOf(JsonPath.read(response, "$.data.accessToken"))); + var saved = userJpaRepository.findById(userId).orElseThrow(); + assertEquals("여행자", saved.getNickname()); + assertEquals(null, saved.getEmail()); + } + + @Test + void 카카오가_액세스_토큰을_거부하면_401_USER_001() throws Exception { + kakaoProfileClient.respond(accessToken -> { + throw UserException.invalidIdToken(new IllegalStateException("카카오 401")); + }); + + mockMvc.perform(callback("kakao", "expired-access-token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER-001")); + } + + @Test + void 카카오_프로필_API_가_죽으면_502_USER_005() throws Exception { + // 타임아웃·5xx — 재시도로 풀릴 수 있으므로 401 과 구분해 내린다. + kakaoProfileClient.respond(accessToken -> { + throw UserException.oidcProviderUnavailable(new IllegalStateException("read timeout")); + }); + + mockMvc.perform(callback("kakao", "kakao-access-token")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.code").value("USER-005")); + } + + @Test + void 다른_카카오_앱에서_발급된_액세스_토큰은_거부한다() throws Exception { + // 카카오 프로필 조회(/v2/user/me)는 토큰이 유효하기만 하면 주인을 돌려준다 — 어느 앱이 발급했는지는 + // 알려주지 않는다. 앱 번호를 대조하지 않으면 남의 카카오 앱 토큰을 손에 넣은 사람이 그 토큰을 그대로 + // 우리 서버에 던져 그 사용자로 로그인할 수 있다. Apple·Google 에서 aud 가 막는 자리다. + kakaoProfileClient.respondWith(uniqueProviderUserId(), "남의앱사용자", null); + kakaoProfileClient.respondTokenInfoFromApp("9999999"); + + mockMvc.perform(callback("kakao", "other-app-access-token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER-001")); + } + + @Test + void 남의_앱_토큰으로는_가입도_일어나지_않는다() throws Exception { + // 거부가 401 을 내는 것으로 끝나면 안 된다 — 그 사이 가입이 일어나 있으면 막은 의미가 없다. + // 같은 신원으로 정상 로그인했을 때 isNewUser 가 true 면, 앞선 시도가 계정을 만들지 않았다는 뜻이다. + String kakaoId = uniqueProviderUserId(); + kakaoProfileClient.respondWith(kakaoId, "사용자", null); + kakaoProfileClient.respondTokenInfoFromApp("9999999"); + mockMvc.perform(callback("kakao", "other-app-access-token")).andExpect(status().isUnauthorized()); + + kakaoProfileClient.respondWith(kakaoId, "사용자", null); + + mockMvc.perform(callback("kakao", "our-app-access-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isNewUser").value(true)); + } + + @Test + void 카카오_토큰_정보_조회가_실패하면_502_USER_005() throws Exception { + // 앱 번호를 확인하지 못한 것은 "확인했더니 남의 앱" 과 다르다 — 재시도로 풀릴 수 있어 502 로 구분한다. + kakaoProfileClient.respondWith(uniqueProviderUserId(), "세빈", null); + kakaoProfileClient.respondTokenInfo(accessToken -> { + throw UserException.oidcProviderUnavailable(new IllegalStateException("read timeout")); + }); + + mockMvc.perform(callback("kakao", "kakao-access-token")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.code").value("USER-005")); + } + + @Test + void 클라이언트가_보낸_providerUserId는_신원_판단에_쓰이지_않는다() throws Exception { + // 이 값을 믿으면 남의 식별자를 적어 그 계정으로 로그인할 수 있다 — 요청 한 번짜리 계정 탈취다. + String verified = uniqueProviderUserId(); + kakaoProfileClient.respondWith(verified, "진짜사용자", null); + + String response = mockMvc.perform(post(CALLBACK_URL.formatted("kakao")) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"accessToken": "kakao-access-token", "providerUserId": "victim-account-id"} + """)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // 발급된 토큰이 가리키는 사용자는 요청이 사칭한 쪽이 아니라 카카오가 확인해 준 쪽이어야 한다. + UUID userId = UUID.fromString(subjectOf(JsonPath.read(response, "$.data.accessToken"))); + assertEquals("진짜사용자", userJpaRepository.findById(userId).orElseThrow().getNickname()); + } + + // ── 세션 수명 ───────────────────────────────────────────── + + @Test + void 발급받은_토큰으로_보호된_엔드포인트를_호출할_수_있다() throws Exception { + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + Tokens issued = login(); + + mockMvc.perform(post(LOGOUT_URL).header(HttpHeaders.AUTHORIZATION, BEARER + issued.accessToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")); + } + + @Test + void 자격증명_없이_보호된_엔드포인트를_부르면_401이고_응답이_공통_래퍼다() throws Exception { + // 이 401 은 서블릿 필터 단계라 GlobalExceptionHandler 가 닿지 못한다. + // 전용 EntryPoint 가 없으면 body 가 비거나 HTML 로 나가 FE 가 매핑할 code 자체가 사라진다. + mockMvc.perform(post(LOGOUT_URL)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value(401)) + .andExpect(jsonPath("$.code").value("COMMON-401")) + .andExpect(jsonPath("$.detail").isNotEmpty()) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void 무효한_access_토큰은_401_USER_004로_재발급을_유도한다() throws Exception { + // 아무것도 안 들고 온 요청(COMMON-401)과 다른 code 여야 한다 — 앱이 취할 행동이 다르다. + mockMvc.perform(post(LOGOUT_URL).header(HttpHeaders.AUTHORIZATION, BEARER + "not-a-real-jwt")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER-004")); + } + + @Test + void refresh를_쓰면_새_토큰_쌍이_나오고_기존_refresh는_바뀐다() throws Exception { + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + Tokens issued = login(); + + String response = mockMvc.perform(reissueRequest(issued.refreshToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + // 재발급은 가입일 수 없다 — 여기서 true 가 나가면 앱이 매 시간 온보딩을 띄운다. + .andExpect(jsonPath("$.data.isNewUser").value(false)) + .andReturn() + .getResponse() + .getContentAsString(); + + assertNotEquals(issued.refreshToken(), JsonPath.read(response, "$.data.refreshToken")); + } + + @Test + void 이미_회전된_refresh를_재사용하면_401이고_사용자_토큰이_전부_폐기된다() throws Exception { + // 정상 클라이언트는 회전된 토큰을 다시 쓰지 않는다 → 탈취 정황이라 살아 있는 토큰까지 끊는다. + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + Tokens issued = login(); + String rotated = JsonPath.read(bodyOf(reissueRequest(issued.refreshToken())), "$.data.refreshToken"); + // 회전 직후 유예 창(정상 앱의 재시도·동시 요청)을 벗어나게 폐기 시각을 과거로 민다. 그러지 않으면 + // 이 재사용이 "재시도" 로 해석돼 탈취 경보가 울리지 않는다 — 그건 별도 테스트가 잠근다. + backdateRevocation(issued.refreshToken()); + + mockMvc.perform(reissueRequest(issued.refreshToken())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER-003")); + + mockMvc.perform(reissueRequest(rotated)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER-003")); + } + + // ── refresh 회전의 동시성 ────────────────────────────────── + + /** + * 스레드를 로 짠다. 테스트 컨텍스트의 hikari {@code maximum-pool-size} 가 2 라 실효 동시성이 2 이고, + * 스레드를 더 늘려도 커넥션을 기다리며 줄을 서 순차 실행에 가까워진다 — 숫자만 커지고 겹치지 않는다. + */ + private static final int CONCURRENT_REISSUES = 2; + + @Test + void 같은_refresh로_동시에_재발급하면_하나만_성공한다() throws Exception { + // 잠금이 없으면 두 트랜잭션이 같은 스냅샷(revoked_at IS NULL)을 보고 둘 다 회전에 성공한다. + // 토큰 하나에서 살아 있는 refresh 가 둘 나오고, 그 순간 재사용 감지의 보장이 성립하지 않는다. + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + Tokens issued = login(); + + List results = reissueConcurrently(issued.refreshToken()); + + assertEquals(1, results.stream().filter(result -> !result.startsWith(FAILED)).count(), results.toString()); + } + + @Test + void 동시_재발급에서_진_요청이_세션을_끊지_않는다() throws Exception { + // 진 요청을 탈취로 오인해 전체 폐기를 돌리면, 이긴 요청이 방금 받아 간 정상 토큰까지 죽어 사용자가 + // 멀쩡한 토큰을 들고도 로그아웃된다. 회전 직후 유예 창이 막는 자리다. + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + Tokens issued = login(); + + List results = reissueConcurrently(issued.refreshToken()); + String winner = results.stream() + .filter(result -> !result.startsWith(FAILED)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("동시 재발급에서 성공한 요청이 없다: " + results)); + + mockMvc.perform(reissueRequest(winner)).andExpect(status().isOk()); + } + + @Test + void 유예_창_안의_재시도는_경보를_울리지_않고_그_요청만_거절한다() throws Exception { + // 네트워크 재시도로 같은 refresh 가 곧바로 다시 오는 것은 탈취가 아니다. 그 요청은 거절하되(줄 토큰이 + // 없다 — 새 토큰은 이미 이긴 요청이 가져갔다) 세션 전체를 끊지는 않는다. + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + Tokens issued = login(); + String rotated = JsonPath.read(bodyOf(reissueRequest(issued.refreshToken())), "$.data.refreshToken"); + + mockMvc.perform(reissueRequest(issued.refreshToken())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER-003")); + + mockMvc.perform(reissueRequest(rotated)).andExpect(status().isOk()); + } + + /** 같은 refresh 로 동시에 재발급을 시도하고, 성공은 새 refresh 를, 실패는 {@code FAILED:code} 를 돌려준다. */ + private List reissueConcurrently(String refreshToken) throws Exception { + CyclicBarrier barrier = new CyclicBarrier(CONCURRENT_REISSUES); + ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_REISSUES); + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < CONCURRENT_REISSUES; i++) { + futures.add(executor.submit(() -> { + barrier.await(); + try { + return authService.reissue(refreshToken).refreshToken(); + } catch (BaseException exception) { + return FAILED + exception.errorCode().code(); + } + })); + } + List results = new ArrayList<>(); + for (Future future : futures) { + results.add(future.get(CONCURRENCY_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + return results; + } finally { + executor.shutdownNow(); + } + } + + @Test + void 로그아웃하면_refresh가_폐기된다() throws Exception { + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + Tokens issued = login(); + + mockMvc.perform(post(LOGOUT_URL).header(HttpHeaders.AUTHORIZATION, BEARER + issued.accessToken())) + .andExpect(status().isOk()); + + mockMvc.perform(reissueRequest(issued.refreshToken())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER-003")); + } + + /** + * 폐기 시각을 유예 창 밖으로 민다 — "회전 직후 재시도" 가 아니라 "한참 뒤의 재사용" 을 만든다. + * + *

시계를 주입하는 대신 DB 를 직접 미는 이유는, 유예 판정이 도메인이 받은 {@code now} 하나로 끝나 + * 흉내 낼 상태가 폐기 시각뿐이기 때문이다. 프로덕션 코드에 테스트용 시계를 뚫는 것보다 싸다. + */ + /** + * 폐기 시각을 유예 창 밖으로 민다. + * + *

DB 안에서 상대적으로 뺀다. 클라이언트에서 계산한 {@code Timestamp} 를 넣으면 드라이버가 JVM + * 기본 시간대로 변환하는데, 이 커넥션은 {@code serverTimezone=Asia/Seoul} 이라 값이 그만큼 틀어진다 — + * 과거로 민다는 것이 미래로 가서 "방금 폐기됨" 으로 읽혔다. 저장된 값에서 빼면 시간대가 끼어들지 않는다. + */ + private void backdateRevocation(String refreshToken) { + long seconds = RefreshToken.ROTATION_GRACE.plus(Duration.ofMinutes(1)).toSeconds(); + int updated = jdbcTemplate.update( + "UPDATE refresh_token SET revoked_at = revoked_at - INTERVAL ? SECOND WHERE token_hash = ?", + seconds, + tokenIssuer.hashRefreshToken(refreshToken)); + assertEquals(1, updated, "폐기 시각을 밀 대상이 없다 — 앞선 회전이 실제로 폐기했는지 확인하라"); + } + + private record Tokens(String accessToken, String refreshToken) {} + + private Tokens login() throws Exception { + String response = bodyOf(callback("google", "any-id-token")); + return new Tokens( + JsonPath.read(response, "$.data.accessToken"), JsonPath.read(response, "$.data.refreshToken")); + } + + private String bodyOf(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + } + + private static MockHttpServletRequestBuilder callback(String provider, String accessToken) { + return post(CALLBACK_URL.formatted(provider)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"accessToken\": \"%s\"}".formatted(accessToken)); + } + + private static MockHttpServletRequestBuilder callbackWithProfile( + String provider, String accessToken, String name, String email) { + return post(CALLBACK_URL.formatted(provider)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"accessToken\": \"%s\", \"name\": \"%s\", \"email\": \"%s\"}" + .formatted(accessToken, name, email)); + } + + private static MockHttpServletRequestBuilder reissueRequest(String refreshToken) { + return post(REISSUE_URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\": \"%s\"}".formatted(refreshToken)); + } + + /** access 토큰(JWT) payload 에서 sub 을 꺼낸다 — 발급된 토큰이 어느 사용자 것인지 확인용. */ + private static String subjectOf(String accessToken) { + String payload = new String(Base64.getUrlDecoder().decode(accessToken.split("\\.")[1])); + return JsonPath.read(payload, "$.sub"); + } + + // ── 기기 연결(#34) ──────────────────────────────────────── + + /** + * 로그인할 때 이 기기를 사용자에게 이어 둔다 — 탈퇴가 데이터를 찾는 유일한 근거다. + * + *

코스·연차는 아직 {@code guest_id} 로 묶여 있다. 이 기록이 없으면 서버는 "이 사용자의 데이터가 + * 무엇인가" 를 스스로 알지 못해, 헤더 없이 온 탈퇴 요청에서 코스·연차가 주인 없이 영영 남는다. + */ + @Test + void 로그인하면_이_기기가_사용자에게_이어진다() throws Exception { + String guest = "link-" + UUID.randomUUID(); + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + + mockMvc.perform(callback("google", "any-id-token").header(GUEST_HEADER, guest)) + .andExpect(status().isOk()); + + assertEquals(1, linkCount(guest), "로그인이 기기를 잇지 않았다"); + } + + @Test + void 헤더가_없어도_로그인은_된다() throws Exception { + // 기록은 나중을 위한 것이지 로그인의 조건이 아니다. 여기서 막으면 헤더를 안 보내는 앱이 못 들어온다. + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + + mockMvc.perform(callback("google", "any-id-token")).andExpect(status().isOk()); + } + + @Test + void 같은_기기로_다시_로그인해도_연결은_하나다() throws Exception { + String guest = "link-" + UUID.randomUUID(); + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + + mockMvc.perform(callback("google", "t1").header(GUEST_HEADER, guest)).andExpect(status().isOk()); + mockMvc.perform(callback("google", "t2").header(GUEST_HEADER, guest)).andExpect(status().isOk()); + + assertEquals(1, linkCount(guest)); + } + + /** + * 한 기기에서 두 사람이 로그인해도 그 기기는 먼저 로그인한 사용자의 것으로 남는다. + * + *

뒤에 온 사람에게 넘기면 그 기기에 쌓인 남의 코스·연차를 넘기는 셈이고, 탈퇴가 그것을 지운다. + * 데이터를 잘못 넘기는 것보다 안 넘기는 쪽이 낫다. + */ + @Test + void 한_기기를_두_사람이_쓰면_먼저_로그인한_쪽이_갖는다() throws Exception { + String guest = "link-" + UUID.randomUUID(); + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "먼저", null); + String firstUser = ownerOf(guest, callback("google", "t1").header(GUEST_HEADER, guest)); + + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "나중", null); + mockMvc.perform(callback("google", "t2").header(GUEST_HEADER, guest)).andExpect(status().isOk()); + + assertEquals(1, linkCount(guest)); + assertEquals(firstUser, currentOwner(guest), "뒤에 로그인한 사용자가 기기를 가져갔다"); + } + + private int linkCount(String guestId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_guest_link WHERE guest_id = ?", Integer.class, guestId); + } + + private String currentOwner(String guestId) { + return jdbcTemplate.queryForObject( + "SELECT HEX(user_id) FROM user_guest_link WHERE guest_id = ?", String.class, guestId); + } + + private String ownerOf(String guestId, MockHttpServletRequestBuilder request) throws Exception { + mockMvc.perform(request).andExpect(status().isOk()); + return currentOwner(guestId); + } + + /** + * 헤더가 길어도 로그인은 성공한다 — 기록은 나중을 위한 것이지 로그인의 조건이 아니다. + * + *

여기서 터지면 계정은 만들어졌는데 토큰을 못 받아, 재시도해도 같은 자리에서 계속 실패하는 락아웃이 + * 된다. 실제로 그랬다 — {@code VARCHAR(64)} 를 넘긴 값이 DB 에서 잘리며 500 을 냈다. + */ + @Test + void 기기_식별자가_너무_길어도_로그인은_된다() throws Exception { + String tooLong = "d".repeat(200); + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + + mockMvc.perform(callback("google", "any-id-token").header(GUEST_HEADER, tooLong)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()); + + assertEquals(0, linkCount(tooLong), "이을 수 없는 값을 기록했다"); + } + + /** + * 같은 기기로 동시에 로그인해도 둘 다 성공한다. + * + *

유니크 제약 위반을 트랜잭션 안에서 삼키면 소용이 없다 — 세션이 rollback-only 가 되어 커밋에서 + * {@code UnexpectedRollbackException} 으로 끝난다. 진 쪽이 로그인에 실패하면 새 기기 첫 로그인을 + * 두 번 누른 사용자가 들어오지 못한다. + */ + @Test + void 같은_기기로_동시에_로그인해도_둘_다_성공한다() throws Exception { + String guest = "link-" + UUID.randomUUID(); + socialIdentityVerifier.respondWith(AuthProvider.GOOGLE, uniqueProviderUserId(), "세빈", null); + int attempts = 2; + CyclicBarrier barrier = new CyclicBarrier(attempts); + ExecutorService executor = Executors.newFixedThreadPool(attempts); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < attempts; i++) { + results.add(executor.submit(() -> { + barrier.await(); + return mockMvc.perform(callback("google", "any-id-token").header(GUEST_HEADER, guest)) + .andReturn() + .getResponse() + .getStatus(); + })); + } + for (Future result : results) { + assertEquals(200, result.get(), "동시 로그인 중 하나가 실패했다"); + } + } finally { + executor.shutdownNow(); + } + assertEquals(1, linkCount(guest)); + } +} diff --git a/src/test/java/com/offway/core/user/controller/BasicAuthIntegrationTest.java b/src/test/java/com/offway/core/user/controller/BasicAuthIntegrationTest.java index dc247af5..16c8aa8c 100644 --- a/src/test/java/com/offway/core/user/controller/BasicAuthIntegrationTest.java +++ b/src/test/java/com/offway/core/user/controller/BasicAuthIntegrationTest.java @@ -3,6 +3,7 @@ import static org.hamcrest.Matchers.containsString; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -10,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.test.web.servlet.MockMvc; @@ -75,4 +77,20 @@ class BasicAuthIntegrationTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("OK")); } + + /** + * Basic 으로는 읽기만 된다 — 브라우저가 자동으로 붙이는 자격증명으로 상태를 바꾸지 못하게 한다. + * + *

브라우저는 캐시된 Basic 자격증명을 교차 출처 쓰기 요청에도 보내고, 공개 GET 경로의 CORS 제한은 그 + * 전송을 막지 못한다. 이 서비스는 CSRF 토큰을 쓰지 않는 무상태 API 라, 막는 자리가 여기다. + */ + @Test + void Basic_으로는_쓰기를_못_한다() throws Exception { + mockMvc.perform(post("/api/v1/courses") + .with(httpBasic(USERNAME, PASSWORD)) + .header("X-Guest-Id", "basic-write-attempt") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + } } diff --git a/src/test/java/com/offway/core/user/domain/AuthProviderTest.java b/src/test/java/com/offway/core/user/domain/AuthProviderTest.java new file mode 100644 index 00000000..cd8ab51a --- /dev/null +++ b/src/test/java/com/offway/core/user/domain/AuthProviderTest.java @@ -0,0 +1,91 @@ +package com.offway.core.user.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class AuthProviderTest { + + @ParameterizedTest + @CsvSource({ + "kakao,KAKAO", + "KAKAO,KAKAO", + "Kakao,KAKAO", + "apple,APPLE", + "google,GOOGLE", + "' google ',GOOGLE" + }) + void 경로값을_대소문자_공백_상관없이_해석한다(String pathValue, AuthProvider expected) { + assertEquals(expected, AuthProvider.from(pathValue)); + } + + @ParameterizedTest + @ValueSource(strings = {"naver", "facebook", "GOOGL", "kakao-talk"}) + void 지원하지_않는_provider는_USER_002다(String pathValue) { + // Spring 기본 변환 실패(형식 오류)에 맡기면 사유가 code 로 전달되지 않는다. + UserException exception = assertThrows(UserException.class, () -> AuthProvider.from(pathValue)); + + assertEquals(UserErrorCode.UNSUPPORTED_PROVIDER, exception.errorCode()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 값이_없어도_USER_002로_끊는다(String pathValue) { + UserException exception = assertThrows(UserException.class, () -> AuthProvider.from(pathValue)); + + assertEquals(UserErrorCode.UNSUPPORTED_PROVIDER, exception.errorCode()); + } + + @Test + void 애플_구글은_서명_검증에_필요한_값을_들고_있다() { + assertTrue(AuthProvider.GOOGLE.oidc().isPresent()); + assertTrue(AuthProvider.APPLE.oidc().isPresent()); + } + + @Test + void 카카오는_서명_검증_대상이_아니다() { + // 이 빈 값이 곧 "프로필 조회로 확인한다"는 분류다. + assertTrue(AuthProvider.KAKAO.oidc().isEmpty()); + } + + @Test + void 애플은_ID토큰에_이름을_담지_않는다() { + // 그래서 최초 로그인 요청의 name 이 유일한 출처가 된다. + assertTrue(AuthProvider.APPLE + .oidc() + .orElseThrow() + .nicknameClaimIfPresent() + .isEmpty()); + } + + @Test + void 구글은_iss_표기_두_가지를_모두_받는다() { + // Google 은 스킴 있는 표기와 없는 표기를 모두 낸다. 하나만 허용하면 다른 표기를 받은 사용자가 전부 + // 401 이 된다 — Google 자신의 검증 라이브러리도 둘 다 받는다. + assertEquals( + List.of("https://accounts.google.com", "accounts.google.com"), + AuthProvider.GOOGLE.oidc().orElseThrow().issuers()); + } + + @Test + void 애플은_iss_표기가_하나다() { + assertEquals( + List.of("https://appleid.apple.com"), + AuthProvider.APPLE.oidc().orElseThrow().issuers()); + } + + @Test + void 구글은_ID토큰의_name_클레임에서_이름을_얻는다() { + assertEquals( + "name", + AuthProvider.GOOGLE.oidc().orElseThrow().nicknameClaimIfPresent().orElseThrow()); + } +} diff --git a/src/test/java/com/offway/core/user/domain/RefreshTokenTest.java b/src/test/java/com/offway/core/user/domain/RefreshTokenTest.java new file mode 100644 index 00000000..14a12f24 --- /dev/null +++ b/src/test/java/com/offway/core/user/domain/RefreshTokenTest.java @@ -0,0 +1,63 @@ +package com.offway.core.user.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class RefreshTokenTest { + + private static final UUID USER_ID = UUID.randomUUID(); + private static final String HASH = "a".repeat(64); + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + + @Test + void 발급_직후에는_사용_가능하다() { + RefreshToken token = RefreshToken.issue(USER_ID, HASH, NOW.plus(60, ChronoUnit.DAYS)); + + assertTrue(token.isUsableAt(NOW)); + assertFalse(token.isRevoked()); + } + + @Test + void 만료_시각에_도달하면_사용할_수_없다() { + // 경계 — 만료 시각 '이후'가 아니라 '도달'부터 무효다. + RefreshToken token = RefreshToken.issue(USER_ID, HASH, NOW); + + assertTrue(token.isExpired(NOW)); + assertFalse(token.isUsableAt(NOW)); + } + + @Test + void 폐기하면_사용할_수_없다() { + RefreshToken token = RefreshToken.issue(USER_ID, HASH, NOW.plus(60, ChronoUnit.DAYS)); + + token.revoke(NOW); + + assertTrue(token.isRevoked()); + assertFalse(token.isUsableAt(NOW)); + } + + @Test + void 이미_폐기된_토큰은_최초_폐기_시각을_유지한다() { + // 재사용 감지의 근거라 나중 호출이 시각을 덮어쓰면 안 된다. + RefreshToken token = RefreshToken.issue(USER_ID, HASH, NOW.plus(60, ChronoUnit.DAYS)); + token.revoke(NOW); + + token.revoke(NOW.plus(1, ChronoUnit.HOURS)); + + assertEquals(NOW, token.getRevokedAt()); + } + + @Test + void 해시가_비면_발급할_수_없다() { + assertThrows( + IllegalArgumentException.class, + () -> RefreshToken.issue(USER_ID, " ", NOW.plus(60, ChronoUnit.DAYS))); + } +} diff --git a/src/test/java/com/offway/core/user/domain/SocialIdentityTest.java b/src/test/java/com/offway/core/user/domain/SocialIdentityTest.java new file mode 100644 index 00000000..ae515c7f --- /dev/null +++ b/src/test/java/com/offway/core/user/domain/SocialIdentityTest.java @@ -0,0 +1,51 @@ +package com.offway.core.user.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class SocialIdentityTest { + + @Test + void provider_식별자가_없으면_만들_수_없다() { + // 식별자 없는 신원은 계정을 못 찾는다 — 여기서 막지 않으면 엉뚱한 계정에 붙거나 매 로그인마다 가입된다. + assertThrows(NullPointerException.class, () -> new SocialIdentity(AuthProvider.KAKAO, null, "세빈", null)); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void provider_식별자가_비면_만들_수_없다(String providerUserId) { + assertThrows( + IllegalArgumentException.class, + () -> new SocialIdentity(AuthProvider.KAKAO, providerUserId, "세빈", null)); + } + + @Test + void provider가_없으면_만들_수_없다() { + assertThrows(NullPointerException.class, () -> new SocialIdentity(null, "sub-1", "세빈", null)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 닉네임_이메일은_없을_수_있다(String absent) { + // Apple 은 이름을 주지 않고, Kakao 는 동의를 거부할 수 있다. 그때도 로그인은 성립해야 한다. + SocialIdentity identity = new SocialIdentity(AuthProvider.APPLE, "sub-1", absent, absent); + + assertTrue(identity.nicknameIfPresent().isEmpty()); + assertTrue(identity.emailIfPresent().isEmpty()); + } + + @Test + void 값이_있으면_그대로_돌려준다() { + SocialIdentity identity = new SocialIdentity(AuthProvider.GOOGLE, "sub-1", "세빈", "user@example.com"); + + assertEquals("세빈", identity.nicknameIfPresent().orElseThrow()); + assertEquals("user@example.com", identity.emailIfPresent().orElseThrow()); + } +} diff --git a/src/test/java/com/offway/core/user/domain/UserTest.java b/src/test/java/com/offway/core/user/domain/UserTest.java new file mode 100644 index 00000000..ace8e111 --- /dev/null +++ b/src/test/java/com/offway/core/user/domain/UserTest.java @@ -0,0 +1,67 @@ +package com.offway.core.user.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class UserTest { + + private static final String DEFAULT_NICKNAME = "여행자"; + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + void 닉네임이_비면_기본_표시_이름을_쓴다(String nickname) { + // Apple 은 ID 토큰에 이름을 주지 않아 실제로 발생한다. 닉네임 때문에 가입이 실패하면 안 된다. + assertEquals(DEFAULT_NICKNAME, User.withNickname(nickname).getNickname()); + } + + @Test + void 닉네임_앞뒤_공백은_제거한다() { + assertEquals("세빈", User.withNickname(" 세빈 ").getNickname()); + } + + @Test + void 닉네임이_컬럼_폭을_넘으면_잘라낸다() { + // provider 가 주는 값이라 우리가 길이를 통제하지 못한다 — 저장 단계 서버 오류로 새지 않게 경계에서 자른다. + String tooLong = "가".repeat(User.MAX_NICKNAME_LENGTH + 10); + + String nickname = User.withNickname(tooLong).getNickname(); + + assertEquals(User.MAX_NICKNAME_LENGTH, nickname.length()); + } + + @Test + void 이메일은_없을_수_있다() { + // Kakao 는 이메일 동의를 거부할 수 있고, Apple 은 최초 로그인에만 준다. 없는 것이 정상 경로다. + assertNull(User.of("세빈", null).getEmail()); + assertNull(User.of("세빈", " ").getEmail()); + } + + @Test + void 이메일_앞뒤_공백은_제거한다() { + assertEquals("user@example.com", User.of("세빈", " user@example.com ").getEmail()); + } + + @Test + void 이메일이_컬럼_폭을_넘으면_잘라낸다() { + // 형식을 강제하지 않는다 — provider 가 주는 값이라 우리가 통제하지 못하고, 이메일 하나 때문에 + // 가입이 실패하면 안 된다. 길이만 컬럼에 맞춘다. + String tooLong = "a".repeat(User.MAX_EMAIL_LENGTH + 10); + + assertEquals(User.MAX_EMAIL_LENGTH, User.of("세빈", tooLong).getEmail().length()); + } + + @Test + void 이름을_바꾸면_같은_정규화_규칙이_적용된다() { + User user = User.withNickname("세빈"); + + user.rename(" "); + + assertEquals(DEFAULT_NICKNAME, user.getNickname()); + } +} diff --git a/src/test/java/com/offway/core/user/infrastructure/kakao/StubKakaoProfileClient.java b/src/test/java/com/offway/core/user/infrastructure/kakao/StubKakaoProfileClient.java new file mode 100644 index 00000000..c62d9dc3 --- /dev/null +++ b/src/test/java/com/offway/core/user/infrastructure/kakao/StubKakaoProfileClient.java @@ -0,0 +1,71 @@ +package com.offway.core.user.infrastructure.kakao; + +import java.util.function.Function; + +/** + * 카카오 API 외부 경계 stub — 통합 테스트에서 {@code kapi.kakao.com} 호출을 격리한다. + * + *

port 를 stub 하므로 {@code KakaoIdentityVerifier}·{@code DelegatingSocialIdentityResolver} 는 실물이 돈다. + * 앱 번호 대조(우리 앱 토큰인가)도 실물 판단이라 여기서 흉내 내지 않는다 — stub 은 카카오가 무엇을 답했는지만 + * 정하고, 그것을 받아들일지는 검증기가 정한다. + * + *

default 동작은 throw 다 — 명시 세팅을 빠뜨린 테스트가 조용히 통과하지 않게. + */ +public class StubKakaoProfileClient implements KakaoProfileClient { + + /** + * 테스트에서 "우리 앱" 으로 취급하는 카카오 앱 번호. + * + *

{@code src/test/resources/application-local.properties} 의 {@code offway.auth.oidc.kakao.audiences} 와 + * 같은 값이어야 한다. 어긋나면 카카오 로그인 테스트가 전부 401 이 된다. + */ + public static final String OUR_APP_ID = "1234567"; + + private Function behavior = accessToken -> { + throw new IllegalStateException("StubKakaoProfileClient 미설정 — 테스트가 respond(...) 로 응답을 지정해야 합니다."); + }; + + /** 토큰 정보 조회 응답. 기본은 우리 앱이 발급한 토큰이다 — 앱 번호를 다루지 않는 테스트가 그 사실을 몰라도 되게. */ + private Function tokenInfoBehavior = ourAppTokenInfo(); + + private static Function ourAppTokenInfo() { + return accessToken -> new KakaoTokenInfo("token-info-id", OUR_APP_ID); + } + + /** + * 액세스 토큰에 따라 프로필 결과를 정하거나 예외를 던지도록 지정한다. + * + *

토큰 정보 응답도 기본값(우리 앱)으로 되돌린다. 이 stub 은 빈이라 클래스 안의 테스트들이 같은 + * 인스턴스를 공유하는데, 앞선 테스트가 남긴 "남의 앱 토큰" 이 뒤 테스트로 새면 엉뚱한 401 이 난다. 앱 번호를 + * 다루는 테스트는 이 호출 뒤에 지정한다. + */ + public void respond(Function behavior) { + this.behavior = behavior; + this.tokenInfoBehavior = ourAppTokenInfo(); + } + + /** 어떤 토큰이든 같은 프로필로 성공시킨다. 토큰 정보는 기본값(우리 앱)으로 되돌린다. */ + public void respondWith(String id, String nickname, String email) { + respond(accessToken -> new KakaoProfile(id, nickname, email)); + } + + /** 토큰 정보 조회 결과를 정한다 — 남의 앱 토큰·조회 실패를 흉내 낼 때 쓴다. */ + public void respondTokenInfo(Function tokenInfoBehavior) { + this.tokenInfoBehavior = tokenInfoBehavior; + } + + /** 이 토큰이 주어진 앱에서 발급된 것으로 답하게 한다. */ + public void respondTokenInfoFromApp(String appId) { + this.tokenInfoBehavior = accessToken -> new KakaoTokenInfo("token-info-id", appId); + } + + @Override + public KakaoProfile fetchProfile(String accessToken) { + return behavior.apply(accessToken); + } + + @Override + public KakaoTokenInfo fetchTokenInfo(String accessToken) { + return tokenInfoBehavior.apply(accessToken); + } +} diff --git a/src/test/java/com/offway/core/user/infrastructure/oidc/NimbusOidcVerifierTest.java b/src/test/java/com/offway/core/user/infrastructure/oidc/NimbusOidcVerifierTest.java new file mode 100644 index 00000000..2debc811 --- /dev/null +++ b/src/test/java/com/offway/core/user/infrastructure/oidc/NimbusOidcVerifierTest.java @@ -0,0 +1,62 @@ +package com.offway.core.user.infrastructure.oidc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.offway.core.user.config.AuthProperties; +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.UserErrorCode; +import com.offway.core.user.domain.UserException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class NimbusOidcVerifierTest { + + @ParameterizedTest + @EnumSource( + value = AuthProvider.class, + names = {"GOOGLE", "APPLE"}) + void 서명된_ID토큰을_주는_provider만_맡는다(AuthProvider provider) { + NimbusOidcVerifier verifier = new NimbusOidcVerifier(new AuthProperties(null, Map.of())); + + assertTrue(verifier.supports(provider)); + } + + @Test + void 카카오는_맡지_않는다() { + // 액세스 토큰에는 서명된 신원이 없어 공개키로 확인할 것이 없다. + NimbusOidcVerifier verifier = new NimbusOidcVerifier(new AuthProperties(null, Map.of())); + + assertFalse(verifier.supports(AuthProvider.KAKAO)); + } + + /** audience 가 비어 있으면 aud 검증이 무력화된다 — 네트워크에 나가기 전에 걸러야 한다. */ + @ParameterizedTest + @EnumSource( + value = AuthProvider.class, + names = {"GOOGLE", "APPLE"}) + void audience가_설정되지_않은_provider는_USER_002로_거부한다(AuthProvider provider) { + NimbusOidcVerifier verifier = new NimbusOidcVerifier(new AuthProperties(null, Map.of())); + + UserException exception = assertThrows(UserException.class, () -> verifier.verify(provider, "any-token")); + + assertEquals(UserErrorCode.UNSUPPORTED_PROVIDER, exception.errorCode()); + } + + @Test + void 다른_provider가_설정돼_있어도_요청한_provider가_비면_거부한다() { + AuthProperties properties = new AuthProperties( + null, Map.of(AuthProvider.GOOGLE, new AuthProperties.Oidc(List.of("google-web-client-id"), null))); + NimbusOidcVerifier verifier = new NimbusOidcVerifier(properties); + + UserException exception = + assertThrows(UserException.class, () -> verifier.verify(AuthProvider.APPLE, "any-token")); + + assertEquals(UserErrorCode.UNSUPPORTED_PROVIDER, exception.errorCode()); + } +} diff --git a/src/test/java/com/offway/core/user/infrastructure/oidc/OidcTokenValidationTest.java b/src/test/java/com/offway/core/user/infrastructure/oidc/OidcTokenValidationTest.java new file mode 100644 index 00000000..df63eaaa --- /dev/null +++ b/src/test/java/com/offway/core/user/infrastructure/oidc/OidcTokenValidationTest.java @@ -0,0 +1,109 @@ +package com.offway.core.user.infrastructure.oidc; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; + +/** + * ID 토큰 검증(#34) — 남의 앱 토큰으로 로그인되지 않는가. + * + *

이 셋이 뚫리면 계정 탈취가 된다. {@code aud} 를 안 보면 다른 앱에서 발급된 토큰을 그대로 받아주고, + * {@code iss} 를 안 보면 아무나 서명한 토큰이 통하며, 만료를 안 보면 유출된 옛 토큰이 영원히 산다. + * + *

검증이 있다는 것만으로는 부족하다. 지우거나 느슨하게 바꿔도 아무 테스트가 깨지지 않으면 그 검증은 + * 다음 리팩터링에서 조용히 사라진다. 여기서 거절되는 것을 직접 확인한다. + * + *

서명 자체는 {@code NimbusJwtDecoder} 가 JWKS 로 확인하므로 이 테스트의 대상이 아니다 — 그건 라이브러리가 + * 보장하고, 우리가 얹은 것은 issuer·audience 다. 만료는 기본 검증에 포함돼 함께 확인한다. + */ +class OidcTokenValidationTest { + + private static final String OUR_APP = "our-client-id.apps.googleusercontent.com"; + private static final String OTHER_APP = "someone-else.apps.googleusercontent.com"; + + /** Google 이 실제로 쓰는 두 표기. 하나만 허용하면 다른 표기를 받은 사용자가 전부 401 이 된다. */ + private static final List ISSUERS = List.of("https://accounts.google.com", "accounts.google.com"); + + private static boolean accepts(Jwt token) { + return !NimbusOidcVerifier.tokenValidator(ISSUERS, List.of(OUR_APP)) + .validate(token) + .hasErrors(); + } + + private static Jwt.Builder token() { + Instant now = Instant.now(); + return Jwt.withTokenValue("ignored") + .header("alg", "RS256") + .issuedAt(now) + .expiresAt(now.plus(1, ChronoUnit.HOURS)) + .claim(JwtClaimNames.ISS, "https://accounts.google.com") + .audience(List.of(OUR_APP)) + .subject("provider-user-id"); + } + + @ParameterizedTest + @ValueSource(strings = {"https://accounts.google.com", "accounts.google.com"}) + void 우리가_아는_iss_표기는_받는다(String issuer) { + assertTrue(accepts(token().claim(JwtClaimNames.ISS, issuer).build())); + } + + @Test + void 모르는_iss_는_거절한다() { + assertFalse(accepts(token().claim(JwtClaimNames.ISS, "https://evil.example.com").build())); + } + + @Test + void iss_가_없으면_거절한다() { + // 클레임을 통째로 비워 오는 토큰도 있다. 없는 것을 "일치" 로 흘리면 검증이 없는 것과 같다. + assertFalse(accepts(token().claims(claims -> claims.remove(JwtClaimNames.ISS)).build())); + } + + @Test + void 남의_앱_토큰은_거절한다() { + // 이 검증이 없으면 공격자가 자기 앱에서 피해자 토큰을 받아 그대로 우리 서버에 던질 수 있다. + assertFalse(accepts(token().audience(List.of(OTHER_APP)).build())); + } + + @Test + void 우리_앱이_섞여_있으면_받는다() { + // aud 는 복수로 올 수 있다. 하나라도 우리 것이면 우리 앱을 위해 발급된 토큰이다. + assertTrue(accepts(token().audience(List.of(OTHER_APP, OUR_APP)).build())); + } + + @Test + void aud_가_없으면_거절한다() { + assertFalse(accepts(token().claims(claims -> claims.remove(JwtClaimNames.AUD)).build())); + } + + @Test + void 만료된_토큰은_거절한다() { + // 유출된 옛 토큰이 영원히 사는 것을 막는다. 기본 검증에 포함돼 있지만 그것 역시 지워질 수 있다. + Instant past = Instant.now().minus(2, ChronoUnit.HOURS); + + assertFalse(accepts(token().issuedAt(past).expiresAt(past.plus(1, ChronoUnit.HOURS)).build())); + } + + @Test + void audience가_비면_모든_토큰을_거절한다() { + // 설정이 빠진 채로 뜨면 aud 검증이 무력해진다 — 그때는 아무도 통과하지 못하는 쪽이 안전하다. + Jwt valid = token().build(); + + assertTrue(NimbusOidcVerifier.tokenValidator(ISSUERS, List.of()) + .validate(valid) + .hasErrors()); + } + + @Test + void 정상_토큰은_통과한다() { + // 위 거절들이 "전부 거절" 이라서 통과하는 것이 아님을 보인다. + assertTrue(accepts(token().build())); + } +} diff --git a/src/test/java/com/offway/core/user/infrastructure/social/DelegatingSocialIdentityResolverTest.java b/src/test/java/com/offway/core/user/infrastructure/social/DelegatingSocialIdentityResolverTest.java new file mode 100644 index 00000000..67bd23cd --- /dev/null +++ b/src/test/java/com/offway/core/user/infrastructure/social/DelegatingSocialIdentityResolverTest.java @@ -0,0 +1,59 @@ +package com.offway.core.user.infrastructure.social; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.SocialIdentity; +import com.offway.core.user.domain.UserErrorCode; +import com.offway.core.user.domain.UserException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DelegatingSocialIdentityResolverTest { + + @Test + void 맡는_전략에_위임한다() { + SocialIdentityResolver resolver = new DelegatingSocialIdentityResolver( + List.of(verifierFor(AuthProvider.GOOGLE, "google-user"), verifierFor(AuthProvider.KAKAO, "kakao-user"))); + + assertEquals("kakao-user", resolver.resolve(AuthProvider.KAKAO, "token").providerUserId()); + } + + @Test + void 맡는_전략이_없으면_USER_002다() { + // provider 는 아는데 검증 수단이 등록되지 않은 상태 — 조용히 통과시키면 신원 없이 로그인이 된다. + SocialIdentityResolver resolver = + new DelegatingSocialIdentityResolver(List.of(verifierFor(AuthProvider.GOOGLE, "google-user"))); + + UserException exception = + assertThrows(UserException.class, () -> resolver.resolve(AuthProvider.APPLE, "token")); + + assertEquals(UserErrorCode.UNSUPPORTED_PROVIDER, exception.errorCode()); + } + + @Test + void 전략이_하나도_없어도_터지지_않고_USER_002로_끊는다() { + SocialIdentityResolver resolver = new DelegatingSocialIdentityResolver(List.of()); + + UserException exception = + assertThrows(UserException.class, () -> resolver.resolve(AuthProvider.GOOGLE, "token")); + + assertEquals(UserErrorCode.UNSUPPORTED_PROVIDER, exception.errorCode()); + } + + private static SocialIdentityVerifier verifierFor(AuthProvider supported, String providerUserId) { + return new SocialIdentityVerifier() { + + @Override + public boolean supports(AuthProvider provider) { + return provider == supported; + } + + @Override + public SocialIdentity verify(AuthProvider provider, String credential) { + return new SocialIdentity(provider, providerUserId, null, null); + } + }; + } +} diff --git a/src/test/java/com/offway/core/user/infrastructure/social/StubSocialIdentityVerifier.java b/src/test/java/com/offway/core/user/infrastructure/social/StubSocialIdentityVerifier.java new file mode 100644 index 00000000..a7d741d5 --- /dev/null +++ b/src/test/java/com/offway/core/user/infrastructure/social/StubSocialIdentityVerifier.java @@ -0,0 +1,50 @@ +package com.offway.core.user.infrastructure.social; + +import com.offway.core.user.domain.AuthProvider; +import com.offway.core.user.domain.SocialIdentity; +import java.util.function.BiFunction; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +/** + * 서명 검증 provider(Apple · Google) 외부 경계 stub — 통합 테스트에서 JWKS 호출을 격리한다. + * + *

{@code @Primary} 가 아니라 {@code @Order} 로 실물을 이긴다. {@code DelegatingSocialIdentityResolver} + * 는 전략을 {@code List} 로 주입받아 먼저 맞는 것을 쓰는데, 그 목록 순서는 {@code @Primary} 가 아니라 + * {@code @Order} 가 정한다. 최우선 순위를 줘야 실물 {@code NimbusOidcVerifier} 보다 앞에 선다. + * + *

카카오는 여기서 맡지 않는다 — 그쪽 외부 경계는 {@code KakaoProfileClient} 라, 그 port 를 stub 해야 + * {@code KakaoIdentityVerifier} 의 판단(미설정 차단·응답 매핑)이 실물로 검증된다. + * + *

default 동작은 throw 다. 검증 경로에 닿는 테스트가 {@code respond(...)} 로 시나리오를 지정하지 않으면 즉시 + * 깨지게 해 "이전 테스트 상태가 살아남는" 함정을 막는다. + */ +@Order(Ordered.HIGHEST_PRECEDENCE) +public class StubSocialIdentityVerifier implements SocialIdentityVerifier { + + private BiFunction behavior = (provider, credential) -> { + throw new IllegalStateException( + "StubSocialIdentityVerifier 미설정 — 테스트가 respond(...) 로 검증 동작을 지정해야 합니다."); + }; + + @Override + public boolean supports(AuthProvider provider) { + return provider.oidc().isPresent(); + } + + /** provider·토큰에 따라 결과를 정하거나 예외를 던지도록 지정한다. */ + public void respond(BiFunction behavior) { + this.behavior = behavior; + } + + /** 어떤 요청이든 같은 신원으로 검증 성공시킨다. */ + public void respondWith(AuthProvider provider, String providerUserId, String nickname, String email) { + this.behavior = + (requestedProvider, credential) -> new SocialIdentity(provider, providerUserId, nickname, email); + } + + @Override + public SocialIdentity verify(AuthProvider provider, String credential) { + return behavior.apply(provider, credential); + } +} diff --git a/src/test/resources/application-local.properties b/src/test/resources/application-local.properties index 52439f16..52529a48 100644 --- a/src/test/resources/application-local.properties +++ b/src/test/resources/application-local.properties @@ -32,6 +32,19 @@ spring.datasource.hikari.minimum-idle=0 offway.security.basic.username=${OFFWAY_BASIC_USERNAME:dev} offway.security.basic.password=${OFFWAY_BASIC_PASSWORD:{noop}dev} +# 인증(#34) — 이 파일이 main 의 application-local.properties 를 가리므로 거기 있는 값을 다시 적는다. +# 없으면 TokenIssuer 가 "서명키 없음"으로 부팅을 막아 통합 테스트가 전부 컨텍스트 로드에서 깨진다. +# 테스트 전용 고정값이라 실제 비밀이 아니다(HS256 최소 32바이트). +offway.auth.jwt.secret=offway-test-only-signing-key-not-a-real-secret + +# 카카오 로그인 경로를 테스트에서 열어둔다 — 미설정이면 프로필 조회 전에 USER-002 로 끊긴다. +# 실제 호출은 StubKakaoProfileClient 가 가로채므로 이 값이 카카오로 나가지 않는다. +offway.auth.oidc.kakao.rest-api-key=test-kakao-rest-api-key + +# 우리 앱으로 취급할 카카오 앱 번호. StubKakaoProfileClient.OUR_APP_ID 와 같은 값이어야 한다 — +# 그 stub 이 "우리 앱이 발급한 토큰" 을 흉내 낼 때 쓰는 번호다. +offway.auth.oidc.kakao.audiences=1234567 + # Flyway spring.flyway.enabled=true spring.flyway.out-of-order=true