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` 다형 참조를 사용한다.
+
+
+
+
+
---
## 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);
}
// -------------------------------------------------------------------------