Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/ai/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\<uuid\>. Replay Detection 으로 탈취 시 전체 무효화 | Redis |
| `[x]` | 로그아웃 | 단일 기기 로그아웃: 요청한 토큰만 삭제 | Redis |
Expand Down
266 changes: 266 additions & 0 deletions docs/superpowers/plans/2026-06-08-google-signup-token.md
Original file line number Diff line number Diff line change
@@ -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 변경만 표시.
Loading