diff --git a/docs/ai/features.md b/docs/ai/features.md index b95c4518..cec279aa 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -51,8 +51,8 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| | `[x]` | 구글 OAuth 로그인 | Google 계정으로 소셜 로그인 | users | -| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드의 문서 타입별 버전 리소스 파일에 보존하고, 현재 버전은 `application.yaml`의 `app.agreements.*.current-version`에서 관리한다 | - | -| `[x]` | 가입 약관 동의 기록 | `POST /auth/google/login`에서 프론트는 `agreementsAccepted`만 전송한다. 신규 사용자는 값이 `true`일 때만 생성되며, 백엔드 현재 약관 버전과 서버 시간을 `users`에 저장한다 | users | +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드 리소스 파일에 있고 현재 버전은 `application.yaml`에서 관리한다 | - | +| `[x]` | 가입 약관 동의 기록 | Google 인가 코드는 1회만 사용할 수 있으므로 `POST /auth/google/login`에서 신규 사용자이고 `agreementsAccepted=true`가 아니면 쿠키 없이 `SIGNUP_REQUIRED`와 10분짜리 `signupToken`을 반환한다. 프론트는 약관 동의 후 `POST /auth/google/signup`에 `signupToken`과 `agreementsAccepted=true`를 보내 가입을 완료하며, 백엔드는 현재 약관 버전과 서버 시간을 `users`에 저장하고 인증 쿠키를 발급한다. 신규 사용자가 첫 로그인 요청부터 `agreementsAccepted=true`를 보내면 즉시 생성한다 | users, Redis | | `[x]` | 약관 재동의 | 기존 사용자의 저장 약관 버전이 현재 서버 버전과 다르면 로그인 시 재동의가 필요하다. 로그인 요청에서 `agreementsAccepted=true`이면 서버 현재 버전으로 갱신 후 토큰을 발급하고, 이미 로그인된 사용자는 `POST /api/users/me/agreements`로 현재 약관에 재동의한다 | users | | `[x]` | 토큰 재발급 (Refresh) | Refresh Token Rotation: UUID 기반 HTTP-only 쿠키(path=/auth/refresh), Redis `refresh:token:{uuid}`→userId(TTL 14일) / `refresh:user:{userId}`→Set\. Replay Detection 으로 탈취 시 전체 무효화 | Redis | | `[x]` | 로그아웃 | 단일 기기 로그아웃: 요청한 토큰만 삭제 | Redis | diff --git a/docs/superpowers/plans/2026-06-08-google-signup-token.md b/docs/superpowers/plans/2026-06-08-google-signup-token.md new file mode 100644 index 00000000..51a923cf --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-google-signup-token.md @@ -0,0 +1,266 @@ +# Google Signup Token Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Google OAuth code를 한 번만 사용하면서 신규 사용자가 약관 동의 후 `signupToken`으로 가입을 완료하게 만든다. + +**Architecture:** 기존 `/auth/google/login`은 유지하고, 신규 사용자 약관 미동의 branch만 `SIGNUP_REQUIRED` 응답으로 바꾼다. Redis `StringRedisTemplate` 기반 `GoogleSignupTokenService`가 10분 TTL 임시 Google 사용자 정보를 저장하며, `POST /auth/google/signup`이 해당 토큰으로 가입과 쿠키 발급을 완료한다. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, Spring MVC, Spring Data Redis `StringRedisTemplate`, Jackson, JUnit 5, Mockito, MockMvc, Testcontainers Redis. + +--- + +## File Map + +- Create: `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginResponse.java` + - `SIGNUP_REQUIRED` 응답 body. +- Create: `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleSignupRequest.java` + - 가입 완료 요청 body. +- Create: `src/main/java/com/howaboutus/backend/auth/service/GoogleSignupTokenService.java` + - Redis 임시 토큰 생성, 조회, 최신 토큰 무효화, 정리. +- Create: `src/main/java/com/howaboutus/backend/auth/service/dto/GoogleSignupPayload.java` + - Redis에 저장할 Google 사용자 정보. +- Create: `src/main/java/com/howaboutus/backend/auth/service/dto/GoogleLoginOutcome.java` + - 로그인 서비스 결과를 `AUTHENTICATED` 또는 `SIGNUP_REQUIRED`로 표현. +- Modify: `src/main/java/com/howaboutus/backend/auth/service/AuthService.java` + - 신규 미동의 사용자 branch에서 signup token 발급. + - signup token으로 가입 완료. +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/AuthController.java` + - login 응답 body branch와 signup endpoint 추가. +- Modify: `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` + - `GOOGLE_SIGNUP_TOKEN_NOT_FOUND` 추가. +- Modify: `src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java` + - 서비스 branch 테스트. +- Create: `src/test/java/com/howaboutus/backend/auth/service/GoogleSignupTokenServiceTest.java` + - Redis token service 단위 테스트. +- Modify: `src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java` + - MockMvc API 계약 테스트. +- Modify: `src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java` + - Redis + DB + MockMvc 가입 준비/완료 통합 테스트. +- Modify: `docs/ai/features.md` + - auth 기능 문서 갱신. + +## Task 1: Redis Signup Token Service + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/auth/service/dto/GoogleSignupPayload.java` +- Create: `src/main/java/com/howaboutus/backend/auth/service/GoogleSignupTokenService.java` +- Create: `src/test/java/com/howaboutus/backend/auth/service/GoogleSignupTokenServiceTest.java` +- Modify: `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` + +- [ ] **Step 1: failing test 작성** + +`GoogleSignupTokenServiceTest`에 다음 케이스를 만든다. + +```java +@Test +@DisplayName("같은 providerId로 새 signup token을 만들면 이전 token은 무효화된다") +void createInvalidatesPreviousTokenForProvider() { + GoogleSignupPayload payload = new GoogleSignupPayload("google-1", "a@test.com", "사용자", null); + + String oldToken = tokenService.create(payload); + String newToken = tokenService.create(payload); + + assertThatThrownBy(() -> tokenService.consume(oldToken)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GOOGLE_SIGNUP_TOKEN_NOT_FOUND); + assertThat(tokenService.consume(newToken)).isEqualTo(payload); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.GoogleSignupTokenServiceTest +``` + +Expected: class 또는 symbol not found로 FAIL. + +- [ ] **Step 3: 최소 구현** + +`GoogleSignupPayload` record를 만들고 `GoogleSignupTokenService`를 구현한다. Redis key는 `auth:google:signup:token:`와 `auth:google:signup:user:`를 사용하고 TTL은 `Duration.ofMinutes(10)`으로 고정한다. `consume`은 payload를 읽은 뒤 token key와 provider index를 삭제한다. + +- [ ] **Step 4: 통과 확인** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.GoogleSignupTokenServiceTest +``` + +Expected: PASS. + +## Task 2: AuthService Login Outcome + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/auth/service/dto/GoogleLoginOutcome.java` +- Modify: `src/main/java/com/howaboutus/backend/auth/service/AuthService.java` +- Modify: `src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java` + +- [ ] **Step 1: failing service tests 작성** + +`AuthServiceTest`에 신규 사용자 약관 미동의 케이스를 추가한다. `authService.googleLogin("auth-code", false)`가 `SIGNUP_REQUIRED` outcome을 반환하고 token 생성 payload가 Google user info와 일치해야 한다. + +- [ ] **Step 2: 실패 확인** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.AuthServiceTest +``` + +Expected: return type mismatch 또는 missing method로 FAIL. + +- [ ] **Step 3: 최소 구현** + +`googleLogin` return type을 `GoogleLoginOutcome`으로 바꾸고, 인증 완료는 `GoogleLoginOutcome.authenticated(LoginResult)`, 가입 필요는 `GoogleLoginOutcome.signupRequired(signupToken, 600)`으로 반환한다. 기존 refresh/logout은 변경하지 않는다. + +- [ ] **Step 4: 통과 확인** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.AuthServiceTest +``` + +Expected: PASS. + +## Task 3: Controller API Contract + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginResponse.java` +- Create: `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleSignupRequest.java` +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/AuthController.java` +- Modify: `src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java` + +- [ ] **Step 1: failing MockMvc tests 작성** + +`AuthControllerTest`에 다음을 추가한다. + +- login이 `SIGNUP_REQUIRED` outcome이면 `200 OK`, JSON body, 쿠키 없음. +- signup 성공이면 `200 OK`, access/refresh 쿠키 있음. +- signup token 에러면 `401`. + +- [ ] **Step 2: 실패 확인** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.controller.AuthControllerTest +``` + +Expected: endpoint 없음 또는 response mismatch로 FAIL. + +- [ ] **Step 3: 최소 구현** + +`googleLogin`은 outcome type에 따라 쿠키 발급 또는 `GoogleLoginResponse.signupRequired(...)` body를 반환한다. `googleSignup`은 `AuthService.googleSignup(signupToken, agreementsAccepted)`를 호출하고 기존 cookie builder를 재사용한다. + +- [ ] **Step 4: 통과 확인** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.controller.AuthControllerTest +``` + +Expected: PASS. + +## Task 4: Integration Coverage + +**Files:** +- Modify: `src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java` + +- [ ] **Step 1: failing integration tests 작성** + +통합 테스트에 다음을 추가한다. + +- 신규 사용자가 약관 동의 없이 login하면 `SIGNUP_REQUIRED`와 token을 받는다. +- 해당 token으로 signup하면 쿠키가 발급된다. +- 같은 providerId로 login을 두 번 하면 이전 token signup은 `401`, 최신 token signup은 성공한다. + +- [ ] **Step 2: 실패 확인** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.AuthIntegrationTest +``` + +Expected: 현재 구현 누락 지점에서 FAIL. + +- [ ] **Step 3: 필요한 구현 보정** + +Redis 직렬화, token consume cleanup, 동시 가입 race에서 `UserService.getOrCreateGoogleUser`가 기존 안전장치를 유지하는지 확인하고 부족한 부분만 보정한다. + +- [ ] **Step 4: 통과 확인** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.AuthIntegrationTest +``` + +Expected: PASS. + +## Task 5: API Docs And Feature Docs + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/AuthController.java` +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java` +- Modify: `docs/ai/features.md` + +- [ ] **Step 1: Swagger 설명 갱신** + +`/auth/google/login` 설명에 `SIGNUP_REQUIRED` branch를 명시하고 `/auth/google/signup` operation과 error codes를 추가한다. + +- [ ] **Step 2: 기능 문서 갱신** + +`docs/ai/features.md`의 "가입 약관 동의 기록" 행을 signup token 흐름으로 갱신한다. `docs/ai/erd.md`는 변경하지 않는다. + +- [ ] **Step 3: MD conflict check** + +Run: + +```bash +rg -n '`docs/ai/[^`]*\.md`|`[A-Z][A-Z_]*\.md`' -g '*.md' +rg -n 'Doc Update Rules|문서 갱신 규칙|갱신 규칙' AGENTS.md docs/ai/README.md docs/ai/plugin-guidelines.md CONTRIBUTING.md +``` + +Expected: 새 dead reference 없음, 갱신 규칙 소유권은 `AGENTS.md` 유지. + +## Task 6: Final Verification + +**Files:** all changed files + +- [ ] **Step 1: focused tests** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.GoogleSignupTokenServiceTest --tests com.howaboutus.backend.auth.service.AuthServiceTest --tests com.howaboutus.backend.auth.controller.AuthControllerTest --tests com.howaboutus.backend.auth.AuthIntegrationTest +``` + +Expected: PASS. + +- [ ] **Step 2: compile and style** + +Run: + +```bash +./gradlew compileJava +./gradlew checkstyleMain checkstyleTest +``` + +Expected: PASS. + +- [ ] **Step 3: status 확인** + +Run: + +```bash +git status --short +``` + +Expected: 의도한 auth/docs/test 변경만 표시. diff --git a/docs/superpowers/specs/2026-06-08-google-signup-token-design.md b/docs/superpowers/specs/2026-06-08-google-signup-token-design.md new file mode 100644 index 00000000..336caf43 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-google-signup-token-design.md @@ -0,0 +1,207 @@ +# Design: Google OAuth 가입 임시 토큰 흐름 + +Generated by superpowers:brainstorming on 2026-06-08 +Branch: refactor/login-agreement +Status: APPROVED + +## 문제 상황 + +Google OAuth 인가 코드는 한 번만 사용할 수 있다. 현재 가입 흐름은 +`POST /auth/google/login`에서 먼저 `code`를 Google token으로 교환한 뒤에야 해당 Google +계정이 기존 회원인지 신규 회원인지 판단한다. 신규 회원이면 프론트가 약관 동의 화면으로 +이동하는데, 이후 기존 `code`로 같은 요청을 다시 보내면 Google이 code 재사용을 거부한다. + +이 리팩터링은 기존 Google 로그인 진입점은 유지하면서, code를 한 번 교환한 뒤에도 신규 +사용자가 약관을 확인하고 동의한 다음 가입을 완료할 수 있게 만드는 것이 목적이다. + +## 현재 동작 + +`POST /auth/google/login`은 `code`와 선택적 `agreementsAccepted`를 받는다. + +- 백엔드는 `code`를 Google에 교환하고 `providerId`, email, nickname, profile image를 추출한다. +- 활성 Google 회원이 있고 약관 재동의가 필요 없으면 access/refresh token 쿠키를 발급한다. +- 활성 Google 회원이 있지만 저장된 약관 버전이 오래됐으면 요청에 `agreementsAccepted=true`가 + 있어야 한다. 없으면 `AGREEMENTS_REACCEPTANCE_REQUIRED`를 반환한다. +- 회원이 없으면 요청에 `agreementsAccepted=true`가 있어야 한다. 없으면 Google code를 이미 + 소비한 뒤 `AGREEMENTS_NOT_ACCEPTED`를 반환한다. + +마지막 케이스가 현재 깨지는 흐름이다. + +## 목표 + +- Google 인가 코드는 한 번만 소비한다. +- 신규 사용자가 login 시도 이후 약관 화면으로 이동해도 교환된 Google 사용자 정보를 잃지 않는다. +- 기존 회원 로그인 동작은 유지한다. +- 변경 범위는 auth 흐름, Redis 임시 상태, API 문서, 집중 테스트로 제한한다. +- PostgreSQL 스키마는 변경하지 않는다. + +## 비목표 + +- OAuth 흐름 전체를 재설계하거나 별도 OAuth prepare endpoint를 도입하지 않는다. +- 기존 refresh token rotation 동작은 변경하지 않는다. +- 약관 원문이나 버전을 DB로 옮기지 않는다. +- 인증된 사용자의 `POST /api/users/me/agreements` 재동의 endpoint는 변경하지 않는다. + +## API 계약 + +### `POST /auth/google/login` + +요청 형식은 유지한다. + +```json +{ + "code": "google-authorization-code", + "agreementsAccepted": true +} +``` + +이 endpoint는 세 가지 성공 결과를 가진다. + +1. 현재 약관에 동의한 기존 회원 + - `200 OK` + - access/refresh 쿠키 발급 + - response body 없음 + +2. 약관 버전이 오래됐고 `agreementsAccepted=true`를 보낸 기존 회원 + - 저장 약관 버전을 서버의 현재 버전으로 갱신 + - `200 OK` + - access/refresh 쿠키 발급 + - response body 없음 + +3. `agreementsAccepted=true`를 보내지 않은 신규 회원 + - `200 OK` + - 인증 쿠키 발급 없음 + - response body: + +```json +{ + "status": "SIGNUP_REQUIRED", + "signupToken": "8fe5d1a7-9b6a-4a67-9123-6d7f79c078e2", + "expiresInSeconds": 600 +} +``` + +신규 사용자가 첫 login 요청부터 `agreementsAccepted=true`를 보내면, 현재 동작처럼 즉시 +회원을 생성하고 인증 쿠키를 발급한다. + +### `POST /auth/google/signup` + +새 endpoint는 약관 화면 이후 가입을 완료한다. + +요청: + +```json +{ + "signupToken": "8fe5d1a7-9b6a-4a67-9123-6d7f79c078e2", + "agreementsAccepted": true +} +``` + +성공 시 동작: + +- `agreementsAccepted=true`를 검증한다. +- Redis에서 임시 Google 사용자 정보를 조회한다. +- 활성 Google 사용자를 생성하거나 이미 생성된 사용자를 조회한다. +- `users`에 서버 기준 현재 약관 버전과 서버 시간을 저장한다. +- 임시 signup token 상태를 삭제한다. +- `200 OK`를 반환한다. +- access/refresh 쿠키를 발급한다. +- response body는 없다. + +## 임시 토큰 저장 방식 + +기존 auth Redis 스타일에 맞춰 `StringRedisTemplate`을 사용한다. + +Redis key: + +- `auth:google:signup:token:{uuid}` -> 직렬화된 Google signup payload, TTL 10분 +- `auth:google:signup:user:{providerId}` -> 최신 `{uuid}`, TTL 10분 + +signup payload에는 사용자 생성에 필요한 최소 정보만 저장한다. + +- Google provider ID +- email +- nickname +- profile image URL + +서버는 opaque UUID signup token을 생성한다. 프론트는 token 값에서 의미를 추론하지 않는다. + +## 중복 로그인 정책 + +Google provider ID 하나당 가장 최근에 발급된 pending signup token만 유효하다. + +같은 미가입 Google 계정으로 fresh Google code를 받아 login을 다시 시도하면: + +1. `auth:google:signup:user:{providerId}`를 조회한다. +2. 이전 UUID가 있으면 `auth:google:signup:token:{oldUuid}`를 삭제한다. +3. 새 token payload를 저장하고 provider ID index를 새 UUID로 갱신한다. + +프론트가 이후 오래된 signup token으로 가입 완료를 요청하면, 백엔드는 해당 token을 없거나 +만료된 token처럼 처리한다. 클라이언트 모델은 단순하게 "마지막 login 시도만 유효"로 정한다. + +## 에러 처리 + +- Google code 교환 실패는 기존 `GOOGLE_AUTH_FAILED`를 유지한다. +- 가입 완료 요청에서 `agreementsAccepted`가 없거나 false이면 기존 `AGREEMENTS_NOT_ACCEPTED`를 사용한다. +- 없거나, 만료됐거나, 형식이 잘못됐거나, 새 token 발급으로 무효화된 signup token은 새 + `401 UNAUTHORIZED` 도메인 에러 `GOOGLE_SIGNUP_TOKEN_NOT_FOUND`를 반환한다. +- 기존 회원이 오래된 약관 상태에서 `agreementsAccepted=true` 없이 login하면 기존 + `AGREEMENTS_REACCEPTANCE_REQUIRED`를 유지한다. +- Redis 장애는 fail-open하지 않는다. 이 흐름은 보안상 민감한 임시 신원 정보를 저장하므로, + 불완전한 상태로 사용자를 생성하기보다 가입 준비 또는 완료를 실패시키는 쪽을 선택한다. + +## 구성 요소 + +- `AuthController` + - `/auth/google/login` return type을 body 없음 또는 `SIGNUP_REQUIRED` body를 표현할 수 있게 변경한다. + - `/auth/google/signup`을 추가한다. + - login, refresh, signup completion에서 쿠키 생성 로직을 공유한다. + +- `AuthService` + - 기존 사용자 login과 약관 재동의 동작을 유지한다. + - 인증 완료 또는 signup 필요 상태를 표현할 수 있는 service result를 반환한다. + - `signupToken` 기반 가입 완료를 처리한다. + +- `GoogleSignupTokenService` + - Redis key, UUID 생성, TTL, 직렬화, 조회, 최신 token 무효화, 정리를 담당한다. + +- DTO + - `SIGNUP_REQUIRED` branch용 `GoogleLoginResponse` + - 가입 완료 요청용 `GoogleSignupRequest` + - login outcome과 임시 Google signup payload용 service DTO + +## 문서 영향 + +- `POST /auth/google/login`의 `SIGNUP_REQUIRED` response body branch를 Swagger/OpenAPI에 문서화한다. +- `POST /auth/google/signup` Swagger/OpenAPI 명세를 추가한다. +- `docs/ai/features.md` 인증 섹션에 signup token 기반 약관 동의 완료와 Google code 단회 사용 제약을 반영한다. +- DB 테이블, 컬럼, 관계, 제약조건 변경이 없으므로 `docs/ai/erd.md`는 갱신하지 않는다. + +## 테스트 전략 + +집중 테스트는 다음 케이스를 포함한다. + +- 신규 사용자가 약관 동의 없이 login하면 `SIGNUP_REQUIRED`, 인증 쿠키 없음, signup token을 받는다. +- 유효한 token과 `agreementsAccepted=true`로 가입 완료하면 사용자를 생성하고 두 쿠키를 발급한다. +- 가입 완료 요청에서 약관 동의가 false이거나 누락되면 `AGREEMENTS_NOT_ACCEPTED`를 반환한다. +- 만료, 누락, malformed, superseded signup token은 `GOOGLE_SIGNUP_TOKEN_NOT_FOUND`를 반환한다. +- 같은 미가입 provider ID로 login을 반복하면 이전 token은 무효화되고 최신 token만 유효하다. +- 기존 사용자 login은 현재 동작과 호환된다. +- 기존 사용자 약관 재동의 흐름은 현재 동작과 호환된다. + +최소 실행: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.AuthServiceTest --tests com.howaboutus.backend.auth.controller.AuthControllerTest +./gradlew compileJava +``` + +integration coverage를 변경하면 관련 auth integration test class도 실행한다. + +## 성공 기준 + +- 프론트는 약관 확인 이후 Google 인가 코드를 재사용할 필요가 없다. +- 신규 가입자는 백엔드가 발급한 `signupToken`으로 약관 동의 후 가입을 완료할 수 있다. +- pending signup 상태는 10분 뒤 만료된다. +- 같은 Google 계정에는 가장 최근 pending signup token만 유효하다. +- 기존 login, refresh, logout, 약관 재동의 동작은 보존된다. diff --git a/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java b/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java index 81e249ef..1c9b6f1e 100644 --- a/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java +++ b/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java @@ -13,7 +13,10 @@ import org.springframework.web.bind.annotation.RestController; import com.howaboutus.backend.auth.controller.dto.GoogleLoginRequest; +import com.howaboutus.backend.auth.controller.dto.GoogleLoginResponse; +import com.howaboutus.backend.auth.controller.dto.GoogleSignupRequest; import com.howaboutus.backend.auth.service.AuthService; +import com.howaboutus.backend.auth.service.dto.GoogleLoginOutcome; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.common.config.properties.CookieProperties; import com.howaboutus.backend.common.config.properties.JwtProperties; @@ -25,6 +28,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -46,9 +50,16 @@ public class AuthController { @Operation( summary = "Google 로그인", description = "Google OAuth2 인가 코드를 사용하여 로그인을 진행하고, 쿠키에 액세스 토큰 및 리프레시 토큰을 심어 반환합니다. " - + "신규 가입자와 변경된 약관에 다시 동의해야 하는 기존 사용자는 agreementsAccepted=true를 보내야 합니다." + + "기존 회원은 로그인 성공 시 응답 본문 없이 인증 쿠키를 받습니다. " + + "신규 가입자가 agreementsAccepted=true를 보내지 않으면 Google code를 재사용하지 않도록 " + + "SIGNUP_REQUIRED 응답과 10분짜리 signupToken을 반환합니다. " + + "변경된 약관에 다시 동의해야 하는 기존 사용자는 agreementsAccepted=true를 보내야 합니다." + ) + @ApiResponse( + responseCode = "200", + description = "로그인 성공 또는 신규 가입 약관 동의 필요", + content = @Content(schema = @Schema(implementation = GoogleLoginResponse.class)) ) - @ApiResponse(responseCode = "200", description = "로그인 성공") @ApiErrorCodes({ ErrorCode.GOOGLE_AUTH_FAILED, ErrorCode.AGREEMENTS_NOT_ACCEPTED, @@ -56,8 +67,35 @@ public class AuthController { }) @Loggable @PostMapping("/google/login") - public ResponseEntity googleLogin(@RequestBody GoogleLoginRequest request) { - LoginResult result = authService.googleLogin(request.code(), request.agreementsAccepted()); + public ResponseEntity googleLogin(@RequestBody GoogleLoginRequest request) { + GoogleLoginOutcome outcome = authService.googleLogin(request.code(), request.agreementsAccepted()); + if (outcome.signupRequired()) { + return ResponseEntity.ok(GoogleLoginResponse.signupRequired( + outcome.signupToken(), + outcome.expiresInSeconds() + )); + } + + LoginResult result = outcome.loginResult(); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, buildAccessTokenCookie(result.accessToken()).toString()) + .header(HttpHeaders.SET_COOKIE, buildRefreshTokenCookie(result.refreshToken()).toString()) + .build(); + } + + @Operation( + summary = "Google 가입 완료", + description = "Google 로그인에서 SIGNUP_REQUIRED 응답으로 받은 signupToken과 약관 동의 여부를 사용해 신규 가입을 완료하고 인증 쿠키를 발급합니다." + ) + @ApiResponse(responseCode = "200", description = "가입 완료", content = @Content) + @ApiErrorCodes({ + ErrorCode.AGREEMENTS_NOT_ACCEPTED, + ErrorCode.GOOGLE_SIGNUP_TOKEN_NOT_FOUND + }) + @Loggable + @PostMapping("/google/signup") + public ResponseEntity googleSignup(@RequestBody GoogleSignupRequest request) { + LoginResult result = authService.googleSignup(request.signupToken(), request.agreementsAccepted()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, buildAccessTokenCookie(result.accessToken()).toString()) .header(HttpHeaders.SET_COOKIE, buildRefreshTokenCookie(result.refreshToken()).toString()) diff --git a/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java index fbc094cc..80c8bc7d 100644 --- a/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java +++ b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java @@ -8,7 +8,7 @@ public record GoogleLoginRequest( @MaskField @Schema(description = "Google OAuth2 인가 코드") String code, - @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + @Schema(description = "현재 필수 약관 전체 동의 여부. 신규 즉시 가입 또는 기존 회원 재동의가 필요할 때 true", example = "true") Boolean agreementsAccepted ) { } diff --git a/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginResponse.java b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginResponse.java new file mode 100644 index 00000000..00fa3c38 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginResponse.java @@ -0,0 +1,18 @@ +package com.howaboutus.backend.auth.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Google 로그인 응답") +public record GoogleLoginResponse( + @Schema(description = "로그인 처리 상태", example = "SIGNUP_REQUIRED") + String status, + @Schema(description = "약관 동의 후 가입 완료에 사용할 임시 토큰") + String signupToken, + @Schema(description = "가입 임시 토큰 만료까지 남은 초", example = "600") + long expiresInSeconds +) { + + public static GoogleLoginResponse signupRequired(String signupToken, long expiresInSeconds) { + return new GoogleLoginResponse("SIGNUP_REQUIRED", signupToken, expiresInSeconds); + } +} diff --git a/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleSignupRequest.java b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleSignupRequest.java new file mode 100644 index 00000000..78749d7d --- /dev/null +++ b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleSignupRequest.java @@ -0,0 +1,14 @@ +package com.howaboutus.backend.auth.controller.dto; + +import com.howaboutus.backend.common.logging.MaskField; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record GoogleSignupRequest( + @MaskField + @Schema(description = "Google 가입 완료 임시 토큰") + String signupToken, + @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + Boolean agreementsAccepted +) { +} diff --git a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java index 09d7e372..7c789322 100644 --- a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java +++ b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java @@ -8,6 +8,8 @@ import com.howaboutus.backend.agreements.service.AgreementService; import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.auth.service.dto.GoogleLoginOutcome; +import com.howaboutus.backend.auth.service.dto.GoogleSignupPayload; import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; @@ -28,18 +30,40 @@ public class AuthService { private final UserService userService; private final JwtProvider jwtProvider; private final RefreshTokenService refreshTokenService; + private final GoogleSignupTokenService googleSignupTokenService; private final AgreementService agreementService; private final Clock clock; @Loggable @Transactional - public LoginResult googleLogin(String authorizationCode, Boolean agreementsAccepted) { + public GoogleLoginOutcome googleLogin(String authorizationCode, Boolean agreementsAccepted) { GoogleUserInfo userInfo = googleOAuthClient.login(authorizationCode); User user = userService.findGoogleUser(userInfo.providerId()) .map(existingUser -> handleExistingUser(existingUser, agreementsAccepted)) - .orElseGet(() -> createNewUser(userInfo, agreementsAccepted)); + .orElse(null); + if (user == null && !Boolean.TRUE.equals(agreementsAccepted)) { + return createSignupRequiredOutcome(userInfo); + } + if (user == null) { + user = createNewUser(userInfo, agreementsAccepted); + } + + return GoogleLoginOutcome.authenticated(issueTokens(user)); + } + + private GoogleLoginOutcome createSignupRequiredOutcome(GoogleUserInfo userInfo) { + String signupToken = googleSignupTokenService.create(new GoogleSignupPayload( + userInfo.providerId(), + userInfo.email(), + userInfo.nickname(), + userInfo.profileImageUrl() + )); + return GoogleLoginOutcome.signupRequired(signupToken, GoogleSignupTokenService.EXPIRES_IN_SECONDS); + } + + private LoginResult issueTokens(User user) { String accessToken = jwtProvider.generateAccessToken(user.getId()); String refreshToken = refreshTokenService.create(user.getId()); @@ -70,6 +94,23 @@ private User createNewUser(GoogleUserInfo userInfo, Boolean agreementsAccepted) ); } + @Loggable + @Transactional + public LoginResult googleSignup(String signupToken, Boolean agreementsAccepted) { + agreementService.validateAccepted(agreementsAccepted); + GoogleSignupPayload payload = googleSignupTokenService.consume(signupToken); + AgreementVersions versions = agreementService.currentVersions(); + User user = userService.getOrCreateGoogleUser( + payload.providerId(), + payload.email(), + payload.nickname(), + payload.profileImageUrl(), + versions, + Instant.now(clock) + ); + return issueTokens(user); + } + @Loggable public LoginResult refresh(String refreshToken) { RotateResult rotated = refreshTokenService.rotate(refreshToken); diff --git a/src/main/java/com/howaboutus/backend/auth/service/GoogleSignupTokenService.java b/src/main/java/com/howaboutus/backend/auth/service/GoogleSignupTokenService.java new file mode 100644 index 00000000..5ef0523e --- /dev/null +++ b/src/main/java/com/howaboutus/backend/auth/service/GoogleSignupTokenService.java @@ -0,0 +1,94 @@ +package com.howaboutus.backend.auth.service; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Service; + +import com.howaboutus.backend.auth.service.dto.GoogleSignupPayload; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import tools.jackson.databind.ObjectMapper; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleSignupTokenService { + + public static final long EXPIRES_IN_SECONDS = 600L; + + private static final String TOKEN_KEY_PREFIX = "auth:google:signup:token:"; + private static final String USER_KEY_PREFIX = "auth:google:signup:user:"; + private static final Duration TOKEN_TTL = Duration.ofSeconds(EXPIRES_IN_SECONDS); + + private static final RedisScript CREATE_SCRIPT = new DefaultRedisScript<>( + """ + local previous = redis.call('GET', KEYS[1]) + if previous then + redis.call('DEL', ARGV[4] .. previous) + end + redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[2]) + redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[2]) + return ARGV[3] + """, + String.class + ); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public String create(GoogleSignupPayload payload) { + String token = UUID.randomUUID().toString(); + String userKey = USER_KEY_PREFIX + payload.providerId(); + String tokenKey = TOKEN_KEY_PREFIX + token; + + redisTemplate.execute( + CREATE_SCRIPT, + List.of(userKey, tokenKey), + serialize(payload), + String.valueOf(EXPIRES_IN_SECONDS), + token, + TOKEN_KEY_PREFIX + ); + return token; + } + + public GoogleSignupPayload consume(String token) { + String payloadJson = redisTemplate.opsForValue().getAndDelete(TOKEN_KEY_PREFIX + token); + if (payloadJson == null) { + throw new CustomException(ErrorCode.GOOGLE_SIGNUP_TOKEN_NOT_FOUND); + } + + GoogleSignupPayload payload = deserialize(payloadJson); + String userKey = USER_KEY_PREFIX + payload.providerId(); + String latestToken = redisTemplate.opsForValue().get(userKey); + if (token.equals(latestToken)) { + redisTemplate.delete(userKey); + } + return payload; + } + + private String serialize(GoogleSignupPayload payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialize Google signup payload", e); + } + } + + private GoogleSignupPayload deserialize(String payloadJson) { + try { + return objectMapper.readValue(payloadJson, GoogleSignupPayload.class); + } catch (Exception e) { + log.warn("Failed to deserialize Google signup payload, treating as not found", e); + throw new CustomException(ErrorCode.GOOGLE_SIGNUP_TOKEN_NOT_FOUND, e); + } + } +} diff --git a/src/main/java/com/howaboutus/backend/auth/service/dto/GoogleLoginOutcome.java b/src/main/java/com/howaboutus/backend/auth/service/dto/GoogleLoginOutcome.java new file mode 100644 index 00000000..27841eff --- /dev/null +++ b/src/main/java/com/howaboutus/backend/auth/service/dto/GoogleLoginOutcome.java @@ -0,0 +1,30 @@ +package com.howaboutus.backend.auth.service.dto; + +public record GoogleLoginOutcome( + Type type, + LoginResult loginResult, + String signupToken, + long expiresInSeconds +) { + + public static GoogleLoginOutcome authenticated(LoginResult loginResult) { + return new GoogleLoginOutcome(Type.AUTHENTICATED, loginResult, null, 0); + } + + public static GoogleLoginOutcome signupRequired(String signupToken, long expiresInSeconds) { + return new GoogleLoginOutcome(Type.SIGNUP_REQUIRED, null, signupToken, expiresInSeconds); + } + + public boolean authenticated() { + return type == Type.AUTHENTICATED; + } + + public boolean signupRequired() { + return type == Type.SIGNUP_REQUIRED; + } + + public enum Type { + AUTHENTICATED, + SIGNUP_REQUIRED + } +} diff --git a/src/main/java/com/howaboutus/backend/auth/service/dto/GoogleSignupPayload.java b/src/main/java/com/howaboutus/backend/auth/service/dto/GoogleSignupPayload.java new file mode 100644 index 00000000..13da414e --- /dev/null +++ b/src/main/java/com/howaboutus/backend/auth/service/dto/GoogleSignupPayload.java @@ -0,0 +1,9 @@ +package com.howaboutus.backend.auth.service.dto; + +public record GoogleSignupPayload( + String providerId, + String email, + String nickname, + String profileImageUrl +) { +} diff --git a/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java b/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java index 264f1759..44a60b72 100644 --- a/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java +++ b/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java @@ -53,6 +53,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) { "/actuator/caches", "/api/agreements/current", "/auth/google/login", + "/auth/google/signup", "/auth/refresh", "/auth/logout", "/ws/**") // 웹소켓 인증은 Spring Security에서 다루지 않음 diff --git a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java index 364644f3..dbaa3eaa 100644 --- a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java +++ b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java @@ -30,6 +30,7 @@ public enum ErrorCode { REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다"), REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "토큰 재사용이 감지되었습니다"), AGREEMENTS_REACCEPTANCE_REQUIRED(HttpStatus.UNAUTHORIZED, "변경된 약관에 다시 동의해야 합니다"), + GOOGLE_SIGNUP_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "가입 임시 토큰이 없거나 만료되었습니다"), // 400 BAD REQUEST SCHEDULE_DATE_MISMATCH(HttpStatus.BAD_REQUEST, "여행 날짜와 일차 정보가 일치하지 않습니다"), diff --git a/src/main/java/com/howaboutus/backend/common/logging/LoggingAspect.java b/src/main/java/com/howaboutus/backend/common/logging/LoggingAspect.java index a45952d1..a14b5edd 100644 --- a/src/main/java/com/howaboutus/backend/common/logging/LoggingAspect.java +++ b/src/main/java/com/howaboutus/backend/common/logging/LoggingAspect.java @@ -23,7 +23,7 @@ public class LoggingAspect { private static final Set SENSITIVE_PARAM_NAMES = - Set.of("token", "refreshToken", "accessToken", "authorizationCode"); + Set.of("token", "refreshToken", "accessToken", "authorizationCode", "signupToken"); private static final Set SKIP_PACKAGES = Set.of("org.springframework", "jakarta.servlet"); diff --git a/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java b/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java index 4f8282af..5a450b50 100644 --- a/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java @@ -19,6 +19,8 @@ import com.howaboutus.backend.support.BaseIntegrationTest; import jakarta.servlet.http.Cookie; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; @AutoConfigureMockMvc class AuthIntegrationTest extends BaseIntegrationTest { @@ -26,6 +28,9 @@ class AuthIntegrationTest extends BaseIntegrationTest { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockitoBean private GoogleOAuthClient googleOAuthClient; @@ -47,6 +52,80 @@ void loginIssuesTokenCookies() throws Exception { .andExpect(cookie().httpOnly("refresh_token", true)); } + @Test + @DisplayName("신규 사용자는 signup token을 받은 뒤 약관 동의로 가입을 완료한다") + void newUserCompletesSignupWithSignupToken() throws Exception { + given(googleOAuthClient.login("auth-code-signup")) + .willReturn(new GoogleUserInfo("google-it-signup", "signup@gmail.com", "가입", null)); + + MvcResult loginResult = mockMvc.perform(post("/auth/google/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"code": "auth-code-signup", "agreementsAccepted": false} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SIGNUP_REQUIRED")) + .andExpect(jsonPath("$.expiresInSeconds").value(600)) + .andExpect(cookie().doesNotExist("access_token")) + .andExpect(cookie().doesNotExist("refresh_token")) + .andReturn(); + + String signupToken = readSignupToken(loginResult); + + mockMvc.perform(post("/auth/google/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"signupToken": "%s", "agreementsAccepted": true} + """.formatted(signupToken))) + .andExpect(status().isOk()) + .andExpect(cookie().exists("access_token")) + .andExpect(cookie().httpOnly("access_token", true)) + .andExpect(cookie().exists("refresh_token")) + .andExpect(cookie().httpOnly("refresh_token", true)); + } + + @Test + @DisplayName("같은 Google 계정으로 signup token을 다시 발급받으면 이전 token은 무효화된다") + void reissuedSignupTokenInvalidatesPreviousToken() throws Exception { + given(googleOAuthClient.login("auth-code-reissue-1")) + .willReturn(new GoogleUserInfo("google-it-reissue", "reissue@gmail.com", "재발급", null)); + given(googleOAuthClient.login("auth-code-reissue-2")) + .willReturn(new GoogleUserInfo("google-it-reissue", "reissue@gmail.com", "재발급", null)); + + String oldSignupToken = readSignupToken(mockMvc.perform(post("/auth/google/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"code": "auth-code-reissue-1", "agreementsAccepted": false} + """)) + .andExpect(status().isOk()) + .andReturn()); + + String newSignupToken = readSignupToken(mockMvc.perform(post("/auth/google/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"code": "auth-code-reissue-2", "agreementsAccepted": false} + """)) + .andExpect(status().isOk()) + .andReturn()); + + mockMvc.perform(post("/auth/google/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"signupToken": "%s", "agreementsAccepted": true} + """.formatted(oldSignupToken))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("GOOGLE_SIGNUP_TOKEN_NOT_FOUND")); + + mockMvc.perform(post("/auth/google/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"signupToken": "%s", "agreementsAccepted": true} + """.formatted(newSignupToken))) + .andExpect(status().isOk()) + .andExpect(cookie().exists("access_token")) + .andExpect(cookie().exists("refresh_token")); + } + @Test @DisplayName("Refresh Token Rotation — 새 토큰을 발급하고 이전 토큰은 거절된다") void refreshRotatesTokenAndRejectsOld() throws Exception { @@ -108,4 +187,9 @@ void logoutInvalidatesRefreshToken() throws Exception { .cookie(refreshCookie)) .andExpect(status().isUnauthorized()); } + + private String readSignupToken(MvcResult result) throws Exception { + JsonNode body = objectMapper.readTree(result.getResponse().getContentAsString()); + return body.get("signupToken").asString(); + } } diff --git a/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java b/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java index 5a406723..d59525a9 100644 --- a/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java @@ -17,6 +17,7 @@ import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; import com.howaboutus.backend.auth.service.AuthService; import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.auth.service.dto.GoogleLoginOutcome; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.common.config.SecurityConfig; import com.howaboutus.backend.common.config.properties.CookieProperties; @@ -56,7 +57,7 @@ class AuthControllerTest { @DisplayName("Google 로그인 성공 시 access_token과 refresh_token 쿠키를 반환한다") void returnsAccessAndRefreshTokenCookiesOnLogin() throws Exception { given(authService.googleLogin("valid-code", true)) - .willReturn(new LoginResult("jwt-token", "1:refresh-uuid", 1L)); + .willReturn(GoogleLoginOutcome.authenticated(new LoginResult("jwt-token", "1:refresh-uuid", 1L))); given(jwtProperties.accessTokenExpiration()).willReturn(1800000L); given(refreshTokenProperties.expiration()).willReturn(1209600000L); @@ -74,6 +75,62 @@ void returnsAccessAndRefreshTokenCookiesOnLogin() throws Exception { .andExpect(cookie().path("refresh_token", "/")); } + @Test + @DisplayName("신규 Google 사용자가 약관 동의 전이면 signup token 응답을 반환하고 쿠키는 발급하지 않는다") + void returnsSignupRequiredBodyWithoutCookies() throws Exception { + given(authService.googleLogin("signup-code", false)) + .willReturn(GoogleLoginOutcome.signupRequired("signup-token", 600L)); + + mockMvc.perform(post("/auth/google/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"code": "signup-code", "agreementsAccepted": false} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SIGNUP_REQUIRED")) + .andExpect(jsonPath("$.signupToken").value("signup-token")) + .andExpect(jsonPath("$.expiresInSeconds").value(600)) + .andExpect(cookie().doesNotExist("access_token")) + .andExpect(cookie().doesNotExist("refresh_token")); + } + + @Test + @DisplayName("Google 가입 완료 성공 시 access_token과 refresh_token 쿠키를 반환한다") + void googleSignupReturnsAccessAndRefreshTokenCookies() throws Exception { + given(authService.googleSignup("signup-token", true)) + .willReturn(new LoginResult("jwt-token", "1:refresh-uuid", 1L)); + given(jwtProperties.accessTokenExpiration()).willReturn(1800000L); + given(refreshTokenProperties.expiration()).willReturn(1209600000L); + + mockMvc.perform(post("/auth/google/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"signupToken": "signup-token", "agreementsAccepted": true} + """)) + .andExpect(status().isOk()) + .andExpect(cookie().exists("access_token")) + .andExpect(cookie().httpOnly("access_token", true)) + .andExpect(cookie().path("access_token", "/")) + .andExpect(cookie().exists("refresh_token")) + .andExpect(cookie().httpOnly("refresh_token", true)) + .andExpect(cookie().path("refresh_token", "/")); + } + + @Test + @DisplayName("Google 가입 완료에서 signup token이 없거나 만료되면 401을 반환한다") + void googleSignupReturns401WhenSignupTokenMissing() throws Exception { + given(authService.googleSignup("expired-token", true)) + .willThrow(new CustomException(ErrorCode.GOOGLE_SIGNUP_TOKEN_NOT_FOUND)); + + mockMvc.perform(post("/auth/google/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"signupToken": "expired-token", "agreementsAccepted": true} + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("GOOGLE_SIGNUP_TOKEN_NOT_FOUND")); + } + @Test @DisplayName("Google 인증 실패 시 401을 반환한다") void returns401WhenGoogleAuthFails() throws Exception { diff --git a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java index da9b3d2e..21da1e8e 100644 --- a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java @@ -18,6 +18,8 @@ import com.howaboutus.backend.agreements.service.AgreementService; import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.auth.service.dto.GoogleLoginOutcome; +import com.howaboutus.backend.auth.service.dto.GoogleSignupPayload; import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; @@ -48,6 +50,9 @@ class AuthServiceTest { @Mock private AgreementService agreementService; + @Mock + private GoogleSignupTokenService googleSignupTokenService; + @Mock private Clock clock; @@ -67,25 +72,31 @@ void registersNewUserAndReturnsTokens() { given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); - LoginResult result = authService.googleLogin("auth-code", true); + GoogleLoginOutcome outcome = authService.googleLogin("auth-code", true); + LoginResult result = outcome.loginResult(); + assertThat(outcome.authenticated()).isTrue(); assertThat(result.accessToken()).isEqualTo("jwt-token"); assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); } @Test - @DisplayName("신규 사용자가 약관에 동의하지 않으면 가입과 토큰 발급을 거부한다") - void rejectsNewUserWithoutAgreementAcceptance() { + @DisplayName("신규 사용자가 약관에 동의하지 않으면 가입 임시 토큰을 발급한다") + void returnsSignupTokenForNewUserWithoutAgreementAcceptance() { GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); given(googleOAuthClient.login("auth-code")).willReturn(userInfo); given(userService.findGoogleUser("google-123")).willReturn(Optional.empty()); - willThrow(new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED)) - .given(agreementService).validateAccepted(false); + given(googleSignupTokenService.create( + new GoogleSignupPayload("google-123", "test@gmail.com", "테스트", null))) + .willReturn("signup-token"); - assertThatThrownBy(() -> authService.googleLogin("auth-code", false)) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) - .isEqualTo(ErrorCode.AGREEMENTS_NOT_ACCEPTED)); + GoogleLoginOutcome outcome = authService.googleLogin("auth-code", false); + + assertThat(outcome.signupRequired()).isTrue(); + assertThat(outcome.signupToken()).isEqualTo("signup-token"); + assertThat(outcome.expiresInSeconds()).isEqualTo(600L); + verify(jwtProvider, never()).generateAccessToken(any()); + verify(refreshTokenService, never()).create(any()); } @Test @@ -99,8 +110,10 @@ void returnsTokensForExistingUser() { given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); - LoginResult result = authService.googleLogin("auth-code", true); + GoogleLoginOutcome outcome = authService.googleLogin("auth-code", true); + LoginResult result = outcome.loginResult(); + assertThat(outcome.authenticated()).isTrue(); assertThat(result.accessToken()).isEqualTo("jwt-token"); assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); } @@ -135,13 +148,48 @@ void updatesStaleAgreementsAndReturnsTokens() { given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); - LoginResult result = authService.googleLogin("auth-code", true); + GoogleLoginOutcome outcome = authService.googleLogin("auth-code", true); + LoginResult result = outcome.loginResult(); + assertThat(outcome.authenticated()).isTrue(); verify(userService).acceptCurrentAgreements(existingUser.getId(), versions, now); assertThat(result.accessToken()).isEqualTo("jwt-token"); assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); } + @Test + @DisplayName("가입 임시 토큰과 약관 동의로 신규 사용자를 생성하고 토큰을 발급한다") + void googleSignupCreatesUserAndReturnsTokens() { + GoogleSignupPayload payload = new GoogleSignupPayload("google-123", "test@gmail.com", "테스트", null); + User mockUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + AgreementVersions versions = new AgreementVersions("1.0", "1.0"); + Instant now = Instant.parse("2026-06-08T10:00:00Z"); + given(googleSignupTokenService.consume("signup-token")).willReturn(payload); + given(agreementService.currentVersions()).willReturn(versions); + given(clock.instant()).willReturn(now); + given(userService.getOrCreateGoogleUser("google-123", "test@gmail.com", "테스트", null, versions, now)) + .willReturn(mockUser); + given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); + given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); + + LoginResult result = authService.googleSignup("signup-token", true); + + assertThat(result.accessToken()).isEqualTo("jwt-token"); + assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); + } + + @Test + @DisplayName("가입 완료에서 약관에 동의하지 않으면 임시 토큰을 소비하지 않는다") + void googleSignupRejectsWithoutAgreementAcceptance() { + willThrow(new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED)) + .given(agreementService).validateAccepted(false); + + assertThatThrownBy(() -> authService.googleSignup("signup-token", false)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AGREEMENTS_NOT_ACCEPTED); + verify(googleSignupTokenService, never()).consume(any()); + } + @Test @DisplayName("Refresh Token으로 새 토큰 쌍을 발급한다") void refreshReturnsNewTokens() { diff --git a/src/test/java/com/howaboutus/backend/auth/service/GoogleSignupTokenServiceTest.java b/src/test/java/com/howaboutus/backend/auth/service/GoogleSignupTokenServiceTest.java new file mode 100644 index 00000000..dca9871f --- /dev/null +++ b/src/test/java/com/howaboutus/backend/auth/service/GoogleSignupTokenServiceTest.java @@ -0,0 +1,71 @@ +package com.howaboutus.backend.auth.service; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.howaboutus.backend.auth.service.dto.GoogleSignupPayload; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.support.BaseIntegrationTest; + +class GoogleSignupTokenServiceTest extends BaseIntegrationTest { + + @Autowired + private GoogleSignupTokenService tokenService; + + @Autowired + private StringRedisTemplate redisTemplate; + + @Test + @DisplayName("signup token을 생성하고 consume하면 payload를 반환한 뒤 재사용을 거부한다") + void consumeReturnsPayloadAndDeletesToken() { + GoogleSignupPayload payload = new GoogleSignupPayload("google-consume", "consume@test.com", "사용자", null); + + String token = tokenService.create(payload); + + assertThat(tokenService.consume(token)).isEqualTo(payload); + assertThatThrownBy(() -> tokenService.consume(token)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GOOGLE_SIGNUP_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("같은 providerId로 새 signup token을 만들면 이전 token은 무효화된다") + void createInvalidatesPreviousTokenForProvider() { + GoogleSignupPayload payload = new GoogleSignupPayload("google-reissue", "reissue@test.com", "사용자", null); + + String oldToken = tokenService.create(payload); + String newToken = tokenService.create(payload); + + assertThatThrownBy(() -> tokenService.consume(oldToken)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GOOGLE_SIGNUP_TOKEN_NOT_FOUND); + assertThat(tokenService.consume(newToken)).isEqualTo(payload); + } + + @Test + @DisplayName("signup token은 10분 TTL로 저장된다") + void createStoresTokenWithTenMinuteTtl() { + GoogleSignupPayload payload = new GoogleSignupPayload("google-ttl", "ttl@test.com", "사용자", null); + + String token = tokenService.create(payload); + + Long ttlSeconds = redisTemplate.getExpire("auth:google:signup:token:" + token); + assertThat(ttlSeconds).isBetween(590L, 600L); + } + + @Test + @DisplayName("Redis에 저장된 payload가 손상되면 consume은 GOOGLE_SIGNUP_TOKEN_NOT_FOUND를 던진다") + void consumeRejectsMalformedPayload() { + String token = "malformed-token"; + redisTemplate.opsForValue().set("auth:google:signup:token:" + token, "this is not json {{{"); + + assertThatThrownBy(() -> tokenService.consume(token)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GOOGLE_SIGNUP_TOKEN_NOT_FOUND); + } +}