diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md new file mode 100644 index 00000000..0489af5a --- /dev/null +++ b/.claude/agents/security-reviewer.md @@ -0,0 +1,81 @@ +--- +name: security-reviewer +description: Coming Backend의 인증·인가 관련 변경사항(JWT, OAuth2, Redis 토큰 처리)을 Coming 도메인 정책 기준으로 검토하는 보안 리뷰 에이전트. "auth 관련 코드 작성 후 보안 리뷰해줘", "JWT/OAuth2 변경사항 보안 검토해줘" 요청 시 사용한다. 읽기 전용이며 코드를 직접 수정하지 않는다. +tools: Read, Bash, Grep +--- + +# Coming Backend 보안 리뷰 에이전트 + +변경된 Java 코드를 아래 체크리스트 기준으로 검토하고 심각도별 이슈를 보고한다. +**읽기 전용 리뷰어다 — 코드를 직접 수정하지 않는다. 발견한 이슈는 보고만 한다.** + +## 실행 순서 + +1. 변경된 파일 목록과 diff를 확인한다 (브랜치 커밋분 + 작업 트리 + 신규 파일) + ```bash + BASE=$(git merge-base origin/develop HEAD) + git diff --name-only "$BASE" # 브랜치 커밋분 + 작업 트리 변경 + git diff "$BASE" + git ls-files --others --exclude-standard # untracked 신규 파일 — 목록의 파일은 직접 읽는다 + ``` +2. 아래 체크리스트 기준으로 검토한다. 인증/인가와 무관한 변경이면 해당 없음으로 보고한다. +3. 심각도별로 이슈를 정리해 보고한다. + +--- + +## Coming 인증 정책 (기준) + +- Access Token: 30분, `Authorization: Bearer {token}` 헤더로만 전달 +- Refresh Token: 7일, HttpOnly Cookie로만 전달 (JS에서 접근 불가해야 함) +- 로그아웃 시 Access Token을 Redis 블랙리스트에 등록 +- OAuth2 Provider: Google, Kakao만 지원 + +## 심각도 기준 + +| 심각도 | 의미 | +|--------|------| +| 🔴 critical | 즉시 수정 필요 (인증 우회, 토큰 탈취, 권한 상승 가능) | +| 🟡 warning | 개선 권장 (정책 불일치, 방어 계층 누락) | +| 🔵 suggestion | 선택적 개선 | + +## 검토 체크리스트 + +### 🔴 critical +- 신규/변경 엔드포인트에 `@PreAuthorize` 또는 SecurityConfig 경로 설정 누락 (인증 필요 API가 허용 목록에 포함) +- `ROLE_ADMIN` 필요 엔드포인트에 권한 체크 누락 (일반 사용자가 403 없이 접근 가능) +- Refresh Token을 응답 바디·헤더·로그에 노출 (HttpOnly Cookie 외 경로로 전달) +- Access Token을 쿠키에 저장하거나 Refresh Token을 로컬 스토리지·바디로 내려보내는 등 Bearer/Cookie 역할 뒤바뀜 +- 로그아웃/토큰 폐기 로직에서 Redis 블랙리스트 등록 누락 +- JWT 서명 검증 없이 payload를 신뢰 (예: 파싱만 하고 `verify` 생략) +- 사용자 입력(닉네임, 문의 내용 등)을 파라미터 바인딩 없이 쿼리 문자열에 직접 결합 (SQL Injection) — JPA 파라미터 바인딩·엔티티 필드 대입은 해당 없음 +- 비밀번호·API 키·클라이언트 시크릿 등이 코드에 하드코딩되거나 로그에 평문 출력 + +### 🟡 warning +- 신규 인증 필요 엔드포인트에 Rate Limit 필터 미적용 +- OAuth2 실패·인증 예외 처리 시 내부 스택트레이스나 시스템 정보를 그대로 클라이언트에 노출 +- 다른 사용자의 리소스에 접근 가능한지(IDOR) 확인하는 소유자 검증 로직 누락 (예: `concertId`만으로 타 유저의 캘린더 항목 삭제 가능) +- 민감 정보(email, provider_id 등)를 응답 DTO에 불필요하게 포함 +- `application.yaml`에 `${ENV_VAR:default}` 형식이 아닌 민감 정보 직접 기재 + +### 🔵 suggestion +- 인증 관련 로그 레벨이 CLAUDE.md 기준(WARN: OAuth2 실패, INFO: 로그인 성공 등)과 다르게 기록됨 +- 에러 메시지가 공격자에게 유효한 계정/토큰 존재 여부를 암시 (예: "존재하지 않는 사용자"와 "비밀번호 불일치"를 구분해서 노출) + +--- + +## 결과 보고 + +``` +=== 보안 리뷰 결과 === +검토 파일: {파일 목록} +인증/인가 관련 변경: 있음 | 없음 + +🔴 critical: {N}건 +🟡 warning: {N}건 +🔵 suggestion: {N}건 + +{이슈 상세 목록 — 파일:라인, 문제, 개선 방향} + +{이슈 없으면: "✅ 보안 관점에서 커밋 진행 가능"} +{🔴 있으면: "🚫 커밋 전 critical 이슈를 수정하세요"} +``` diff --git a/.claude/hooks/guard_files.py b/.claude/hooks/guard_files.py new file mode 100644 index 00000000..170cbb11 --- /dev/null +++ b/.claude/hooks/guard_files.py @@ -0,0 +1,70 @@ +"""PreToolUse: 시크릿 파일 접근과 커밋된 Flyway 마이그레이션 수정을 차단한다. + +Bash는 명령을 토큰으로 나눠 각 토큰의 파일명을 검사한다. 따옴표로 묶인 문장(공백 포함 토큰)과 +heredoc 본문은 제외하므로 커밋 메시지·PR 본문의 언급은 걸리지 않는다. +문자열 조립 같은 의도적 우회까지 막지는 않는다 — 실수 방지용이다. +""" +import json +import os +import re +import shlex +import subprocess +import sys + +SECRET_NAME = re.compile(r"^(\.env(\..+)?|application-(local|secret).*|credentials(\..+)?|.*\.secrets?)$") +SECRET_ALLOWED = {".env.example"} +HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1.*?\n(.*?)^\s*\2\s*$", re.S | re.M) +REDIRECT_PREFIX = re.compile(r"^[0-9&]*[<>|]+") + + +def deny(reason): + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + }}, ensure_ascii=False)) + sys.exit(0) + + +def is_secret(path): + name = os.path.basename(path.rstrip("/")) + return bool(SECRET_NAME.match(name)) and name not in SECRET_ALLOWED + + +def bash_targets(command): + command = HEREDOC.sub("", command) + lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|<>()") + lexer.whitespace_split = True + try: + tokens = list(lexer) + except ValueError: + tokens = command.split() + # 파일명에 공백이 든 토큰은 따옴표로 묶인 문장이므로 제외(디렉터리 공백은 허용), `--file=.env`는 `=` 뒤만 본다 + tokens = [t for t in tokens if not re.search(r"\s", os.path.basename(t))] + return [REDIRECT_PREFIX.sub("", t).rsplit("=", 1)[-1] for t in tokens] + + +data = json.load(sys.stdin) +tool = data.get("tool_name", "") +tool_input = data.get("tool_input", {}) +path = tool_input.get("file_path", "") +name = os.path.basename(path) + +if tool == "Bash": + targets = bash_targets(tool_input.get("command", "")) +elif tool == "Grep": + targets = [tool_input.get("path", ""), tool_input.get("glob", "")] +else: + targets = [path] +hits = [t for t in targets if t and is_secret(t)] +if hits: + deny(f"시크릿 파일 접근 차단: {', '.join(hits)} — 필요한 내용은 사용자에게 직접 수정을 요청한다") + +if tool in ("Write", "Edit") and "/db/migration/" in path and name.startswith("V"): + cwd = data.get("cwd") or "." + committed = subprocess.run( + ["git", "cat-file", "-e", f"HEAD:./{os.path.relpath(path, cwd)}"], + cwd=cwd, capture_output=True, + ).returncode == 0 + if committed: + deny(f"커밋된 Flyway 마이그레이션 수정 차단: {path} — 변경은 새 V{{n}}__*.sql 파일로 추가한다") diff --git a/.claude/hooks/stop_reminder.sh b/.claude/hooks/stop_reminder.sh new file mode 100644 index 00000000..e863b258 --- /dev/null +++ b/.claude/hooks/stop_reminder.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Stop: 커밋되지 않은 Java 변경이 있을 때만 커밋 전 워크플로우를 안내한다. +cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || exit 0 +[ -n "$(git status --porcelain -- '*.java')" ] || exit 0 +echo '{"systemMessage": "⚠️ 커밋되지 않은 Java 변경 있음 — write-tests → ./gradlew test → /be-review (auth 변경 시 + security-reviewer) → /simplify → /commit → /pr"}' diff --git a/.claude/settings.json b/.claude/settings.json index 8397acb4..34ee3a1a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,12 +2,12 @@ "hooks": { "PreToolUse": [ { - "matcher": "Write|Edit", + "matcher": "Read|Write|Edit|Grep|Bash", "hooks": [ { "type": "command", - "command": "python3 -c \"import json,sys; data=json.load(sys.stdin); fp=data.get('tool_input',{}).get('file_path',''); blocked=['.env','.secret','application-prod','application-local','credentials','application-secret']; blocked_match=next((b for b in blocked if b in fp),None); print(json.dumps({'hookSpecificOutput':{'hookEventName':'PreToolUse','permissionDecision':'deny','permissionDecisionReason':'시크릿 파일 수정 차단: '+fp}})) if blocked_match else None\"", - "statusMessage": "시크릿 파일 검사 중..." + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard_files.py\"", + "statusMessage": "보호 파일 검사 중..." } ] } @@ -18,11 +18,11 @@ "hooks": [ { "type": "command", - "command": "echo '⚠️ 커밋 전 워크플로우: /be-review → /simplify → /commit → /pr'" + "command": "sh \"$CLAUDE_PROJECT_DIR/.claude/hooks/stop_reminder.sh\"" } ] } ], "PostToolUse": [] } -} +} \ No newline at end of file diff --git a/.github/erd.png b/.github/erd.png new file mode 100644 index 00000000..9340bbc4 Binary files /dev/null and b/.github/erd.png differ diff --git a/CLAUDE.md b/CLAUDE.md index 0feb5ba6..26c72380 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ | Cache/Session | Redis | | Auth | OAuth2 (Google/Kakao) + JWT | | ORM | Spring Data JPA | +| Batch / Mail | Spring Batch, Spring Mail + Thymeleaf | | Util | Lombok | | Build | Gradle | @@ -24,7 +25,12 @@ com.Coming.Backend ├── concert/ # 공연, 예매 링크, 셋리스트 ├── calendar/ # 사용자 공연 캘린더 ├── release/ # 음악 발매 (앨범, 트랙) -├── user/ # 사용자 정보 +├── rating/ # 공연·발매 별점 +├── post/ # 커뮤니티 게시글·댓글, 멘션 태그, 통합 검색 +├── report/ # 게시글·댓글 신고 +├── notice/ # 공지사항 +├── policy/ # 약관·개인정보처리방침, 개정 안내 메일(Spring Batch) +├── user/ # 마이페이지 (다가오는 공연, 관람 이력, 내 문의) ├── inquiry/ # 문의 ├── admin/ # 관리자 └── common/ @@ -58,7 +64,8 @@ com.Coming.Backend ## 알려진 제약 -- `application-local.yaml`은 gitignore 대상이며, 프로젝트 훅이 파일명에 `application-local`/`application-prod`/`.env`/`credentials`가 포함된 파일의 Write/Edit를 자동 차단한다 (`.claude/settings.json`). 이 파일 수정이 필요하면 Claude가 직접 편집할 수 없으니, 추가할 내용을 알려주고 사용자가 직접 추가하도록 요청한다. +- `application-local.yaml`은 gitignore 대상이며, 프로젝트 훅(`.claude/hooks/guard_files.py`)이 시크릿 파일(`.env*`(`.env.example` 제외)·`application-local*`·`application-secret*`·`credentials*`·`*.secret(s)`)의 Read/Write/Edit/Grep 및 Bash 명령 내 접근을 자동 차단한다. 이 파일 수정이 필요하면 Claude가 직접 편집할 수 없으니, 추가할 내용을 알려주고 사용자가 직접 추가하도록 요청한다. +- 같은 훅이 git에 커밋된 Flyway 마이그레이션(`db/migration/V*.sql`)의 수정도 차단한다 (checksum 불일치 방지). 스키마 변경은 항상 새 버전 파일로 추가한다. ## 명세 위치 (Cominggg/Specification) @@ -107,45 +114,10 @@ com.Coming.Backend ### 커밋 전 체크리스트 - `/be-review` 통과(🔴 critical 0건) 전에 `/commit`을 실행하지 않는다. -- auth 관련 코드(JWT, OAuth2, Redis 토큰 처리) 작성 시 `/security-review`도 추가 실행한다. +- auth 관련 코드(JWT, OAuth2, Redis 토큰 처리) 작성 시 `security-reviewer` 에이전트도 추가로 호출한다 (Coming 인증 정책 기준 전용 체크리스트 보유, 읽기 전용). --- ## 행동 원칙 -> Adapted from [andrej-karpathy-skills/CLAUDE.md](https://github.com/forrestchang/andrej-karpathy-skills/blob/main/CLAUDE.md) -> These guidelines bias toward caution over speed. For trivial tasks, use judgment. - -### 1. 코딩 전에 먼저 생각하라 - -- 가정을 명시적으로 밝혀라. 불확실하면 물어봐라. -- 여러 해석이 가능하면 모두 제시하고, 조용히 하나를 고르지 마라. -- 더 단순한 방법이 있으면 말해라. 필요하면 반박해라. -- 무언가 불분명하면 멈춰라. 무엇이 헷갈리는지 이름 붙이고 물어봐라. - -### 2. 단순함 우선 - -- 요청된 것 이상의 기능을 만들지 마라. -- 단일 사용 코드에 추상화를 만들지 마라. -- 요청되지 않은 유연성이나 설정 가능성을 넣지 마라. -- 불가능한 시나리오에 대한 에러 핸들링을 만들지 마라. -- 200줄로 쓴 코드가 50줄로 가능하면 다시 써라. - -### 3. 외과적 변경 - -- 요청된 코드만 수정하라. 인접한 코드, 주석, 포맷을 "개선"하지 마라. -- 망가지지 않은 것을 리팩터링하지 마라. -- 기존 스타일이 마음에 들지 않아도 맞춰라. -- 관련 없는 데드코드를 발견하면 언급만 하고, 삭제하지 마라. - -### 4. 목표 기반 실행 - -작업을 검증 가능한 목표로 변환하라: -- "검증 추가" → "잘못된 입력 테스트 작성 후 통과" -- "버그 수정" → "재현 테스트 작성 후 통과" - -다단계 작업은 계획을 먼저 제시하라: -``` -1. [단계] → 검증: [체크] -2. [단계] → 검증: [체크] -``` +전역 `~/.claude/CLAUDE.md`의 "행동 원칙"(코딩 전에 먼저 생각하라·단순함 우선·외과적 변경·목표 기반 실행)을 따른다. diff --git a/README.md b/README.md index f2d12994..b6e78443 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ | ORM | Spring Data JPA | | API 문서 | springdoc-openapi | | 모니터링 | Actuator + Micrometer(Prometheus) + Grafana | +| 배치 · 메일 | Spring Batch, Spring Mail + Thymeleaf | | 부하 테스트 | k6 | | CI/CD | GitHub Actions → GHCR → SSH 배포 | | 컨테이너 | Docker | @@ -49,76 +50,97 @@ |--------|-----------| | `auth` | OAuth2 로그인(Google/Kakao), JWT 발급·재발급, Redis 블랙리스트 로그아웃 | | `artist` | 아티스트 조회, 팔로우 | -| `concert` | 공연 조회, 예매 링크, 셋리스트, 인기순 정렬 | +| `concert` | 공연 조회, 예매 링크, 셋리스트, 인기순 정렬, 별점 등록 | | `calendar` | 사용자 공연 캘린더 등록/조회 | -| `release` | 아티스트별 음악 발매(앨범/싱글/EP) 정보 | -| `user` | 사용자 정보 | +| `release` | 아티스트별 음악 발매(앨범/싱글/EP) 정보, 별점 등록 | +| `rating` | 공연·발매 별점 저장 및 평균 집계 | +| `post` | 커뮤니티 게시글·댓글, 추천·좋아요, 아티스트/공연/발매 멘션 태그, 통합 검색 | +| `report` | 게시글·댓글 신고, 이벤트 기반 알림 | +| `notice` | 공지사항 조회 | +| `policy` | 약관·개인정보처리방침 버전 관리, 개정 시 Spring Batch 기반 안내 메일 발송 | +| `user` | 마이페이지 (다가오는 공연, 관람 이력, 내 문의) | | `inquiry` | 문의 등록·조회, 이벤트 기반 알림 | -| `admin` | 관리자 전용 CRUD (아티스트·공연 등) | +| `admin` | 관리자 전용 기능 (아티스트·공연·공지·문의·신고 관리, 데이터 파이프라인 수집 요청) | **인증 정책**: Access Token 30분(`Authorization: Bearer`), Refresh Token 7일(HttpOnly Cookie), 로그아웃 시 Redis 블랙리스트 등록. **공통 응답 형식** + +에러 ```json -// 에러 { "code": "CONCERT_NOT_FOUND", "message": "존재하지 않는 공연입니다." } +``` -// 페이지네이션 +페이지네이션 +```json { "content": [], "page": 0, "size": 20, "totalElements": 100, "totalPages": 5 } ``` +## ERD + +Flyway V1–V40 적용 기준 27개 테이블(Spring Batch 메타 테이블 제외). FK는 대부분 DB 제약 없이 애플리케이션 레벨에서 관리하는 논리적 참조이며, 별점·신고·문의·멘션 태그는 `target_type + target_id` 다형 참조를 사용한다. + +

+ ERD +

+ --- ## AI 협업 워크플로우 -이 프로젝트는 기능 구현뿐 아니라 **개발 프로세스 자체를 Claude Code의 서브에이전트·스킬로 설계**했습니다. -1인 개발이지만, 역할을 나눠 병렬로 검토·검증하는 체계를 갖추는 것을 목표로 했습니다. +Claude Code 에이전트·스킬·훅으로 이슈부터 PR까지 진행합니다. 프로젝트 규칙은 [`CLAUDE.md`](CLAUDE.md)에 모여 있고, 아래 도구들이 그 규칙을 역할별로 나눠 강제합니다. + +### 흐름 -### 개발 흐름 +``` +/issue → /plan-issue → 구현 → write-tests → /be-review (+ security-reviewer) → /simplify → /commit → /pr → /sync-docs +``` -1. `/issue` — GitHub 이슈·브랜치 생성 -2. 구현 -3. `write-tests` — 3개 이상 도메인은 병렬 실행 -4. `/be-review` — DDD·SOLID·커버리지·API 스펙 리뷰 (🔴 critical 존재 시 2번으로 복귀) -5. `/simplify` → `/commit` → `/pr` → `/sync-docs` +| 단계 | 도구 | 하는 일 | +|------|------|--------| +| 이슈·브랜치 | `/issue` | GitHub 이슈 생성 + `{type}/#{번호}-...` 브랜치 체크아웃 | +| 계획 | `/plan-issue` | 이슈 체크리스트를 코드 현황과 대조하고, 남은 작업을 커밋 단위로 순서화 (마이그레이션 → Entity/Repository → Service → Controller) | +| 구현 | 메인 세션 | 클래스 단위 구현 | +| 테스트 작성 | `write-tests` 에이전트 | 클래스 구현 직후 테스트 작성, `./gradlew test`로 통과 확인 | +| 리뷰 | `/be-review` | DDD 레이어·SOLID·테스트 커버리지·API 스펙 검토 — 🔴 critical 0건이어야 커밋 | +| 보안 리뷰 | `security-reviewer` 에이전트 | auth 관련 변경(JWT, OAuth2, Redis 토큰 처리) 시 추가 검토 | +| 정리 | `/simplify` | 변경 코드의 중복·불필요한 복잡도 정리 | +| 커밋·PR | `/commit`, `/pr` | 컨벤션(`[{type}] 요약`)에 맞춘 커밋·PR 작성 | +| 명세 동기화 | `/sync-docs` | 변경 사항을 `Cominggg/Specification` 명세 문서에 반영 | -- `/be-review`에서 🔴 critical 이슈가 0건일 때만 커밋으로 진행합니다. -- auth 관련 코드(JWT, OAuth2, Redis 토큰 처리)는 `/security-review`를 추가로 실행합니다. -- 테스트 작성은 도메인 수에 따라 병렬/순차를 구분합니다 — 3개 이상 도메인은 에이전트를 병렬 호출하고, 1~2개는 순차 작성합니다(cold start 중복 비용이 병렬화 이득을 초과하는 지점을 기준으로 판단). +두 에이전트는 이 레포의 [`.claude/agents/`](.claude/agents/)에 포함돼 있습니다. `/issue`, `/plan-issue`, `/be-review`, `/commit`, `/pr`, `/sync-docs`는 작성자의 전역 Claude Code 스킬이고, `/simplify`는 Claude Code 기본 제공 스킬이라 레포에는 없습니다. -### 도메인 전문가 서브에이전트 +### 에이전트 역할 분리와 병렬 실행 -기능 추가 전 검토가 필요할 때, 실제 API·DB 스키마를 알고 있는 역할별 에이전트에게 병렬로 의견을 구합니다. +| 에이전트 | 도구 | 역할 | +|---------|------|------| +| [`write-tests`](.claude/agents/write-tests.md) | Read·Write·Edit·Bash | `src/test/java/`에 구현과 같은 패키지 구조로 테스트 작성 (Service는 Mockito 단위 테스트, Controller는 `@WebMvcTest`) | +| [`security-reviewer`](.claude/agents/security-reviewer.md) | Read·Bash·Grep | 읽기 전용 — 이슈를 심각도별로 보고만 하고 코드를 수정하지 않음 | -| 에이전트 | 역할 | -|----------|------| -| `pm-expert` | 사용자 가치·우선순위·운영 리스크 관점 검토 | -| `be-expert` | API 설계, DB 스키마 변경, 인증·보안 영향 검토 | -| `fe-expert` | 기존 컴포넌트·UX 관점에서 구현 난이도 검토 | -| `data-expert` | 외부 수집 파이프라인 영향, 수집 주기·매칭 로직 검토 | +`write-tests`는 테스트 대상 도메인 수에 따라 실행 방식을 나눕니다. -`review-feature` 스킬은 이 4개 에이전트를 **동시에** 호출한 뒤, 아래 규칙으로 결과를 합산해 최종 권고를 냅니다. +- 3개 이상 도메인이면 도메인별로 병렬 호출하고, 1~2개면 순차 작성합니다 (cold start 중복 비용이 병렬화 이득을 초과하는 지점을 기준으로 판단). +- 호출 전 `build.gradle`과 기존 테스트 예제 1개를 프롬프트에 포함해, 에이전트마다 같은 파일을 반복 탐색하지 않게 합니다. +- 같은 도메인의 Service·Controller 테스트는 순서대로 작성합니다 (Controller 테스트가 Service 계약을 전제). -- 전원 "추가" → ✅ 추가 -- 1개 이상 "조건부" + 나머지 "추가" → ⚠️ 조건부 추가 -- 2개 이상 "보류" → 🔁 보류 -- 1개 이상 "반려" → ❌ 반려 +### 코드 리뷰 -### 커스텀 스킬 +`/be-review` 체크리스트는 심각도(🔴/🟡/🔵)별로 나뉘며, 커밋을 막는 🔴 critical 기준은 다음과 같습니다. -| 스킬 | 역할 | -|------|------| -| `be-review` | DDD 레이어·SOLID·테스트 커버리지·API 스펙 리뷰, 심각도별(🔴/🟡/🔵) 보고 | -| `security-review` | auth 관련 코드 보안 검토 | -| `review-feature` | 신규 기능 아이디어를 4개 에이전트로 병렬 사전 검토 | -| `commit` / `pr` | 컨벤션에 맞는 커밋 메시지·PR 초안 자동 생성 | +- **DDD 레이어**: Controller에 비즈니스 로직 금지, Entity를 응답 타입으로 직접 반환 금지 +- **캡슐화**: Entity에 `@Setter`·`@Data`, `public` 필드 금지 +- **테스트**: 신규 `@Service` 메서드에 단위 테스트 필수, 통합 테스트는 H2·Mock DB가 아닌 실제 PostgreSQL 사용 +- **API 스펙**: 에러 응답은 `{"code", "message"}` 형식과 `ErrorCode` enum만 사용, 인증 필요 엔드포인트의 Security 설정 누락 금지 -이 외에 이슈 생성·브랜치 자동화(`issue`), 명세 동기화(`read-spec`/`sync-docs`) 등 반복 작업용 스킬도 함께 운용하고 있습니다. +`security-reviewer`는 범용 `/security-review` 대신 Coming 인증 정책(Access 30분 Bearer·Refresh 7일 HttpOnly Cookie·로그아웃 Redis 블랙리스트) 기준으로 토큰 노출·Bearer/Cookie 역할 뒤바뀜, 블랙리스트 등록 누락, 서명 미검증, 권한 체크 누락, IDOR 등을 검토합니다. -### 안전장치 +### 훅 ([`.claude/settings.json`](.claude/settings.json), [`.claude/hooks/`](.claude/hooks)) -- `application-local.yaml`, `.env`, `credentials`가 포함된 파일명은 프로젝트 훅(`.claude/settings.json`)이 Claude의 Write/Edit 자체를 차단합니다. -- `write-tests` 에이전트를 프로젝트 로컬로 두어(`.claude/agents/write-tests.md`) 이 레포의 테스트 컨벤션에 맞는 테스트만 생성하도록 제한했습니다. +| 시점 | 대상 | 동작 | +|------|------|------| +| PreToolUse | Read·Write·Edit·Grep·Bash | 시크릿 파일(`.env*`·`application-local*`·`application-secret*`·`credentials*`·`*.secret(s)`, `.env.example` 제외) 접근 차단 — Bash는 명령 토큰의 파일명 검사(따옴표 문장·heredoc 본문 제외) | +| PreToolUse | Write·Edit | 커밋된 Flyway 마이그레이션(`db/migration/V*.sql`) 수정 차단 — checksum 불일치 방지, 변경은 새 버전 파일로 | +| Stop | 응답 종료 시 | 커밋되지 않은 `.java` 변경이 있을 때만 커밋 전 워크플로우 안내 표시 | 이 워크플로우를 설계하며 겪은 구체적인 판단·트레이드오프는 별도 문서로 기록하고 있습니다. @@ -161,15 +183,15 @@ open -a Docker && docker start redis # Docker 데몬이 꺼져 있으면 먼 - **CD** (`cd.yml`): `main` 브랜치 push(= `develop` → `main` 병합) 시 Docker 이미지를 GHCR에 push하고, Lightsail 인스턴스로 SSH 접속해 `scripts/deploy.sh` 실행 - Nginx가 `be-blue`(:8080)/`be-green`(:8081) 중 활성 슬롯으로만 트래픽을 전달하고, Redis는 두 슬롯이 공유합니다. Data Pipeline(`data`)도 같은 Docker Compose에 포함되어 별도 인스턴스 없이 함께 배포됩니다. - 배포 시 standby 슬롯에 새 이미지를 pull → `/actuator/health` 체크 통과 → Nginx upstream 전환 → 이전 슬롯 정지 순으로 무중단 배포합니다. -- DB는 별도 Lightsail 인스턴스(Managed PostgreSQL)로 분리되어 두 슬롯이 공통으로 바라보고, 4xx/5xx 에러·공연 데이터 수집 결과·문의 접수는 각각 Discord Webhook으로 알림됩니다. +- DB는 별도 Lightsail 인스턴스(Managed PostgreSQL)로 분리되어 두 슬롯이 공통으로 바라보고, 4xx/5xx 에러·공연 데이터 수집 결과·문의 접수·신고 접수는 각각 Discord Webhook으로 알림됩니다. ## 프로젝트 규모 | 항목 | 내용 | |------|------| | 개발 기간 | 2026-05 ~ (진행 중) | -| 도메인 수 | 8개 (auth / artist / concert / calendar / release / user / inquiry / admin) | -| DB 마이그레이션 | 25개 (Flyway) | +| 도메인 수 | 13개 (auth / artist / concert / calendar / release / rating / post / report / notice / policy / user / inquiry / admin) | +| DB 마이그레이션 | 40개 (Flyway) | | 연동 레포 | 4개 (Backend / Frontend / Data / Specification) | --- diff --git a/src/main/java/com/Coming/Backend/auth/controller/AuthController.java b/src/main/java/com/Coming/Backend/auth/controller/AuthController.java index 10a14bae..5098b225 100644 --- a/src/main/java/com/Coming/Backend/auth/controller/AuthController.java +++ b/src/main/java/com/Coming/Backend/auth/controller/AuthController.java @@ -47,6 +47,9 @@ public class AuthController { @Value("${jwt.refresh-token-expiry}") private long refreshTokenExpiry; + @Value("${app.cookie.secure:true}") + private boolean cookieSecure; + private final AuthService authService; @Operation(summary = "소셜 로그인 페이지로 리다이렉트") @@ -77,8 +80,9 @@ public ResponseEntity refresh( public ResponseEntity logout( @RequestHeader("Authorization") String authorizationHeader, @AuthenticationPrincipal Long userId, + @CookieValue(name = "refreshToken", required = false) String refreshToken, HttpServletResponse response) { - authService.logout(authorizationHeader.substring(BEARER_PREFIX.length()), userId); + authService.logout(authorizationHeader.substring(BEARER_PREFIX.length()), userId, refreshToken); deleteRefreshTokenCookie(response); return ResponseEntity.ok().build(); } @@ -137,7 +141,7 @@ public ResponseEntity checkNickname(@RequestParam String private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) - .secure(true) + .secure(cookieSecure) .path("/") .maxAge(refreshTokenExpiry / 1000) .sameSite("Strict") @@ -148,7 +152,7 @@ private void addRefreshTokenCookie(HttpServletResponse response, String refreshT private void deleteRefreshTokenCookie(HttpServletResponse response) { ResponseCookie cookie = ResponseCookie.from("refreshToken", "") .httpOnly(true) - .secure(true) + .secure(cookieSecure) .path("/") .maxAge(0) .sameSite("Strict") diff --git a/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java b/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java index 70752d99..9696800f 100644 --- a/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java +++ b/src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java @@ -48,7 +48,8 @@ public DevTokenResult login(String nickname, UserRole role) { String accessToken = jwtProvider.generateAccessToken(user.getId(), user.getRole().name()); String refreshToken = jwtProvider.generateRefreshToken(user.getId()); - tokenRepository.save(user.getId(), refreshToken, refreshTokenExpiry); + tokenRepository.saveSession(user.getId(), jwtProvider.parseRefreshToken(refreshToken).sessionId(), + refreshToken, refreshTokenExpiry); log.info("Dev 로그인 — userId: {}, nickname: {}", user.getId(), user.getNickname()); return new DevTokenResult(accessToken, refreshToken); diff --git a/src/main/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilter.java index f55735c2..215b597f 100644 --- a/src/main/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilter.java @@ -34,7 +34,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (token != null) { try { Claims claims = jwtProvider.parseClaims(token); - if (!blacklistRepository.isBlacklisted(token)) { + if (jwtProvider.isAccessToken(claims) && !blacklistRepository.isBlacklisted(token)) { setAuthentication(claims); } } catch (Exception e) { diff --git a/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java b/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java index 08a4f4e0..439b623d 100644 --- a/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java +++ b/src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java @@ -9,6 +9,7 @@ import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import java.util.Date; +import java.util.UUID; import javax.crypto.SecretKey; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -16,6 +17,10 @@ @Component public class JwtProvider { + private static final String TOKEN_TYPE_CLAIM = "typ"; + private static final String ACCESS_TOKEN_TYPE = "access"; + private static final String REFRESH_TOKEN_TYPE = "refresh"; + private final SecretKey secretKey; private final long accessTokenExpiry; private final long refreshTokenExpiry; @@ -35,16 +40,29 @@ public String generateAccessToken(Long userId, String role) { return Jwts.builder() .subject(userId.toString()) .claim("role", role) + .claim(TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE) .issuedAt(now) .expiration(new Date(now.getTime() + accessTokenExpiry)) .signWith(secretKey) .compact(); } + /** + * 새 세션의 Refresh Token을 발급한다. 세션 식별자(jti)는 새로 생성된다. + */ public String generateRefreshToken(Long userId) { + return generateRefreshToken(userId, UUID.randomUUID().toString()); + } + + /** + * 기존 세션의 Refresh Token을 재발급한다. 회전 시 세션 식별자(jti)를 유지하기 위해 사용한다. + */ + public String generateRefreshToken(Long userId, String sessionId) { Date now = new Date(); return Jwts.builder() .subject(userId.toString()) + .id(sessionId) + .claim(TOKEN_TYPE_CLAIM, REFRESH_TOKEN_TYPE) .issuedAt(now) .expiration(new Date(now.getTime() + refreshTokenExpiry)) .signWith(secretKey) @@ -55,8 +73,24 @@ public void validateToken(String token) { parseClaims(token); } - public Long getUserId(String token) { - return Long.parseLong(parseClaims(token).getSubject()); + /** + * Refresh Token을 검증하고 사용자 ID와 세션 식별자를 반환한다. + * Access Token이나 세션 식별자(jti) 도입 이전에 발급된 토큰이면 {@link InvalidTokenException}. + */ + public RefreshTokenClaims parseRefreshToken(String token) { + Claims claims = parseClaims(token); + String tokenType = claims.get(TOKEN_TYPE_CLAIM, String.class); + if (!REFRESH_TOKEN_TYPE.equals(tokenType) || claims.getId() == null) { + throw new InvalidTokenException(); + } + return new RefreshTokenClaims(Long.parseLong(claims.getSubject()), claims.getId()); + } + + /** + * API 인증에 사용할 수 있는 Access Token인지 확인한다. Refresh Token은 서명이 유효해도 false. + */ + public boolean isAccessToken(Claims claims) { + return ACCESS_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class)); } public String getRole(String token) { diff --git a/src/main/java/com/Coming/Backend/auth/jwt/RefreshTokenClaims.java b/src/main/java/com/Coming/Backend/auth/jwt/RefreshTokenClaims.java new file mode 100644 index 00000000..1afc331d --- /dev/null +++ b/src/main/java/com/Coming/Backend/auth/jwt/RefreshTokenClaims.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.auth.jwt; + +/** + * 검증된 Refresh Token에서 추출한 사용자 ID와 세션 식별자(jti). + */ +public record RefreshTokenClaims(Long userId, String sessionId) { +} diff --git a/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java b/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java index 320b74f4..b62256f3 100644 --- a/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java +++ b/src/main/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandler.java @@ -45,7 +45,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo log.info("OAuth2 로그인 성공 — userId: {}, isNewUser: {}", user.getId(), oAuth2User.isNewUser()); String refreshToken = jwtProvider.generateRefreshToken(user.getId()); - tokenRepository.save(user.getId(), refreshToken, refreshTokenExpiry); + tokenRepository.saveSession(user.getId(), jwtProvider.parseRefreshToken(refreshToken).sessionId(), + refreshToken, refreshTokenExpiry); addRefreshTokenCookie(response, refreshToken); String redirectUrl = redirectBaseUri + "?isNewUser=" + (user.getRole() == UserRole.PENDING); diff --git a/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java b/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java index f34029bc..29001c42 100644 --- a/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java +++ b/src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java @@ -1,14 +1,47 @@ package com.Coming.Backend.auth.repository; -import java.util.Optional; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Repository; +/** + * Refresh Token을 기기(세션) 단위로 저장한다. + *
    + *
  • {@code RT:{userId}:{sessionId}} — 세션별 Refresh Token
  • + *
  • {@code RT_SESSIONS:{userId}} — 사용자의 세션 목록 ZSET (score = 세션 생성 시각)
  • + *
+ */ @Repository public class TokenRepository { private static final String KEY_PREFIX = "RT:"; + private static final String SESSIONS_KEY_PREFIX = "RT_SESSIONS:"; + private static final int MAX_SESSIONS = 5; + + // 저장값이 일치하면 교체하고(1), 다르면 이미 회전된 토큰의 재사용으로 보고 세션을 폐기하며(0), 세션이 없으면 -1. + // 세션이 refresh로만 연장되는 경우에도 세션 목록이 먼저 만료되지 않도록 목록 TTL을 함께 연장한다. + private static final RedisScript ROTATE_SCRIPT = new DefaultRedisScript<>( + "local stored = redis.call('get', KEYS[1]) " + + "if not stored then return -1 end " + + "if stored ~= ARGV[1] then " + + "redis.call('del', KEYS[1]) " + + "redis.call('zrem', KEYS[2], ARGV[4]) " + + "return 0 end " + + "redis.call('set', KEYS[1], ARGV[2], 'PX', ARGV[3]) " + + "redis.call('pexpire', KEYS[2], ARGV[3]) " + + "return 1", + Long.class); + + public enum RotationResult { + ROTATED, + REUSE_DETECTED, + SESSION_NOT_FOUND + } private final RedisTemplate redisTemplate; @@ -16,17 +49,80 @@ public TokenRepository(RedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } - public void save(Long userId, String refreshToken, long ttlMillis) { + /** + * 새 세션을 저장한다. 세션이 최대 개수를 넘으면 가장 오래 전에 생성된 세션부터 제거한다. + */ + public void saveSession(Long userId, String sessionId, String refreshToken, long ttlMillis) { + String sessionsKey = sessionsKey(userId); redisTemplate.opsForValue() - .set(KEY_PREFIX + userId, refreshToken, ttlMillis, TimeUnit.MILLISECONDS); + .set(sessionKey(userId, sessionId), refreshToken, ttlMillis, TimeUnit.MILLISECONDS); + redisTemplate.opsForZSet().add(sessionsKey, sessionId, System.currentTimeMillis()); + redisTemplate.expire(sessionsKey, ttlMillis, TimeUnit.MILLISECONDS); + + pruneSessions(userId); + } + + /** + * 저장된 Refresh Token이 currentToken과 일치할 때만 newToken으로 교체한다. + * 일치하지 않으면 이미 회전된 토큰의 재사용(탈취 의심)으로 보고 해당 세션을 폐기한다. + */ + public RotationResult rotate(Long userId, String sessionId, String currentToken, String newToken, + long ttlMillis) { + Long result = redisTemplate.execute( + ROTATE_SCRIPT, + List.of(sessionKey(userId, sessionId), sessionsKey(userId)), + currentToken, newToken, String.valueOf(ttlMillis), sessionId); + if (Long.valueOf(1L).equals(result)) { + return RotationResult.ROTATED; + } + if (Long.valueOf(0L).equals(result)) { + return RotationResult.REUSE_DETECTED; + } + return RotationResult.SESSION_NOT_FOUND; + } + + public void deleteSession(Long userId, String sessionId) { + redisTemplate.delete(sessionKey(userId, sessionId)); + redisTemplate.opsForZSet().remove(sessionsKey(userId), sessionId); + } + + public void deleteAllSessions(Long userId) { + String sessionsKey = sessionsKey(userId); + Set sessionIds = redisTemplate.opsForZSet().range(sessionsKey, 0, -1); + List keys = new ArrayList<>(); + if (sessionIds != null) { + sessionIds.forEach(sessionId -> keys.add(sessionKey(userId, sessionId))); + } + keys.add(sessionsKey); + redisTemplate.delete(keys); + } + + // 만료된 세션을 목록에서 정리한 뒤, 남은 세션이 최대 개수를 넘으면 오래된 순으로 제거한다. + // score(생성 시각)는 refresh로 연장된 세션을 구분하지 못하므로, 만료는 Refresh Token 키 존재 여부로 판단한다. + private void pruneSessions(Long userId) { + String sessionsKey = sessionsKey(userId); + Set sessionIds = redisTemplate.opsForZSet().range(sessionsKey, 0, -1); + if (sessionIds == null) { + return; + } + List activeSessionIds = new ArrayList<>(); + for (String sessionId : sessionIds) { + if (Boolean.TRUE.equals(redisTemplate.hasKey(sessionKey(userId, sessionId)))) { + activeSessionIds.add(sessionId); + } else { + redisTemplate.opsForZSet().remove(sessionsKey, sessionId); + } + } + for (int i = 0; i < activeSessionIds.size() - MAX_SESSIONS; i++) { + deleteSession(userId, activeSessionIds.get(i)); + } } - public Optional find(Long userId) { - String value = redisTemplate.opsForValue().get(KEY_PREFIX + userId); - return Optional.ofNullable(value); + private String sessionKey(Long userId, String sessionId) { + return KEY_PREFIX + userId + ":" + sessionId; } - public void delete(Long userId) { - redisTemplate.delete(KEY_PREFIX + userId); + private String sessionsKey(Long userId) { + return SESSIONS_KEY_PREFIX + userId; } } diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java index 8d5a6b38..026bcb39 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -19,9 +19,11 @@ import com.Coming.Backend.auth.exception.TermsNotAgreedException; import com.Coming.Backend.auth.exception.UserNotFoundException; import com.Coming.Backend.auth.jwt.JwtProvider; +import com.Coming.Backend.auth.jwt.RefreshTokenClaims; import com.Coming.Backend.artist.repository.UserFollowArtistRepository; import com.Coming.Backend.auth.repository.BlacklistRepository; import com.Coming.Backend.auth.repository.TokenRepository; +import com.Coming.Backend.auth.repository.TokenRepository.RotationResult; import com.Coming.Backend.auth.repository.UserRepository; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.inquiry.repository.InquiryRepository; @@ -33,6 +35,7 @@ import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -56,38 +59,49 @@ public class AuthService { /** * Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다. - * 기존 Refresh Token은 즉시 교체되어 재사용이 불가능하다. + * 해당 세션(기기)의 Refresh Token만 교체되며, 이미 회전된 Refresh Token이 다시 제출되면 그 세션을 폐기한다. * * @param refreshToken HttpOnly Cookie에서 추출한 Refresh Token */ public TokenPair refreshToken(String refreshToken) { - Long userId = extractUserIdFromRefreshToken(refreshToken); - validateStoredRefreshToken(userId, refreshToken); + RefreshTokenClaims claims = parseRefreshToken(refreshToken); + Long userId = claims.userId(); + String sessionId = claims.sessionId(); User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); if (user.getStatus() != UserStatus.ACTIVE) { throw new RefreshTokenInvalidException(); } String newAccessToken = jwtProvider.generateAccessToken(userId, user.getRole().name()); - String newRefreshToken = jwtProvider.generateRefreshToken(userId); - tokenRepository.save(userId, newRefreshToken, jwtProvider.getRefreshTokenExpiry()); + String newRefreshToken = jwtProvider.generateRefreshToken(userId, sessionId); + RotationResult result = tokenRepository.rotate(userId, sessionId, refreshToken, newRefreshToken, + jwtProvider.getRefreshTokenExpiry()); + if (result == RotationResult.REUSE_DETECTED) { + log.warn("Refresh Token 재사용 감지 — 세션 폐기, userId: {}", userId); + } + if (result != RotationResult.ROTATED) { + throw new RefreshTokenInvalidException(); + } return new TokenPair(newAccessToken, newRefreshToken); } /** - * Access Token을 블랙리스트에 등록하고 Refresh Token을 삭제한다. + * Access Token을 블랙리스트에 등록하고 현재 기기의 세션만 삭제한다. + * Refresh Token이 없거나 유효하지 않으면 세션 삭제는 건너뛴다 (세션은 TTL 만료로 정리). * - * @param accessToken Authorization 헤더에서 추출한 Access Token - * @param userId 인증된 사용자 ID + * @param accessToken Authorization 헤더에서 추출한 Access Token + * @param userId 인증된 사용자 ID + * @param refreshToken HttpOnly Cookie에서 추출한 Refresh Token (nullable) */ - public void logout(String accessToken, Long userId) { + public void logout(String accessToken, Long userId, String refreshToken) { blacklistRepository.save(accessToken, jwtProvider.getRemainingExpiry(accessToken)); - tokenRepository.delete(userId); + findOwnSessionId(userId, refreshToken) + .ifPresent(sessionId -> tokenRepository.deleteSession(userId, sessionId)); log.info("로그아웃"); } /** - * 회원 탈퇴 처리 후 토큰을 무효화한다. + * 회원 탈퇴 처리 후 Access Token을 블랙리스트에 등록하고 모든 기기의 세션을 삭제한다. * * @param accessToken Authorization 헤더에서 추출한 Access Token * @param userId 인증된 사용자 ID @@ -101,7 +115,8 @@ public void withdraw(String accessToken, Long userId) { User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); user.withdraw(); userRepository.flush(); - logout(accessToken, userId); + blacklistRepository.save(accessToken, jwtProvider.getRemainingExpiry(accessToken)); + tokenRepository.deleteAllSessions(userId); log.info("회원 탈퇴"); } @@ -206,9 +221,9 @@ public NicknameCheckResponse checkNickname(String nickname) { return new NicknameCheckResponse(available); } - private Long extractUserIdFromRefreshToken(String refreshToken) { + private RefreshTokenClaims parseRefreshToken(String refreshToken) { try { - return jwtProvider.getUserId(refreshToken); + return jwtProvider.parseRefreshToken(refreshToken); } catch (ExpiredTokenException e) { throw new RefreshTokenExpiredException(); } catch (InvalidTokenException e) { @@ -216,11 +231,16 @@ private Long extractUserIdFromRefreshToken(String refreshToken) { } } - private void validateStoredRefreshToken(Long userId, String refreshToken) { - String stored = tokenRepository.find(userId) - .orElseThrow(RefreshTokenInvalidException::new); - if (!stored.equals(refreshToken)) { - throw new RefreshTokenInvalidException(); + // 다른 사용자의 Refresh Token으로 그 사용자의 세션을 지우지 못하도록 소유자를 확인한다. + private Optional findOwnSessionId(Long userId, String refreshToken) { + if (refreshToken == null) { + return Optional.empty(); + } + try { + RefreshTokenClaims claims = jwtProvider.parseRefreshToken(refreshToken); + return userId.equals(claims.userId()) ? Optional.of(claims.sessionId()) : Optional.empty(); + } catch (ExpiredTokenException | InvalidTokenException e) { + return Optional.empty(); } } diff --git a/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java b/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java index 875ef788..75856ab6 100644 --- a/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/Coming/Backend/auth/controller/AuthControllerTest.java @@ -1,12 +1,16 @@ package com.Coming.Backend.auth.controller; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; 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; @@ -14,6 +18,7 @@ import com.Coming.Backend.auth.dto.MeResponse; import com.Coming.Backend.auth.dto.NicknameCheckResponse; import com.Coming.Backend.auth.dto.RegisterRequest; +import com.Coming.Backend.auth.dto.TokenPair; import com.Coming.Backend.auth.dto.TokenResponse; import com.Coming.Backend.auth.exception.NicknameDuplicateException; import com.Coming.Backend.auth.exception.TermsNotAgreedException; @@ -21,6 +26,7 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.common.exception.GlobalExceptionHandler; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -35,6 +41,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @ExtendWith(MockitoExtension.class) @@ -184,4 +191,62 @@ void should_return_200_with_available_false_when_nickname_is_taken() throws Exce .andExpect(status().isOk()) .andExpect(jsonPath("$.available").value(false)); } + + // ------------------------------------------------------------------------- + // POST /api/auth/logout + // ------------------------------------------------------------------------- + + @Test + void should_return_200_and_pass_refresh_token_when_logout_with_cookie() throws Exception { + // given + String refreshToken = "refresh-token-value"; + + // when & then + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer " + ACCESS_TOKEN) + .cookie(new Cookie("refreshToken", refreshToken))) + .andExpect(status().isOk()) + .andExpect(header().string("Set-Cookie", containsString("Max-Age=0"))); + verify(authService).logout(ACCESS_TOKEN, USER_ID, refreshToken); + } + + @Test + void should_return_200_when_logout_without_refresh_token_cookie() throws Exception { + // given — Refresh Token 쿠키 없음 + + // when & then + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer " + ACCESS_TOKEN)) + .andExpect(status().isOk()); + verify(authService).logout(ACCESS_TOKEN, USER_ID, null); + } + + @Test + void should_issueNonSecureCookie_when_cookieSecureDisabled() throws Exception { + // given — 로컬(http) 환경: app.cookie.secure=false + ReflectionTestUtils.setField(authController, "cookieSecure", false); + given(authService.refreshToken("refresh-token-value")) + .willReturn(new TokenPair(ACCESS_TOKEN, "new-refresh-token")); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .cookie(new Cookie("refreshToken", "refresh-token-value"))) + .andExpect(status().isOk()) + .andExpect(header().string("Set-Cookie", containsString("new-refresh-token"))) + .andExpect(header().string("Set-Cookie", not(containsString("Secure")))); + } + + @Test + void should_issueSecureCookie_when_cookieSecureEnabled() throws Exception { + // given + ReflectionTestUtils.setField(authController, "cookieSecure", true); + given(authService.refreshToken("refresh-token-value")) + .willReturn(new TokenPair(ACCESS_TOKEN, "new-refresh-token")); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .cookie(new Cookie("refreshToken", "refresh-token-value"))) + .andExpect(status().isOk()) + .andExpect(header().string("Set-Cookie", containsString("Secure"))); + } } diff --git a/src/test/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilterTest.java b/src/test/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilterTest.java index 291e47eb..ab2b1def 100644 --- a/src/test/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/Coming/Backend/auth/jwt/JwtAuthenticationFilterTest.java @@ -64,6 +64,7 @@ void should_setAuthentication_when_validTokenGiven() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(claims.getSubject()).willReturn(USER_ID.toString()); given(claims.get("role", String.class)).willReturn(ROLE); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(false); @@ -85,6 +86,7 @@ void should_notSetAuthentication_when_tokenIsBlacklisted() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(true); // when @@ -94,6 +96,21 @@ void should_notSetAuthentication_when_tokenIsBlacklisted() throws Exception { assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } + @Test + void should_notSetAuthentication_when_refreshTokenGivenAsBearer() throws Exception { + // given — 서명이 유효한 Refresh Token을 Bearer로 제출 + request.addHeader("Authorization", "Bearer " + VALID_TOKEN); + given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(false); + + // when + filter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + assertThat(filterChain.getRequest()).isNotNull(); + } + @Test void should_passFilterWithoutAuthentication_when_tokenIsTampered() throws Exception { // given @@ -136,6 +153,7 @@ void should_invokeNextFilter_when_validTokenGiven() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(claims.getSubject()).willReturn(USER_ID.toString()); given(claims.get("role", String.class)).willReturn(ROLE); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(false); @@ -166,6 +184,7 @@ void should_setMdcUserId_when_validTokenGiven() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(claims.getSubject()).willReturn(USER_ID.toString()); given(claims.get("role", String.class)).willReturn(ROLE); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(false); @@ -185,6 +204,7 @@ void should_clearMdcUserId_after_requestCompletes() throws Exception { // given request.addHeader("Authorization", "Bearer " + VALID_TOKEN); given(jwtProvider.parseClaims(VALID_TOKEN)).willReturn(claims); + given(jwtProvider.isAccessToken(claims)).willReturn(true); given(claims.getSubject()).willReturn(USER_ID.toString()); given(claims.get("role", String.class)).willReturn(ROLE); given(blacklistRepository.isBlacklisted(VALID_TOKEN)).willReturn(false); diff --git a/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java b/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java index 2c54d70e..e02a366d 100644 --- a/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java +++ b/src/test/java/com/Coming/Backend/auth/jwt/JwtProviderTest.java @@ -5,6 +5,10 @@ import com.Coming.Backend.auth.exception.ExpiredTokenException; import com.Coming.Backend.auth.exception.InvalidTokenException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.util.Date; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -32,7 +36,7 @@ void should_returnUserId_when_parseAccessToken() { String token = jwtProvider.generateAccessToken(userId, role); // then - assertThat(jwtProvider.getUserId(token)).isEqualTo(userId); + assertThat(jwtProvider.parseClaims(token).getSubject()).isEqualTo(userId.toString()); } @Test @@ -57,7 +61,85 @@ void should_returnUserId_when_parseRefreshToken() { String token = jwtProvider.generateRefreshToken(userId); // then - assertThat(jwtProvider.getUserId(token)).isEqualTo(userId); + assertThat(jwtProvider.parseRefreshToken(token).userId()).isEqualTo(userId); + } + + @Test + void should_issueDistinctSessionIds_when_generateRefreshTokenTwiceInSameSecond() { + // given + Long userId = 1L; + + // when + String first = jwtProvider.generateRefreshToken(userId); + String second = jwtProvider.generateRefreshToken(userId); + + // then + assertThat(first).isNotEqualTo(second); + assertThat(jwtProvider.parseRefreshToken(first).sessionId()) + .isNotBlank() + .isNotEqualTo(jwtProvider.parseRefreshToken(second).sessionId()); + } + + @Test + void should_keepSessionId_when_generateRefreshTokenWithSessionId() { + // given + String sessionId = "session-1"; + + // when + String token = jwtProvider.generateRefreshToken(1L, sessionId); + + // then + assertThat(jwtProvider.parseRefreshToken(token)) + .isEqualTo(new RefreshTokenClaims(1L, sessionId)); + } + + @Test + void should_throwInvalidTokenException_when_parseRefreshTokenWithAccessToken() { + // given + String accessToken = jwtProvider.generateAccessToken(1L, "USER"); + + // when & then + assertThatThrownBy(() -> jwtProvider.parseRefreshToken(accessToken)) + .isInstanceOf(InvalidTokenException.class); + } + + @Test + void should_throwInvalidTokenException_when_refreshTokenHasNoJtiOrType() { + // given — 세션 식별자·토큰 종류 도입 이전 형식의 Refresh Token + String legacyToken = Jwts.builder() + .subject("1") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + REFRESH_EXPIRY)) + .signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET))) + .compact(); + + // when & then + assertThatThrownBy(() -> jwtProvider.parseRefreshToken(legacyToken)) + .isInstanceOf(InvalidTokenException.class); + } + + @Test + void should_returnTrue_when_checkAccessTokenType() { + // given + String accessToken = jwtProvider.generateAccessToken(1L, "USER"); + + // when + boolean isAccessToken = jwtProvider.isAccessToken(jwtProvider.parseClaims(accessToken)); + + // then + assertThat(isAccessToken).isTrue(); + } + + @Test + void should_returnFalse_when_checkRefreshTokenAsAccessToken() { + // given + String refreshToken = jwtProvider.generateRefreshToken(1L); + + // when + boolean isAccessToken = jwtProvider.isAccessToken(jwtProvider.parseClaims(refreshToken)); + + // then + assertThat(isAccessToken).isFalse(); } @Test diff --git a/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java b/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java index 57562b19..b6bf90fa 100644 --- a/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java +++ b/src/test/java/com/Coming/Backend/auth/oauth2/OAuth2SuccessHandlerTest.java @@ -8,6 +8,7 @@ import com.Coming.Backend.auth.entity.UserRole; import com.Coming.Backend.auth.entity.UserStatus; import com.Coming.Backend.auth.jwt.JwtProvider; +import com.Coming.Backend.auth.jwt.RefreshTokenClaims; import com.Coming.Backend.auth.repository.TokenRepository; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -62,6 +63,8 @@ void should_redirect_with_isNewUser_false_when_existing_user_logs_in() throws Ex CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.USER), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -79,6 +82,8 @@ void should_redirect_with_isNewUser_true_when_new_user_logs_in() throws Exceptio CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.PENDING), Map.of(), true); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -96,6 +101,8 @@ void should_redirect_with_isNewUser_true_when_returning_pending_user_logs_in() t CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.PENDING), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -113,6 +120,8 @@ void should_set_httpOnly_refresh_token_cookie_when_authentication_success() thro CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.USER), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -128,11 +137,13 @@ void should_set_httpOnly_refresh_token_cookie_when_authentication_success() thro } @Test - void should_call_tokenRepository_save_when_authentication_success() throws Exception { + void should_saveNewSession_when_authentication_success() throws Exception { // given CustomOAuth2User oAuth2User = new CustomOAuth2User(buildUser(UserRole.USER), Map.of(), false); given(authentication.getPrincipal()).willReturn(oAuth2User); given(jwtProvider.generateRefreshToken(1L)).willReturn("refresh-token-value"); + given(jwtProvider.parseRefreshToken("refresh-token-value")) + .willReturn(new RefreshTokenClaims(1L, "session-1")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -141,6 +152,6 @@ void should_call_tokenRepository_save_when_authentication_success() throws Excep oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); // then - verify(tokenRepository).save(1L, "refresh-token-value", REFRESH_TOKEN_EXPIRY); + verify(tokenRepository).saveSession(1L, "session-1", "refresh-token-value", REFRESH_TOKEN_EXPIRY); } } diff --git a/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java b/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java index 8825eba9..078c8c1f 100644 --- a/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/auth/repository/TokenRepositoryTest.java @@ -1,22 +1,34 @@ package com.Coming.Backend.auth.repository; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import java.util.Optional; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; +import com.Coming.Backend.auth.repository.TokenRepository.RotationResult; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.data.redis.core.script.RedisScript; @ExtendWith(MockitoExtension.class) class TokenRepositoryTest { + private static final long TTL = 604_800_000L; + @InjectMocks private TokenRepository tokenRepository; @@ -26,55 +38,131 @@ class TokenRepositoryTest { @Mock private ValueOperations valueOperations; + @Mock + private ZSetOperations zSetOperations; + @Test - void should_return_token_when_find_after_save() { + void should_storeTokenAndRegisterSession_when_saveSession() { // given - Long userId = 1L; - String refreshToken = "refresh-token-value"; - long ttlMillis = 604_800_000L; - given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.get("RT:1")).willReturn(refreshToken); + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(zSetOperations.range("RT_SESSIONS:1", 0, -1)).willReturn(Set.of("s1")); + given(redisTemplate.hasKey("RT:1:s1")).willReturn(true); // when - tokenRepository.save(userId, refreshToken, ttlMillis); - Optional result = tokenRepository.find(userId); + tokenRepository.saveSession(1L, "s1", "token", TTL); // then - assertThat(result).isPresent().contains(refreshToken); - verify(valueOperations).set("RT:1", refreshToken, ttlMillis, TimeUnit.MILLISECONDS); + verify(valueOperations).set("RT:1:s1", "token", TTL, TimeUnit.MILLISECONDS); + verify(zSetOperations).add(eq("RT_SESSIONS:1"), eq("s1"), anyDouble()); + verify(redisTemplate).expire("RT_SESSIONS:1", TTL, TimeUnit.MILLISECONDS); + verify(redisTemplate, never()).delete(anyString()); } @Test - void should_return_empty_when_find_after_delete() { + void should_removeSessionFromList_when_itsTokenHasExpired() { // given - Long userId = 1L; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(zSetOperations.range("RT_SESSIONS:1", 0, -1)).willReturn(orderedSet("expired", "s1")); + given(redisTemplate.hasKey("RT:1:expired")).willReturn(false); + given(redisTemplate.hasKey("RT:1:s1")).willReturn(true); + + // when + tokenRepository.saveSession(1L, "s1", "token", TTL); + + // then + verify(zSetOperations).remove("RT_SESSIONS:1", "expired"); + verify(zSetOperations, never()).remove("RT_SESSIONS:1", "s1"); + } + @Test + void should_evictOldestSession_when_sessionCountExceedsLimit() { + // given + Set sessionIds = orderedSet("s0", "s1", "s2", "s3", "s4", "s5"); given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.get("RT:1")).willReturn(null); - given(redisTemplate.delete("RT:1")).willReturn(true); + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(zSetOperations.range("RT_SESSIONS:1", 0, -1)).willReturn(sessionIds); + given(redisTemplate.hasKey(anyString())).willReturn(true); // when - tokenRepository.delete(userId); - Optional result = tokenRepository.find(userId); + tokenRepository.saveSession(1L, "s5", "token", TTL); // then - assertThat(result).isEmpty(); - verify(redisTemplate).delete("RT:1"); + verify(redisTemplate).delete("RT:1:s0"); + verify(zSetOperations).remove("RT_SESSIONS:1", "s0"); + verify(redisTemplate, never()).delete("RT:1:s1"); } @Test - void should_return_empty_when_find_with_unsaved_userId() { + void should_returnRotated_when_storedTokenMatches() { // given - Long userId = 999L; + givenRotateScriptReturns(1L); - given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.get("RT:999")).willReturn(null); + // when + RotationResult result = tokenRepository.rotate(1L, "s1", "old", "new", TTL); + + // then + assertThat(result).isEqualTo(RotationResult.ROTATED); + } + + @Test + void should_returnReuseDetected_when_storedTokenDoesNotMatch() { + // given — 스크립트가 불일치 시 세션을 폐기하고 0을 반환 + givenRotateScriptReturns(0L); // when - Optional result = tokenRepository.find(userId); + RotationResult result = tokenRepository.rotate(1L, "s1", "old", "new", TTL); // then - assertThat(result).isEmpty(); + assertThat(result).isEqualTo(RotationResult.REUSE_DETECTED); + } + + @Test + void should_returnSessionNotFound_when_sessionDoesNotExist() { + // given + givenRotateScriptReturns(-1L); + + // when + RotationResult result = tokenRepository.rotate(1L, "s1", "old", "new", TTL); + + // then + assertThat(result).isEqualTo(RotationResult.SESSION_NOT_FOUND); + } + + @Test + void should_deleteOnlyThatSession_when_deleteSession() { + // given + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + + // when + tokenRepository.deleteSession(1L, "s1"); + + // then + verify(redisTemplate).delete("RT:1:s1"); + verify(zSetOperations).remove("RT_SESSIONS:1", "s1"); + } + + @Test + void should_deleteEverySessionAndList_when_deleteAllSessions() { + // given + given(redisTemplate.opsForZSet()).willReturn(zSetOperations); + given(zSetOperations.range("RT_SESSIONS:1", 0, -1)).willReturn(orderedSet("s1", "s2")); + + // when + tokenRepository.deleteAllSessions(1L); + + // then + verify(redisTemplate).delete(List.of("RT:1:s1", "RT:1:s2", "RT_SESSIONS:1")); + } + + private void givenRotateScriptReturns(Long scriptResult) { + given(redisTemplate.execute(ArgumentMatchers.>any(), + eq(List.of("RT:1:s1", "RT_SESSIONS:1")), + eq("old"), eq("new"), eq(String.valueOf(TTL)), eq("s1"))).willReturn(scriptResult); + } + + private static Set orderedSet(String... values) { + return new LinkedHashSet<>(List.of(values)); } } diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java index 97056881..708b277b 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -4,9 +4,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -21,15 +21,18 @@ import com.Coming.Backend.auth.entity.UserRole; import com.Coming.Backend.auth.entity.UserStatus; import com.Coming.Backend.auth.exception.ExpiredTokenException; +import com.Coming.Backend.auth.exception.InvalidTokenException; import com.Coming.Backend.auth.exception.NicknameDuplicateException; import com.Coming.Backend.auth.exception.RefreshTokenExpiredException; import com.Coming.Backend.auth.exception.RefreshTokenInvalidException; import com.Coming.Backend.auth.exception.UserNotFoundException; import org.springframework.dao.DataIntegrityViolationException; import com.Coming.Backend.auth.jwt.JwtProvider; +import com.Coming.Backend.auth.jwt.RefreshTokenClaims; import com.Coming.Backend.artist.repository.UserFollowArtistRepository; import com.Coming.Backend.auth.repository.BlacklistRepository; import com.Coming.Backend.auth.repository.TokenRepository; +import com.Coming.Backend.auth.repository.TokenRepository.RotationResult; import com.Coming.Backend.auth.repository.UserRepository; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; @@ -87,6 +90,9 @@ class AuthServiceTest { private static final String NEW_REFRESH_TOKEN = "new-refresh-token"; private static final String ACCESS_TOKEN = "valid-access-token"; private static final String NEW_ACCESS_TOKEN = "new-access-token"; + private static final String SESSION_ID = "session-1"; + private static final long REFRESH_EXPIRY = 604_800_000L; + private static final RefreshTokenClaims REFRESH_CLAIMS = new RefreshTokenClaims(USER_ID, SESSION_ID); private User buildUser() { return User.builder() @@ -128,12 +134,13 @@ private PolicyDocument buildPolicyDocument(Long policyId, PolicyType type) { void should_return_new_token_pair_and_rotate_refresh_token_when_valid() { // given User user = buildUser(); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of(REFRESH_TOKEN)); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); - given(jwtProvider.generateRefreshToken(USER_ID)).willReturn(NEW_REFRESH_TOKEN); - given(jwtProvider.getRefreshTokenExpiry()).willReturn(604800000L); + given(jwtProvider.generateRefreshToken(USER_ID, SESSION_ID)).willReturn(NEW_REFRESH_TOKEN); + given(jwtProvider.getRefreshTokenExpiry()).willReturn(REFRESH_EXPIRY); + given(tokenRepository.rotate(USER_ID, SESSION_ID, REFRESH_TOKEN, NEW_REFRESH_TOKEN, REFRESH_EXPIRY)) + .willReturn(RotationResult.ROTATED); // when TokenPair result = authService.refreshToken(REFRESH_TOKEN); @@ -141,13 +148,13 @@ void should_return_new_token_pair_and_rotate_refresh_token_when_valid() { // then assertThat(result.accessToken()).isEqualTo(NEW_ACCESS_TOKEN); assertThat(result.refreshToken()).isEqualTo(NEW_REFRESH_TOKEN); - verify(tokenRepository).save(USER_ID, NEW_REFRESH_TOKEN, 604800000L); + verify(tokenRepository).rotate(USER_ID, SESSION_ID, REFRESH_TOKEN, NEW_REFRESH_TOKEN, REFRESH_EXPIRY); } @Test void should_throw_refresh_token_expired_exception_when_refresh_token_is_expired() { // given - given(jwtProvider.getUserId(REFRESH_TOKEN)).willThrow(new ExpiredTokenException()); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willThrow(new ExpiredTokenException()); // when & then assertThatThrownBy(() -> authService.refreshToken(REFRESH_TOKEN)) @@ -155,6 +162,18 @@ void should_throw_refresh_token_expired_exception_when_refresh_token_is_expired( .hasMessage(ErrorCode.REFRESH_TOKEN_EXPIRED.getMessage()); } + @Test + void should_throw_RefreshTokenInvalidException_when_token_is_not_a_valid_refresh_token() { + // given — Access Token 또는 jti·typ 도입 이전에 발급된 Refresh Token + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willThrow(new InvalidTokenException()); + + // when & then + assertThatThrownBy(() -> authService.refreshToken(REFRESH_TOKEN)) + .isInstanceOf(RefreshTokenInvalidException.class) + .hasMessage(ErrorCode.REFRESH_TOKEN_INVALID.getMessage()); + verify(tokenRepository, never()).rotate(any(), any(), any(), any(), anyLong()); + } + @Test void should_throw_RefreshTokenInvalidException_when_inactive_user_refreshes_token() { // given @@ -165,8 +184,7 @@ void should_throw_RefreshTokenInvalidException_when_inactive_user_refreshes_toke .provider("google") .providerId("google-123") .build(); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of(REFRESH_TOKEN)); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); given(userRepository.findById(USER_ID)).willReturn(Optional.of(inactiveUser)); // when & then @@ -185,8 +203,7 @@ void should_throw_RefreshTokenInvalidException_when_suspended_user_refreshes_tok .provider("google") .providerId("google-123") .build(); - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of(REFRESH_TOKEN)); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); given(userRepository.findById(USER_ID)).willReturn(Optional.of(suspendedUser)); // when & then @@ -196,10 +213,34 @@ void should_throw_RefreshTokenInvalidException_when_suspended_user_refreshes_tok } @Test - void should_throw_refresh_token_invalid_exception_when_refresh_token_does_not_match_redis() { - // given - given(jwtProvider.getUserId(REFRESH_TOKEN)).willReturn(USER_ID); - given(tokenRepository.find(USER_ID)).willReturn(Optional.of("different-token")); + void should_throw_RefreshTokenInvalidException_when_reused_refresh_token_is_detected() { + // given — 이미 회전된 토큰 재제출 (세션은 저장소에서 폐기됨) + User user = buildUser(); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); + given(jwtProvider.generateRefreshToken(USER_ID, SESSION_ID)).willReturn(NEW_REFRESH_TOKEN); + given(jwtProvider.getRefreshTokenExpiry()).willReturn(REFRESH_EXPIRY); + given(tokenRepository.rotate(USER_ID, SESSION_ID, REFRESH_TOKEN, NEW_REFRESH_TOKEN, REFRESH_EXPIRY)) + .willReturn(RotationResult.REUSE_DETECTED); + + // when & then + assertThatThrownBy(() -> authService.refreshToken(REFRESH_TOKEN)) + .isInstanceOf(RefreshTokenInvalidException.class) + .hasMessage(ErrorCode.REFRESH_TOKEN_INVALID.getMessage()); + } + + @Test + void should_throw_RefreshTokenInvalidException_when_session_does_not_exist() { + // given — 로그아웃·세션 상한 초과·탈퇴로 제거된 세션 + User user = buildUser(); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); + given(jwtProvider.generateRefreshToken(USER_ID, SESSION_ID)).willReturn(NEW_REFRESH_TOKEN); + given(jwtProvider.getRefreshTokenExpiry()).willReturn(REFRESH_EXPIRY); + given(tokenRepository.rotate(USER_ID, SESSION_ID, REFRESH_TOKEN, NEW_REFRESH_TOKEN, REFRESH_EXPIRY)) + .willReturn(RotationResult.SESSION_NOT_FOUND); // when & then assertThatThrownBy(() -> authService.refreshToken(REFRESH_TOKEN)) @@ -212,19 +253,59 @@ void should_throw_refresh_token_invalid_exception_when_refresh_token_does_not_ma // ------------------------------------------------------------------------- @Test - void should_register_blacklist_and_delete_refresh_token_when_logout() { + void should_register_blacklist_and_delete_current_session_when_logout() { + // given + long remainingExpiry = 900_000L; + given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(remainingExpiry); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(REFRESH_CLAIMS); + + // when + authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); + + // then + verify(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); + verify(tokenRepository).deleteSession(USER_ID, SESSION_ID); + verify(tokenRepository, never()).deleteAllSessions(any()); + } + + @Test + void should_only_register_blacklist_when_logout_without_refresh_token() { // given long remainingExpiry = 900_000L; given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(remainingExpiry); - willDoNothing().given(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - willDoNothing().given(tokenRepository).delete(USER_ID); // when - authService.logout(ACCESS_TOKEN, USER_ID); + authService.logout(ACCESS_TOKEN, USER_ID, null); // then verify(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - verify(tokenRepository).delete(USER_ID); + verify(tokenRepository, never()).deleteSession(any(), any()); + } + + @Test + void should_not_delete_session_when_logout_with_invalid_refresh_token() { + // given + given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(900_000L); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willThrow(new InvalidTokenException()); + + // when + authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); + + // then + verify(tokenRepository, never()).deleteSession(any(), any()); + } + + @Test + void should_not_delete_session_when_logout_with_other_users_refresh_token() { + // given + given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(900_000L); + given(jwtProvider.parseRefreshToken(REFRESH_TOKEN)).willReturn(new RefreshTokenClaims(2L, SESSION_ID)); + + // when + authService.logout(ACCESS_TOKEN, USER_ID, REFRESH_TOKEN); + + // then + verify(tokenRepository, never()).deleteSession(any(), any()); } // ------------------------------------------------------------------------- @@ -232,14 +313,12 @@ void should_register_blacklist_and_delete_refresh_token_when_logout() { // ------------------------------------------------------------------------- @Test - void should_call_withdraw_and_invalidate_tokens_when_withdraw() { + void should_call_withdraw_and_invalidate_all_sessions_when_withdraw() { // given User user = buildUser(); long remainingExpiry = 900_000L; given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(jwtProvider.getRemainingExpiry(ACCESS_TOKEN)).willReturn(remainingExpiry); - willDoNothing().given(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - willDoNothing().given(tokenRepository).delete(USER_ID); // when authService.withdraw(ACCESS_TOKEN, USER_ID); @@ -251,7 +330,7 @@ void should_call_withdraw_and_invalidate_tokens_when_withdraw() { verify(userConcertCalendarRepository).deleteByUserId(USER_ID); verify(inquiryRepository).deleteByUserId(USER_ID); verify(blacklistRepository).save(ACCESS_TOKEN, remainingExpiry); - verify(tokenRepository).delete(USER_ID); + verify(tokenRepository).deleteAllSessions(USER_ID); } // -------------------------------------------------------------------------