diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 00000000..fde2d061 --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,215 @@ +# πŸ“„ Backend PR Review Guide (for claude) + +## πŸ“Œ λͺ©μ  + +이 λ¬Έμ„œλŠ” λ°±μ—”λ“œ Pull Requestλ₯Ό 리뷰할 λ•Œ, +LLM이 μΌκ΄€λ˜κ³  싀무적인 κΈ°μ€€μœΌλ‘œ μ½”λ“œ 리뷰λ₯Ό μˆ˜ν–‰ν•˜λ„λ‘ 돕기 μœ„ν•œ κ°€μ΄λ“œμž…λ‹ˆλ‹€. + +λ¦¬λ·°λŠ” λ‹¨μˆœ μ½”λ“œ μŠ€νƒ€μΌμ΄ μ•„λ‹ˆλΌ, +**섀계 / μ•ˆμ •μ„± / μ„±λŠ₯ / λ³΄μ•ˆ / μœ μ§€λ³΄μˆ˜μ„± 쀑심**으둜 μˆ˜ν–‰ν•΄μ•Ό ν•©λ‹ˆλ‹€. + +--- + +## 리뷰 κΈ°λ³Έ 원칙 + +- λ‹¨μˆœ β€œμ’‹λ‹€/λ‚˜μ˜λ‹€β€κ°€ μ•„λ‹ˆλΌ **이유 + κ°œμ„  λ°©ν–₯** μ œμ‹œ +- κ°€λŠ₯ν•˜λ©΄ **λŒ€μ•ˆ μ½”λ“œ** λ˜λŠ” **ꡬ체적 μˆ˜μ • λ°©ν–₯** μ œμ‹œ +- λΆˆν•„μš”ν•œ nitpick μ§€μ–‘ +- **문제 β†’ 이유 β†’ κ°œμ„ μ•ˆ** ꡬ쑰둜 μ„€λͺ… + +--- + +## 1. μ•„ν‚€ν…μ²˜ / 섀계 + +- μ±…μž„ 뢄리가 잘 λ˜μ–΄ μžˆλŠ”κ°€? + - Controller / Service / Repository 역할이 λͺ…ν™•ν•œκ°€? +- λΉ„μ¦ˆλ‹ˆμŠ€ 둜직이 Service에 μœ„μΉ˜ν•˜λŠ”κ°€? +- Entity에 둜직이 적절히 ν¬ν•¨λ˜μ–΄ μžˆλŠ”κ°€? +- 도메인 쀑심 섀계가 λ˜μ–΄ μžˆλŠ”κ°€? + +### 체크 포인트 + +- Serviceκ°€ λ„ˆλ¬΄ λΉ„λŒ€ν•˜μ§€ μ•Šμ€κ°€? +- Controllerμ—μ„œ λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μ²˜λ¦¬ν•˜μ§€ μ•ŠλŠ”κ°€? +- Repositoryκ°€ λΆˆν•„μš”ν•œ λ‘œμ§μ„ κ°€μ§€μ§€ μ•ŠλŠ”κ°€? + +--- + +## 2. 도메인 λͺ¨λΈ (Entity) + +- μ—”ν‹°ν‹°κ°€ λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™μ„ 잘 ν‘œν˜„ν•˜λŠ”κ°€? +- setter λ‚¨μš© 없이 λ©”μ„œλ“œ 기반 변경인가? +- μƒνƒœ λ³€κ²½ λ©”μ„œλ“œκ°€ λͺ…ν™•ν•œκ°€? + +### 체크 포인트 + +- `user.restore()` 같은 λͺ…ν™•ν•œ ν–‰μœ„ λ©”μ„œλ“œ 쑴재 μ—¬λΆ€ +- λ¬΄λΆ„λ³„ν•œ public setter μ‚¬μš© μ—¬λΆ€ +- soft delete μ •μ±… 일관성 + +--- + +## 3. 인증 / λ³΄μ•ˆ + +- 인증 둜직이 μ•ˆμ „ν•œκ°€? +- μ™ΈλΆ€ API 토큰을 κ·ΈλŒ€λ‘œ μ‹ λ’°ν•˜μ§€ μ•ŠλŠ”κ°€? +- JWT μ‚¬μš© 방식이 μ˜¬λ°”λ₯Έκ°€? + +### 체크 포인트 + +- 우리 μ„œλΉ„μŠ€ JWT λ”°λ‘œ λ°œκΈ‰ν•˜λŠ”κ°€ +- refresh token μ €μž₯ μœ„μΉ˜κ°€ μ•ˆμ „ν•œκ°€ (DB/Redis) +- 민감 정보 둜그 좜λ ₯ μ—¬λΆ€ + +--- + +## 4. μ˜ˆμ™Έ 처리 + +- μ˜ˆμ™Έκ°€ μΌκ΄€λ˜κ²Œ μ²˜λ¦¬λ˜λŠ”κ°€? +- λΉ„μ¦ˆλ‹ˆμŠ€ μ˜ˆμ™Έ vs μ‹œμŠ€ν…œ μ˜ˆμ™Έ κ΅¬λΆ„λ˜μ–΄ μžˆλŠ”κ°€? + +### 체크 포인트 + +- ErrorCode enum μ‚¬μš© μ—¬λΆ€ +- try-catch λ‚¨μš© μ—¬λΆ€ +- 의미 μ—†λŠ” RuntimeException throw μ—¬λΆ€ + +--- + +## 5. νŠΈλžœμž­μ…˜ + +- ν•„μš”ν•œ 곳에 `@Transactional`이 μžˆλŠ”κ°€? +- readOnly 섀정이 μ μ ˆν•œκ°€? + +### 체크 포인트 + +- 쑰회 ν›„ μ—”ν‹°ν‹° μˆ˜μ • μ‹œ νŠΈλžœμž­μ…˜ 쑴재 μ—¬λΆ€ +- νŠΈλžœμž­μ…˜ λ²”μœ„κ°€ κ³Όλ„ν•˜μ§€ μ•Šμ€κ°€ + +--- + +## 6. μ„±λŠ₯ / 쿼리 + +- λΆˆν•„μš”ν•œ 쿼리가 λ°œμƒν•˜μ§€ μ•ŠλŠ”κ°€? +- N+1 문제 κ°€λŠ₯μ„± μžˆλŠ”κ°€? + +### 체크 포인트 + +- find ν›„ λ‹€μ‹œ μ‘°νšŒν•˜λŠ” ꡬ쑰 +- lazy loading 문제 +- 인덱슀 κ³ λ € μ—¬λΆ€ + +--- + +## 7. λ™μ‹œμ„± / 데이터 μ •ν•©μ„± + +- race condition κ°€λŠ₯μ„± μžˆλŠ”κ°€? +- 쀑볡 데이터 생성 κ°€λŠ₯μ„± μžˆλŠ”κ°€? + +### 체크 포인트 + +- μ†Œμ…œ 둜그인 쀑볡 κ°€μž… κ°€λŠ₯μ„± +- DB unique constraint 쑴재 μ—¬λΆ€ +- save 전에 κ²€μ¦λ§Œ ν•˜λŠ” ꡬ쑰 + +--- + +## 8. ν…ŒμŠ€νŠΈ κ°€λŠ₯μ„± + +- ν…ŒμŠ€νŠΈ κ°€λŠ₯ν•œ ꡬ쑰인가? +- μ˜μ‘΄μ„± 뢄리가 잘 λ˜μ–΄ μžˆλŠ”κ°€? + +### 체크 포인트 + +- Service λ‹¨μœ„ ν…ŒμŠ€νŠΈ κ°€λŠ₯ μ—¬λΆ€ +- μ™ΈλΆ€ API mock κ°€λŠ₯ μ—¬λΆ€ + +--- + +## 9. μ½”λ“œ 가독성 / μœ μ§€λ³΄μˆ˜μ„± + +- λ³€μˆ˜λͺ… / λ©”μ„œλ“œλͺ…이 λͺ…ν™•ν•œκ°€? +- μ½”λ“œ 흐름이 직관적인가? + +### 체크 포인트 + +- map/orElseGet λ‚¨μš© μ—¬λΆ€ +- if-elseκ°€ 더 λ‚˜μ€ κ²½μš°μΈμ§€ +- λ©”μ„œλ“œ 뢄리 μ μ ˆν•œκ°€ + +--- + +## 10. API 섀계 + +- RESTfulν•˜κ²Œ μ„€κ³„λ˜μ–΄ μžˆλŠ”κ°€? +- μš”μ²­/응닡이 λͺ…ν™•ν•œκ°€? + +### 체크 포인트 + +- DTO μ‚¬μš© μ—¬λΆ€ +- Entity 직접 λ°˜ν™˜ μ—¬λΆ€ +- 응닡 포맷 일관성 + +--- + +## 11. λ°μ΄ν„°λ² μ΄μŠ€ + +- μŠ€ν‚€λ§ˆ 섀계가 μ μ ˆν•œκ°€? +- μΈλ±μŠ€κ°€ ν•„μš”ν•œ 곳에 μžˆλŠ”κ°€? + +### 체크 포인트 + +- `(oauth_provider, oauth_id)` unique μ—¬λΆ€ +- nullable μ •μ±… μ μ ˆν•œκ°€ +- enum μ‚¬μš© μ‹œ ν™•μž₯μ„± κ³ λ €ν–ˆλŠ”κ°€ + +--- + +## 12. μœ„ν—˜ μ‹ ν˜Έ (μ€‘μš”) + +μ•„λž˜κ°€ 보이면 λ°˜λ“œμ‹œ μ§€μ ν•˜μ„Έμš”. + +- ❌ 인증 둜직이 Controller에 있음 +- ❌ Entity setter λ‚¨μš© +- ❌ νŠΈλžœμž­μ…˜ 없이 μ—”ν‹°ν‹° λ³€κ²½ +- ❌ unique μ œμ•½ 없이 쀑볡 생성 κ°€λŠ₯ +- ❌ μ™ΈλΆ€ API 응닡을 κ·ΈλŒ€λ‘œ μ‹ λ’° +- ❌ λΉ„μ¦ˆλ‹ˆμŠ€ 둜직이 μ—¬λŸ¬ 계측에 흩어짐 + +--- + +## 🧾 리뷰 좜λ ₯ ν˜•μ‹ + +```text +### 문제점 +- μ„€λͺ… + +### μ™œ λ¬Έμ œμΈκ°€ +- μ„€λͺ… + +### κ°œμ„  방법 +- μ„€λͺ… + +### κ°œμ„  μ˜ˆμ‹œ μ½”λ“œ (선택) +- μ½”λ“œ +``` + +--- + +## 리뷰 μš°μ„ μˆœμœ„ + +1. 버그 κ°€λŠ₯μ„± +2. 데이터 μ •ν•©μ„± +3. λ³΄μ•ˆ +4. μ„±λŠ₯ +5. 섀계 +6. 가독성 + +--- + +## μΆ”κ°€ μ§€μΉ¨ + +- μž‘μ€ μŠ€νƒ€μΌ 지적보닀 **치λͺ…적인 섀계 문제λ₯Ό μš°μ„ ** +- β€œμ΄κ±΄ μ·¨ν–₯” μˆ˜μ€€μ€ κ³Όν•˜κ²Œ μ§€μ ν•˜μ§€ μ•ŠκΈ° +- λ°˜λ“œμ‹œ **싀무 κΈ°μ€€μœΌλ‘œ νŒλ‹¨** + diff --git a/.github/workflows/pr-discord-notify.yml b/.github/workflows/pr-discord-notify.yml new file mode 100644 index 00000000..7d8284cd --- /dev/null +++ b/.github/workflows/pr-discord-notify.yml @@ -0,0 +1,210 @@ +name: 🌿 PR λ””μŠ€μ½”λ“œ μ•Œλ¦Ό + +on: + pull_request: + types: [opened, reopened, ready_for_review, closed] + +jobs: + # ────────────────────────────────────────────── + # PR μ—΄λ¦Ό β†’ 리뷰 μš”μ²­ μ•Œλ¦Ό + # ────────────────────────────────────────────── + notify-review: + if: >- + github.event.action != 'closed' + && !github.event.pull_request.draft + runs-on: ubuntu-latest + steps: + - name: 🎲 λ©”μ‹œμ§€ 뽑기 + id: message + run: | + MESSAGES=( + "μ‚΄λ €μ£Όμ„Έμš”... 리뷰 μ•ˆ ν•˜λ©΄ λ¨Έμ§€ λͺ» ν•΄μš”... πŸ₯Ί" + "λ”°λˆλ”°λˆν•œ PR이 λ‚˜μ™”μ–΄μš”! 식기 전에 λ¦¬λ·°ν•΄μ£Όμ„Έμš”~ 🍞" + "리뷰 ν•œ μŠ€ν‘Όλ§Œ... 제발... πŸ₯„" + "μƒˆ PR이 당신을 기닀리고 μžˆμ–΄μš”. μ½μ”Ήν•˜μ§€ λ§ˆμ„Έμš” πŸ’Œ" + "μ½”λ“œκ°€ μ™Έλ‘œμ›Œν•˜κ³  μžˆμ–΄μš”. λˆ„κ°€ μ’€ λ΄μ£Όμ„Έμš”... 🐾" + "PR 승인 λ²„νŠΌμ€ μ„Έμƒμ—μ„œ 제일 μ•„λ¦„λ‹€μš΄ λ²„νŠΌμ΄μ—μš” πŸ’š" + "λ¦¬λ·°ν•΄μ£Όμ‹œλ©΄ 였늘 ν•˜λ£¨ μ„œλ²„ μ•ˆ ν„°μ§‘λ‹ˆλ‹€ β˜•βœ¨" + "이 PR은 μ‚¬λž‘κ³Ό μ •μ„±μœΌλ‘œ λ§Œλ“€μ–΄μ‘ŒμŠ΅λ‹ˆλ‹€. 감상 λΆ€νƒλ“œλ¦½λ‹ˆλ‹€ 🎨" + "κΈ΄κΈ‰) μ½”λ“œκ°€ approveλ₯Ό κ°ˆλ§ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€ πŸ†˜" + "[속보] λ¨Έμ§€ λŒ€κΈ° 쀑인 PR 발견! 리뷰어 급ꡬ! πŸ“°" + "μŠ€ν”„λ§μ΄ 꽃을 ν”Όμš°λ €λ©΄ 리뷰가 ν•„μš”ν•©λ‹ˆλ‹€ 🌸" + "JPAκ°€ 울고 μžˆμ–΄μš”. 제발 λ΄μ£Όμ„Έμš” 😭" + "500 Internal Server Error... 리뷰 μ•ˆ ν•΄μ„œ κ·Έλž˜μš” πŸ”₯" + "이 PR μ•ˆ 보면 null pointer exception λ‚©λ‹ˆλ‹€ (μ§„μ§œμž„) πŸ‘€" + "λΉŒλ“œλŠ” μ„±κ³΅ν–ˆμ–΄μš”. 이제 리뷰만 λ‚¨μ•˜μŠ΅λ‹ˆλ‹€ πŸ› οΈ" + "컀밋 ν•˜λ‚˜ν•˜λ‚˜μ— 땀이 λ°°μ–΄μžˆμ–΄μš”... μ œλ°œμš” πŸ’¦" + "였늘 μ•Όκ·Ό μ—¬λΆ€λŠ” λ‹Ήμ‹ μ˜ 리뷰 속도에 λ‹¬λ €μžˆμŠ΅λ‹ˆλ‹€ πŸŒ™" + "λ¨Έμ§€ λͺ» ν•˜λ©΄ 잠 λͺ» μžμš”. 제발 λ¦¬λ·°ν•΄μ£Όμ„Έμš” 😴" + "이 μ½”λ“œ, 혼자 짜기 λ„ˆλ¬΄ μ™Έλ‘œμ› μ–΄μš” πŸ₯²" + "리뷰 μ•ˆ ν•˜λ©΄... λ‹€μŒ μŠ€ν”„λ¦°νŠΈμ— κΈ°μ–΅ν• κ²Œμš” πŸ‘οΈ" + "μ„Έλͺ¨μ‚°μ΄ λ‹Ήμ‹ μ˜ approveλ₯Ό 기닀리고 μžˆμ–΄μš” πŸ”οΈ" + "μ–΄μ°¨ν”Ό λ‹€ 같이 μ“Έ μ½”λ“œμž–μ•„μš”. 같이 λ΄μš” 🀝" + ) + INDEX=$(( RANDOM % ${#MESSAGES[@]} )) + echo "cute_msg=${MESSAGES[$INDEX]}" >> "$GITHUB_OUTPUT" + + - name: πŸ“¨ λ””μŠ€μ½”λ“œλ‘œ 리뷰 μš”μ²­ 보내기 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.BE_PR_WEBHOOK }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_BODY: ${{ github.event.pull_request.body }} + CUTE_MSG: ${{ steps.message.outputs.cute_msg }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + ADDITIONS: ${{ github.event.pull_request.additions }} + DELETIONS: ${{ github.event.pull_request.deletions }} + run: | + TOTAL_CHANGES=$(( ADDITIONS + DELETIONS )) + if [ "$TOTAL_CHANGES" -lt 50 ]; then + SIZE_LABEL="🍰 ν•œμž… 크기" + elif [ "$TOTAL_CHANGES" -lt 200 ]; then + SIZE_LABEL="πŸ” μ λ‹Ήν•œ 크기" + elif [ "$TOTAL_CHANGES" -lt 500 ]; then + SIZE_LABEL="πŸ• κ½€ 큰 편" + elif [ "$TOTAL_CHANGES" -lt 1000 ]; then + SIZE_LABEL="πŸ‹οΈ ν—¬μŠ€μž₯ κΈ‰ (νž˜λ‚΄μ„Έμš”...)" + else + SIZE_LABEL="πŸ’€ 리뷰어 사망각 (컀피 μ±™κΈ°μ„Έμš”)" + fi + + DESCRIPTION=$(echo "$PR_BODY" | head -c 200) + if [ ${#PR_BODY} -gt 200 ]; then + DESCRIPTION="${DESCRIPTION}..." + fi + DESCRIPTION=$(echo "$DESCRIPTION" | jq -Rs '.') + + curl -H "Content-Type: application/json" \ + -X POST \ + -d @- \ + "$DISCORD_WEBHOOK_URL" <- + github.event.action == 'closed' + && github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: 🎲 μΆ•ν•˜ λ©”μ‹œμ§€ 뽑기 + id: message + run: | + MESSAGES=( + "μ½”λ“œκ°€ λ¬΄μ‚¬νžˆ μƒˆ 집을 μ°Ύμ•˜μ–΄μš”! 이사 μΆ•ν•˜~ 🏠" + "λ¨Έμ§€ μ™„λ£Œ! 였늘의 μ˜μ›…μ€ λ°”λ‘œ λ‹Ήμ‹ ! 🦸" + "main λΈŒλžœμΉ˜κ°€ ν•œ 뼘 더 μžλžμ–΄μš” 🌱" + "μ„±κ³΅μ μœΌλ‘œ ν•©λ₯˜! μ„Έλͺ¨μ‚° λ°±μ—”λ“œ μ–΄λ²€μ ΈμŠ€ κ²°μ„± μ™„λ£Œ πŸ›‘οΈ" + "Merge! 이 μˆœκ°„μ„ μœ„ν•΄ μ‚΄μ•˜λ‹€... 감동 πŸ₯²" + "λΈŒλžœμΉ˜μ—¬ μ•ˆλ…•... λ„Œ main μ†μ—μ„œ μ˜μ›νžˆ μ‚΄ κ±°μ•Ό πŸ’" + "μΆ•) λ¨Έμ§€ 기념 컀피 ν•œ μž” μ–΄λ•Œμš”? β˜•πŸŽŠ" + "λ“œλ””μ–΄ ν•΄λƒˆλ‹€! 리뷰어뢄듀 κ°μ‚¬ν•©λ‹ˆλ‹€ 🫑✨" + "Spring이 ν™œμ§ ν”Όμ—ˆμŠ΅λ‹ˆλ‹€ 🌸" + "μ„œλ²„κ°€ ν•œμΈ΅ 더 κ°•ν•΄μ‘Œμ–΄μš” πŸ’ͺ" + "git log --oneline ν•œ 쀄이 λŠ˜μ—ˆμŠ΅λ‹ˆλ‹€ πŸ“œ" + "μ˜€λŠ˜λ„ 무사 λ¨Έμ§€... 개발자의 ν•˜λ£¨κ°€ λΉ›λ‚©λ‹ˆλ‹€ 🌟" + "리뷰어 μ—¬λŸ¬λΆ„, μ˜€λŠ˜λ„ μ„Έλͺ¨μ‚°μ„ μ§€μΌœμ£Όμ…”μ„œ κ°μ‚¬ν•©λ‹ˆλ‹€ πŸ”οΈ" + "λ°°ν¬κΉŒμ§€ λ‹¬λ €λ΄μš”! ν™”μ΄νŒ… πŸš€" + "이제 λ‹€μŒ 이슈둜 κ³ κ³ μ”½~ πŸ’¨" + ) + INDEX=$(( RANDOM % ${#MESSAGES[@]} )) + echo "cute_msg=${MESSAGES[$INDEX]}" >> "$GITHUB_OUTPUT" + + - name: 🎊 λ””μŠ€μ½”λ“œλ‘œ λ¨Έμ§€ μΆ•ν•˜ 보내기 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.BE_PR_WEBHOOK }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + MERGED_BY: ${{ github.event.pull_request.merged_by.login }} + CUTE_MSG: ${{ steps.message.outputs.cute_msg }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + ADDITIONS: ${{ github.event.pull_request.additions }} + DELETIONS: ${{ github.event.pull_request.deletions }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d @- \ + "$DISCORD_WEBHOOK_URL" < session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - // TODO: 인증 방식 κ²°μ • ν›„ κ²½λ‘œλ³„ μ ‘κ·Ό μ œμ–΄λ‘œ ꡐ체 .authorizeHttpRequests(auth -> auth .requestMatchers(SWAGGER_URIS).permitAll() + .requestMatchers(OAUTH_URIS).permitAll() + .requestMatchers(AUTH_URIS).permitAll() .anyRequest().authenticated() - ); + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/semosan/api/common/config/SwaggerConfig.java b/src/main/java/com/semosan/api/common/config/SwaggerConfig.java index aad912d8..e6d413de 100644 --- a/src/main/java/com/semosan/api/common/config/SwaggerConfig.java +++ b/src/main/java/com/semosan/api/common/config/SwaggerConfig.java @@ -1,8 +1,11 @@ package com.semosan.api.common.config; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,6 +15,16 @@ public class SwaggerConfig { @Bean public OpenAPI openAPI() { return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) + .components(new Components() + .addSecuritySchemes("bearerAuth", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT 기반 bearer 인증") + ) + ) .info(new Info() .title("Semosan Backend API") .description("KUSITMS 33TH Semosan ν”„λ‘œμ νŠΈ λ°±μ—”λ“œ API λ¬Έμ„œ") diff --git a/src/main/java/com/semosan/api/common/config/WebClientConfig.java b/src/main/java/com/semosan/api/common/config/WebClientConfig.java new file mode 100644 index 00000000..7228325e --- /dev/null +++ b/src/main/java/com/semosan/api/common/config/WebClientConfig.java @@ -0,0 +1,31 @@ +package com.semosan.api.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient kakaoAuthWebClient() { + return WebClient.builder() + .baseUrl("https://kauth.kakao.com") + .build(); + } + + @Bean + public WebClient kakaoApiWebClient() { + return WebClient.builder() + .baseUrl("https://kapi.kakao.com") + .build(); + } + + @Bean + public WebClient appleApiWebClient() { + return WebClient.builder() + .baseUrl("https://appleid.apple.com") + .build(); + } + +} diff --git a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java index 3a4493c3..8e87351b 100644 --- a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java +++ b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java @@ -1,7 +1,7 @@ package com.semosan.api.common.exception; import com.semosan.api.common.base.BaseStatus; -import com.semosan.api.common.base.ErrorStatus; +import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.common.response.ApiResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; diff --git a/src/main/java/com/semosan/api/common/jwt/JwtFilter.java b/src/main/java/com/semosan/api/common/jwt/JwtFilter.java new file mode 100644 index 00000000..4eed374f --- /dev/null +++ b/src/main/java/com/semosan/api/common/jwt/JwtFilter.java @@ -0,0 +1,78 @@ +package com.semosan.api.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.response.ApiResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + String accessToken = resolveToken(request); + if (accessToken != null) { + jwtService.validateAccessToken(accessToken); + Long userId = jwtService.getUserIdFromJwtToken(accessToken); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, new ArrayList<>()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } catch (GeneralException e) { + // GeneralException을 κ·ΈλŒ€λ‘œ ApiResponse 포맷으둜 μ§λ ¬ν™”ν•˜μ—¬ 응닡 + // Spring Securityκ°€ κ°€λ‘œμ±„μ§€ μ•Šλ„λ‘ 직접 response에 μž‘μ„± + log.warn("[*] JwtFilter GeneralException : {}", e.getMessage()); + sendErrorResponse(response, e); + } + } + + // Authorization ν—€λ”μ—μ„œ JWT 토큰 μΆ”μΆœ + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + // GeneralException을 ApiResponse 포맷으둜 직접 response에 μž‘μ„± + private void sendErrorResponse(HttpServletResponse response, GeneralException e) throws IOException { + response.setStatus(e.getErrorStatus().getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResponse body = new ApiResponse<>( + false, + e.getErrorStatus().getCode(), + e.getErrorStatus().getMessage(), + null + ); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } + +} diff --git a/src/main/java/com/semosan/api/common/jwt/JwtService.java b/src/main/java/com/semosan/api/common/jwt/JwtService.java new file mode 100644 index 00000000..207fb37b --- /dev/null +++ b/src/main/java/com/semosan/api/common/jwt/JwtService.java @@ -0,0 +1,140 @@ +package com.semosan.api.common.jwt; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.user.entity.User; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.HexFormat; + +@Component +public class JwtService { + + private final SecretKey secretKey; + @Getter + private final long accessTokenExpiration; + @Getter + private final long refreshTokenExpiration; + + public JwtService( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration}") long accessTokenExpiration, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + } + + // μ•‘μ„ΈμŠ€/λ¦¬ν”„λ ˆμ‹œ 토큰 λ°œκΈ‰ 및 λ¦¬ν”„λ ˆμ‹œ 토큰 ν•΄μ‹œ μ €μž₯ + public TokenIssuance issueTokens(User user) { + String accessToken = generateAccessToken(user); + String refreshToken = generateRefreshToken(user); + user.updateRefreshToken(hashToken(refreshToken)); + return new TokenIssuance(accessToken, refreshToken); + } + + // Access Token 생성 + public String generateAccessToken(User user) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + accessTokenExpiration); + + return Jwts.builder() + .subject(user.getId().toString()) + .claim("email", user.getEmail()) + .claim("loginType", user.getOauthProvider().name()) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey, Jwts.SIG.HS256) + .compact(); + } + + // Refresh Token 생성 + public String generateRefreshToken(User user) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + refreshTokenExpiration); + + return Jwts.builder() + .subject(user.getId().toString()) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey, Jwts.SIG.HS256) + .compact(); + } + + // Refresh Token을 SHA-256으둜 ν•΄μ‹œ β€” DB μ €μž₯ μ „ μ‚¬μš© + public String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new GeneralException(ErrorStatus.JWT_GENERAL_ERROR); + } + } + + // Access Token 검증 + public void validateAccessToken(String accessToken) { + if (accessToken == null || accessToken.isBlank()) { + throw new GeneralException(ErrorStatus.JWT_TOKEN_NOT_FOUND); + } + parseClaims(accessToken); + } + + // Refresh Token μ„œλͺ…/만료 검증 ν›„ Claims λ°˜ν™˜ β€” DB 비ꡐ μ „ 1μ°¨ κ²€μ¦μš© + public Claims validateRefreshTokenSignature(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new GeneralException(ErrorStatus.JWT_TOKEN_NOT_FOUND); + } + return parseClaims(refreshToken); + } + + // Refresh Token DB μ €μž₯ ν•΄μ‹œκ°’κ³Ό 비ꡐ β€” 2μ°¨ κ²€μ¦μš© (μ„œλͺ… 검증은 1μ°¨μ—μ„œ μ™„λ£Œ) + public void validateRefreshToken(String refreshToken, String storedHashedToken) { + if (!hashToken(refreshToken).equals(storedHashedToken)) + throw new GeneralException(ErrorStatus.REFRESH_TOKEN_MISMATCH); + } + + // AccessTokenμ—μ„œ userId μΆ”μΆœ + public Long getUserIdFromJwtToken(String token) { + try { + Claims claims = parseClaims(token); + return Long.parseLong(claims.getSubject()); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.JWT_EXTRACT_ID_FAILED); + } + } + + // λ‚΄λΆ€ Claims νŒŒμ‹± 둜직 + private Claims parseClaims(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SecurityException e) { + throw new GeneralException(ErrorStatus.JWT_INVALID_SIGNATURE); + } catch (MalformedJwtException e) { + throw new GeneralException(ErrorStatus.JWT_MALFORMED); + } catch (ExpiredJwtException e) { + throw new GeneralException(ErrorStatus.JWT_EXPIRED); + } catch (UnsupportedJwtException e) { + throw new GeneralException(ErrorStatus.JWT_UNSUPPORTED); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus.JWT_INVALID); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.JWT_GENERAL_ERROR); + } + } + +} diff --git a/src/main/java/com/semosan/api/common/jwt/TokenIssuance.java b/src/main/java/com/semosan/api/common/jwt/TokenIssuance.java new file mode 100644 index 00000000..60b93c0c --- /dev/null +++ b/src/main/java/com/semosan/api/common/jwt/TokenIssuance.java @@ -0,0 +1,6 @@ +package com.semosan.api.common.jwt; + +public record TokenIssuance( + String accessToken, + String refreshToken +) {} diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java new file mode 100644 index 00000000..742d3e2e --- /dev/null +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -0,0 +1,69 @@ +package com.semosan.api.common.status; + +import com.semosan.api.common.base.BaseStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorStatus implements BaseStatus { + + /** + * Common + */ + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COM_400", "잘λͺ»λœ μš”μ²­μž…λ‹ˆλ‹€."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COM_401", "인증이 ν•„μš”ν•©λ‹ˆλ‹€."), + FORBIDDEN(HttpStatus.FORBIDDEN, "COM_403", "μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."), + NOT_FOUND(HttpStatus.NOT_FOUND, "COM_404", "μš”μ²­ν•œ μžμ›μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COM_405", "ν—ˆμš©λ˜μ§€ μ•Šμ€ λ©”μ†Œλ“œμž…λ‹ˆλ‹€."), + CONFLICT(HttpStatus.CONFLICT, "COM_409", "이미 μ‘΄μž¬ν•˜λŠ” λ¦¬μ†ŒμŠ€μž…λ‹ˆλ‹€."), + UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "COM_415", "μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν˜•μ‹μž…λ‹ˆλ‹€."), + UNPROCESSABLE_ENTITY(HttpStatus.UNPROCESSABLE_ENTITY, "COM_422", "μ²˜λ¦¬ν•  수 μ—†λŠ” μš”μ²­μž…λ‹ˆλ‹€."), + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "COM_429", "μš”μ²­μ΄ λ„ˆλ¬΄ λ§ŽμŠ΅λ‹ˆλ‹€."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COM_500", "μ„œλ²„ λ‚΄λΆ€ 였λ₯˜μž…λ‹ˆλ‹€."), + + /** + * JWT + */ + JWT_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT_401_1", "토큰이 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + JWT_INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "JWT_401_2", "잘λͺ»λœ JWT μ„œλͺ…μž…λ‹ˆλ‹€."), + JWT_MALFORMED(HttpStatus.UNAUTHORIZED, "JWT_401_3", "잘λͺ»λœ JWT ν˜•μ‹μž…λ‹ˆλ‹€."), + JWT_EXPIRED(HttpStatus.UNAUTHORIZED, "JWT_401_4", "만료된 JWT ν† ν°μž…λ‹ˆλ‹€."), + JWT_UNSUPPORTED(HttpStatus.UNAUTHORIZED, "JWT_401_5", "μ§€μ›λ˜μ§€ μ•ŠλŠ” JWT ν† ν°μž…λ‹ˆλ‹€."), + JWT_INVALID(HttpStatus.UNAUTHORIZED, "JWT_401_6", "JWT 토큰이 잘λͺ»λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + JWT_EXTRACT_ID_FAILED(HttpStatus.UNAUTHORIZED, "JWT_401_7", "ν† ν°μ—μ„œ μ‚¬μš©μž 정보λ₯Ό μΆ”μΆœν•  수 μ—†μŠ΅λ‹ˆλ‹€."), + JWT_GENERAL_ERROR(HttpStatus.UNAUTHORIZED, "JWT_401_8", "JWT 토큰 처리 쀑 μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."), + JWT_INVALID_TYPE(HttpStatus.UNAUTHORIZED, "JWT_401_9", "토큰 νƒ€μž…μ΄ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT_401_10", "DB에 μ €μž₯된 토큰과 μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + REFRESH_TOKEN_MISMATCH(HttpStatus.UNAUTHORIZED, "JWT_401_11", "λ¦¬ν”„λ ˆμ‹œ 토큰 정보가 μ‚¬μš©μž 정보와 μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + JWT_EXTRACT_ROLE_FAILED(HttpStatus.UNAUTHORIZED, "JWT_401_12", "ν† ν°μ—μ„œ μ‚¬μš©μž Role을 μΆ”μΆœν•  수 μ—†μŠ΅λ‹ˆλ‹€."), + + /** + * Kakao OAuth + */ + KAKAO_TOKEN_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "KAKAO_502_1", "카카였 토큰 λ°œκΈ‰μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + KAKAO_USER_INFO_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "KAKAO_502_2", "카카였 μ‚¬μš©μž 정보 μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + + /** + * User + */ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + + /** + * Apple OAuth + */ + APPLE_PUBLIC_KEY_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "APPLE_502_1", "μ• ν”Œ κ³΅κ°œν‚€ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.UNAUTHORIZED, "APPLE_401_1", "μœ νš¨ν•œ μ• ν”Œ κ³΅κ°œν‚€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + APPLE_IDENTITY_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "APPLE_401_2", "μ• ν”Œ identity token이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + + /** + * Withdraw + */ + KAKAO_UNLINK_FAILED(HttpStatus.BAD_GATEWAY, "KAKAO_502_3", "카카였 연동 ν•΄μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + APPLE_REVOKE_FAILED(HttpStatus.BAD_GATEWAY, "APPLE_502_1", "μ• ν”Œ 토큰 폐기에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java new file mode 100644 index 00000000..0faf01ae --- /dev/null +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -0,0 +1,28 @@ +package com.semosan.api.common.status; + +import com.semosan.api.common.base.BaseStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SuccessStatus implements BaseStatus { + + COMMON_SUCCESS_STATUS(HttpStatus.OK, "COM_200", "μ„±κ³΅μ μœΌλ‘œ μ²˜λ¦¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + + /** + * Auth + */ + LOGIN_SUCCESS(HttpStatus.OK, "AUTH_200_1", "λ‘œκ·ΈμΈμ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + REISSUE_SUCCESS(HttpStatus.OK, "AUTH_200_2", "토큰 μž¬λ°œκΈ‰μ— μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€."), + + /** + * User + */ + WITHDRAW_SUCCESS(HttpStatus.OK, "USER_200_1", "νšŒμ› νƒˆν‡΄κ°€ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java b/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..c243e740 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java @@ -0,0 +1,65 @@ +package com.semosan.api.domain.auth.controller; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.common.status.SuccessStatus; +import com.semosan.api.domain.auth.controller.docs.AuthControllerDocs; +import com.semosan.api.domain.auth.dto.request.LoginRequest; +import com.semosan.api.domain.auth.dto.response.LoginResponse; +import com.semosan.api.domain.auth.dto.response.ReissueResponse; +import com.semosan.api.domain.auth.service.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController implements AuthControllerDocs { + + private final AuthService authService; + + @PostMapping("/test/login") + @Override + public ResponseEntity> login( + @Valid @RequestBody LoginRequest request + ) { + LoginResponse response = authService.login(request); + return ApiResponse.success(SuccessStatus.LOGIN_SUCCESS, response); + } + + @PostMapping("/token/reissue") + @Override + public ResponseEntity> reissue( + @RequestHeader("Authorization") String authorizationHeader + ) { + String refreshToken = resolveToken(authorizationHeader); + ReissueResponse response = authService.reissue(refreshToken); + return ApiResponse.success(SuccessStatus.REISSUE_SUCCESS, response); + } + + @DeleteMapping("/withdraw") + @Override + public ResponseEntity> withdraw( + @AuthenticationPrincipal Long userId + ) { + authService.withdraw(userId); + return ApiResponse.success(SuccessStatus.WITHDRAW_SUCCESS); + } + + private String resolveToken(String authorizationHeader) { + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + throw new GeneralException(ErrorStatus.JWT_TOKEN_NOT_FOUND); + } + return authorizationHeader.substring(7); + } + +} diff --git a/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java b/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java new file mode 100644 index 00000000..18c7cb43 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java @@ -0,0 +1,87 @@ +package com.semosan.api.domain.auth.controller.docs; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.auth.dto.request.LoginRequest; +import com.semosan.api.domain.auth.dto.response.LoginResponse; +import com.semosan.api.domain.auth.dto.response.ReissueResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Auth", description = "인증 κ΄€λ ¨ API") +public interface AuthControllerDocs { + + @Operation( + summary = "ν…ŒμŠ€νŠΈ 둜그인", + description = "testUserId둜 μœ μ €λ₯Ό μ‘°νšŒν•˜κ±°λ‚˜ μ—†μœΌλ©΄ μžλ™ 생성 ν›„ JWTλ₯Ό λ°œκΈ‰ν•©λ‹ˆλ‹€. secretKey둜 접근을 μ œν•œν•©λ‹ˆλ‹€." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "둜그인 성곡", + content = @Content(schema = @Schema(implementation = LoginResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘λͺ»λœ μš”μ²­ (testUserId, deviceType, secretKey λˆ„λ½)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "μ‹œν¬λ¦Ώ ν‚€ 뢈일치", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> login( + @Valid @RequestBody LoginRequest request + ); + + @Operation( + summary = "토큰 μž¬λ°œκΈ‰", + description = "Authorization 헀더에 'Bearer {refreshToken}' ν˜•μ‹μœΌλ‘œ μ „λ‹¬ν•˜λ©΄ μ•‘μ„ΈμŠ€ 토큰과 λ¦¬ν”„λ ˆμ‹œ 토큰을 μž¬λ°œκΈ‰ν•©λ‹ˆλ‹€." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "토큰 μž¬λ°œκΈ‰ 성곡", + content = @Content(schema = @Schema(implementation = ReissueResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "λ¦¬ν”„λ ˆμ‹œ 토큰 만료 λ˜λŠ” 뢈일치", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> reissue( + @Parameter(description = "Bearer {refreshToken}", required = true) + @RequestHeader("Authorization") String authorizationHeader + ); + + @Operation( + summary = "νšŒμ› νƒˆν‡΄", + description = "νšŒμ›μ„ νƒˆν‡΄ μ²˜λ¦¬ν•©λ‹ˆλ‹€. soft delete λ°©μ‹μœΌλ‘œ 처리되며 동일 μ†Œμ…œ κ³„μ •μœΌλ‘œ μž¬κ°€μž…μ΄ κ°€λŠ₯ν•©λ‹ˆλ‹€." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "νšŒμ› νƒˆν‡΄ 성곡" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 ν•„μš”", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> withdraw( + @AuthenticationPrincipal Long userId + ); + +} diff --git a/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java b/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java new file mode 100644 index 00000000..5ac2cc32 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java @@ -0,0 +1,17 @@ +package com.semosan.api.domain.auth.dto.request; + +import com.semosan.api.domain.user.enums.DeviceType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record LoginRequest( + + @NotBlank(message = "ν…ŒμŠ€νŠΈ μœ μ € IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + String testUserId, + + @NotNull(message = "λ””λ°”μ΄μŠ€ νƒ€μž…μ€ ν•„μˆ˜μž…λ‹ˆλ‹€.") + DeviceType deviceType, + + @NotBlank(message = "μ‹œν¬λ¦Ώ ν‚€λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + String secretKey +) {} diff --git a/src/main/java/com/semosan/api/domain/auth/dto/response/LoginResponse.java b/src/main/java/com/semosan/api/domain/auth/dto/response/LoginResponse.java new file mode 100644 index 00000000..202ed1e2 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/dto/response/LoginResponse.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.auth.dto.response; + +public record LoginResponse( + Long userId, + String accessToken, + String refreshToken +) {} diff --git a/src/main/java/com/semosan/api/domain/auth/dto/response/ReissueResponse.java b/src/main/java/com/semosan/api/domain/auth/dto/response/ReissueResponse.java new file mode 100644 index 00000000..98fc6479 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/dto/response/ReissueResponse.java @@ -0,0 +1,6 @@ +package com.semosan.api.domain.auth.dto.response; + +public record ReissueResponse( + String accessToken, + String refreshToken +) {} diff --git a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java new file mode 100644 index 00000000..56411928 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java @@ -0,0 +1,71 @@ +package com.semosan.api.domain.auth.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.jwt.JwtService; +import com.semosan.api.common.jwt.TokenIssuance; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.auth.dto.request.LoginRequest; +import com.semosan.api.domain.auth.dto.response.LoginResponse; +import com.semosan.api.domain.auth.dto.response.ReissueResponse; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.service.UserService; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserService userService; + private final JwtService jwtService; + + @Value("${test.secret-key}") + private String testSecretKey; + + // μ‹œν¬λ¦Ώ ν‚€ 검증 ν›„ ν…ŒμŠ€νŠΈ μœ μ € 쑰회/생성 및 JWT λ°œκΈ‰ + @Transactional + public LoginResponse login(LoginRequest request) { + if (!MessageDigest.isEqual( + testSecretKey.getBytes(StandardCharsets.UTF_8), + request.secretKey().getBytes(StandardCharsets.UTF_8))) { + throw new GeneralException(ErrorStatus.FORBIDDEN); + } + + User user = userService.findOrCreateTestUser(request.testUserId(), request.deviceType()); + TokenIssuance tokens = jwtService.issueTokens(user); + + return new LoginResponse(user.getId(), tokens.accessToken(), tokens.refreshToken()); + } + + // λ¦¬ν”„λ ˆμ‹œ 토큰 검증 ν›„ μƒˆ μ•‘μ„ΈμŠ€/λ¦¬ν”„λ ˆμ‹œ 토큰 λ°œκΈ‰ (Rotation) + @Transactional + public ReissueResponse reissue(String refreshToken) { + Claims claims = jwtService.validateRefreshTokenSignature(refreshToken); + Long userId = Long.parseLong(claims.getSubject()); + + User user = userService.findById(userId); + + if (user.getRefreshToken() == null) + throw new GeneralException(ErrorStatus.REFRESH_TOKEN_NOT_FOUND); + jwtService.validateRefreshToken(refreshToken, user.getRefreshToken()); + + TokenIssuance tokens = jwtService.issueTokens(user); + return new ReissueResponse(tokens.accessToken(), tokens.refreshToken()); + } + + // μœ μ € 쑰회 ν›„ soft delete 및 λ¦¬ν”„λ ˆμ‹œ 토큰 λ¬΄νš¨ν™” + @Transactional + public void withdraw(Long userId) { + User user = userService.findById(userId); + user.withdraw(); + } + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/client/OAuthAppleClient.java b/src/main/java/com/semosan/api/domain/oauth/client/OAuthAppleClient.java new file mode 100644 index 00000000..c5cc16c2 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/client/OAuthAppleClient.java @@ -0,0 +1,103 @@ +package com.semosan.api.domain.oauth.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthAppleClient { + + private static final String APPLE_PUBLIC_KEYS_URI = "https://appleid.apple.com/auth/keys"; + private static final String APPLE_ISSUER = "https://appleid.apple.com"; + + private final WebClient appleApiWebClient; + private final ObjectMapper objectMapper; + + // identity token 검증 ν›„ Claims λ°˜ν™˜ + public Claims getAppleClaims(String identityToken) { + List> keys = getApplePublicKeys(); + String kid = extractKid(identityToken); + + Map matchedKey = keys.stream() + .filter(key -> kid.equals(key.get("kid"))) + .findFirst() + .orElseThrow(() -> new GeneralException(ErrorStatus.APPLE_PUBLIC_KEY_NOT_FOUND)); + + PublicKey publicKey = buildPublicKey(matchedKey); + + try { + return Jwts.parser() + .verifyWith(publicKey) + .requireIssuer(APPLE_ISSUER) + .build() + .parseSignedClaims(identityToken) + .getPayload(); + } catch (Exception e) { + log.error("[*] μ• ν”Œ identity token 검증 μ‹€νŒ¨ : {}", e.getMessage()); + throw new GeneralException(ErrorStatus.APPLE_IDENTITY_TOKEN_INVALID); + } + } + + // μ• ν”Œ κ³΅κ°œν‚€ λͺ©λ‘ 쑰회 + @SuppressWarnings("unchecked") + private List> getApplePublicKeys() { + try { + Map response = appleApiWebClient.get() + .uri(APPLE_PUBLIC_KEYS_URI) + .retrieve() + .bodyToMono(Map.class) + .block(); + + return (List>) response.get("keys"); + } catch (WebClientResponseException e) { + log.error("[*] μ• ν”Œ κ³΅κ°œν‚€ 쑰회 μ‹€νŒ¨ status={}", e.getStatusCode()); + throw new GeneralException(ErrorStatus.APPLE_PUBLIC_KEY_REQUEST_FAILED); + } + } + + // identity token ν—€λ”μ—μ„œ kid μΆ”μΆœ β€” ObjectMapper둜 μ•ˆμ „ν•˜κ²Œ νŒŒμ‹± + private String extractKid(String identityToken) { + try { + String header = identityToken.split("\\.")[0]; + String decoded = new String(Base64.getUrlDecoder().decode(header)); + return objectMapper.readTree(decoded).get("kid").asText(); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.APPLE_IDENTITY_TOKEN_INVALID); + } + } + + // κ³΅κ°œν‚€ 맡으둜 RSA PublicKey 생성 + private PublicKey buildPublicKey(Map key) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(key.get("n")); + byte[] eBytes = Base64.getUrlDecoder().decode(key.get("e")); + + RSAPublicKeySpec spec = new RSAPublicKeySpec( + new BigInteger(1, nBytes), + new BigInteger(1, eBytes) + ); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } catch (Exception e) { + log.error("[*] μ• ν”Œ κ³΅κ°œν‚€ 생성 μ‹€νŒ¨ : {}", e.getMessage()); + throw new GeneralException(ErrorStatus.APPLE_PUBLIC_KEY_NOT_FOUND); + } + } + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java b/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java new file mode 100644 index 00000000..3e7939a9 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java @@ -0,0 +1,115 @@ +package com.semosan.api.domain.oauth.client; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.domain.oauth.properties.KakaoProperties; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.oauth.dto.KakaoTokenResponse; +import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuthKakaoClient { + + private final WebClient kakaoAuthWebClient; + private final WebClient kakaoApiWebClient; + private final KakaoProperties kakaoProperties; + + // 인가 μ½”λ“œ -> 카카였 μ•‘μ„ΈμŠ€ 토큰 + public KakaoTokenResponse getKakaoToken(String code) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "authorization_code"); + form.add("client_id", kakaoProperties.clientId()); + form.add("redirect_uri", kakaoProperties.redirectUri()); + form.add("code", code); + if (StringUtils.hasText(kakaoProperties.clientSecret())) { + form.add("client_secret", kakaoProperties.clientSecret()); + } + + try { + KakaoTokenResponse response = kakaoAuthWebClient.post() + .uri("/oauth/token") + .body(BodyInserters.fromFormData(form)) + .retrieve() + .onStatus( + HttpStatusCode::isError, + r -> r.bodyToMono(String.class) + .flatMap(body -> handleError(r.statusCode(), body, "토큰 λ°œκΈ‰")) + ) + .bodyToMono(KakaoTokenResponse.class) + .block(); + + if (response == null || !StringUtils.hasText(response.accessToken())) { + throw new GeneralException(ErrorStatus.KAKAO_TOKEN_REQUEST_FAILED); + } + return response; + } catch (WebClientResponseException e) { + log.error("[*] 카카였 토큰 λ°œκΈ‰ μ‹€νŒ¨ status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(ErrorStatus.KAKAO_TOKEN_REQUEST_FAILED); + } + } + + // 카카였 μ•‘μ„ΈμŠ€ 토큰 -> μ‚¬μš©μž 정보 + public KakaoUserInfoResponse getKakaoUserInfo(String kakaoAccessToken) { + try { + KakaoUserInfoResponse response = kakaoApiWebClient.get() + .uri("/v2/user/me") + .header("Authorization", "Bearer " + kakaoAccessToken) + .retrieve() + .onStatus( + HttpStatusCode::isError, + r -> r.bodyToMono(String.class) + .flatMap(body -> handleError(r.statusCode(), body, "μ‚¬μš©μž 정보 쑰회")) + ) + .bodyToMono(KakaoUserInfoResponse.class) + .block(); + + if (response == null || response.id() == null) { + throw new GeneralException(ErrorStatus.KAKAO_USER_INFO_REQUEST_FAILED); + } + return response; + } catch (WebClientResponseException e) { + log.error("[*] 카카였 μ‚¬μš©μž 정보 쑰회 μ‹€νŒ¨ status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(ErrorStatus.KAKAO_USER_INFO_REQUEST_FAILED); + } + } + + // 카카였 연동 ν•΄μ œ + public void unlinkKakaoUser(String kakaoOauthId) { + try { + kakaoApiWebClient.post() + .uri("/v1/user/unlink") + .header("Authorization", "KakaoAK " + kakaoProperties.adminKey()) + .body(BodyInserters.fromFormData("target_id_type", "user_id") + .with("target_id", kakaoOauthId)) + .retrieve() + .onStatus( + HttpStatusCode::isError, + r -> r.bodyToMono(String.class) + .flatMap(body -> handleError(r.statusCode(), body, "연동 ν•΄μ œ")) + ) + .bodyToMono(Void.class) + .block(); + } catch (WebClientResponseException e) { + log.error("[*] 카카였 연동 ν•΄μ œ μ‹€νŒ¨ status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(ErrorStatus.KAKAO_UNLINK_FAILED); + } + } + + private Mono handleError(HttpStatusCode statusCode, String body, String action) { + log.error("[*] 카카였 {} μ‹€νŒ¨ status={}, body={}", action, statusCode, body); + return Mono.error(new GeneralException(ErrorStatus.KAKAO_TOKEN_REQUEST_FAILED)); + } + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java b/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java new file mode 100644 index 00000000..d726a56b --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java @@ -0,0 +1,43 @@ +package com.semosan.api.domain.oauth.controller; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.SuccessStatus; +import com.semosan.api.domain.oauth.controller.docs.OAuthControllerDocs; +import com.semosan.api.domain.oauth.dto.request.OAuthAppleLoginRequest; +import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; +import com.semosan.api.domain.oauth.dto.response.OAuthLoginResponse; +import com.semosan.api.domain.oauth.service.OAuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/oauth") +@RequiredArgsConstructor +public class OAuthController implements OAuthControllerDocs { + + private final OAuthService oAuthService; + + @PostMapping("/kakao/login") + @Override + public ResponseEntity> kakaoLogin( + @Valid @RequestBody OAuthKakaoLoginRequest request + ) { + OAuthLoginResponse response = oAuthService.kakaoLogin(request); + return ApiResponse.success(SuccessStatus.LOGIN_SUCCESS, response); + } + + @PostMapping("/apple/login") + @Override + public ResponseEntity> appleLogin( + @Valid @RequestBody OAuthAppleLoginRequest request + ) { + OAuthLoginResponse response = oAuthService.appleLogin(request); + return ApiResponse.success(SuccessStatus.LOGIN_SUCCESS, response); + } + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java b/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java new file mode 100644 index 00000000..1ce7823c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java @@ -0,0 +1,74 @@ +package com.semosan.api.domain.oauth.controller.docs; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.oauth.dto.request.OAuthAppleLoginRequest; +import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; +import com.semosan.api.domain.oauth.dto.response.OAuthLoginResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "OAuth", description = "μ†Œμ…œ 둜그인 κ΄€λ ¨ API") +public interface OAuthControllerDocs { + + @Operation( + summary = "카카였 μ†Œμ…œ 둜그인", + description = "ν”„λ‘ νŠΈμ—”λ“œμ—μ„œ 전달받은 카카였 인가 μ½”λ“œ(code)둜 둜그인 λ˜λŠ” νšŒμ›κ°€μž…μ„ μ²˜λ¦¬ν•˜κ³  μ„œλΉ„μŠ€ JWTλ₯Ό λ°œκΈ‰ν•©λ‹ˆλ‹€." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "둜그인 성곡", + content = @Content(schema = @Schema(implementation = OAuthLoginResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘λͺ»λœ μš”μ²­ (인가 μ½”λ“œ λ˜λŠ” λ””λ°”μ΄μŠ€ νƒ€μž… λˆ„λ½)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "502", + description = "카카였 API 호좜 μ‹€νŒ¨", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> kakaoLogin( + @Valid @RequestBody OAuthKakaoLoginRequest request + ); + + @Operation( + summary = "μ• ν”Œ μ†Œμ…œ 둜그인", + description = "ν”„λ‘ νŠΈμ—”λ“œμ—μ„œ 전달받은 μ• ν”Œ identity token으둜 둜그인 λ˜λŠ” νšŒμ›κ°€μž…μ„ μ²˜λ¦¬ν•˜κ³  μ„œλΉ„μŠ€ JWTλ₯Ό λ°œκΈ‰ν•©λ‹ˆλ‹€. 이름(name)은 졜초 둜그인 μ‹œμ—λ§Œ μ „λ‹¬λ©λ‹ˆλ‹€." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "둜그인 성곡", + content = @Content(schema = @Schema(implementation = OAuthLoginResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘λͺ»λœ μš”μ²­ (identity token λ˜λŠ” λ””λ°”μ΄μŠ€ νƒ€μž… λˆ„λ½)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "μ• ν”Œ identity token 검증 μ‹€νŒ¨", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "502", + description = "μ• ν”Œ κ³΅κ°œν‚€ 쑰회 μ‹€νŒ¨", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> appleLogin( + @Valid @RequestBody OAuthAppleLoginRequest request + ); + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/KakaoTokenResponse.java b/src/main/java/com/semosan/api/domain/oauth/dto/KakaoTokenResponse.java new file mode 100644 index 00000000..90fd7e88 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/KakaoTokenResponse.java @@ -0,0 +1,21 @@ +package com.semosan.api.domain.oauth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoTokenResponse( + + @JsonProperty("access_token") + String accessToken, + + @JsonProperty("token_type") + String tokenType, + + @JsonProperty("refresh_token") + String refreshToken, + + @JsonProperty("expires_in") + Long expiresIn, + + @JsonProperty("refresh_token_expires_in") + Long refreshTokenExpiresIn +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/KakaoUserInfoResponse.java b/src/main/java/com/semosan/api/domain/oauth/dto/KakaoUserInfoResponse.java new file mode 100644 index 00000000..ddf9c2d8 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/KakaoUserInfoResponse.java @@ -0,0 +1,27 @@ +package com.semosan.api.domain.oauth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoUserInfoResponse( + + Long id, + + @JsonProperty("kakao_account") + KakaoAccount kakaoAccount +) { + public record KakaoAccount( + + String email, + + @JsonProperty("profile") + Profile profile + ) { + public record Profile( + + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} + } +} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthAppleLoginRequest.java b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthAppleLoginRequest.java new file mode 100644 index 00000000..f3db874c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthAppleLoginRequest.java @@ -0,0 +1,16 @@ +package com.semosan.api.domain.oauth.dto.request; + +import com.semosan.api.domain.user.enums.DeviceType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record OAuthAppleLoginRequest( + + @NotBlank(message = "identity token은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + String identityToken, + + String name, + + @NotNull(message = "λ””λ°”μ΄μŠ€ νƒ€μž…μ€ ν•„μˆ˜μž…λ‹ˆλ‹€.") + DeviceType deviceType +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java new file mode 100644 index 00000000..48fc15f2 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java @@ -0,0 +1,14 @@ +package com.semosan.api.domain.oauth.dto.request; + +import com.semosan.api.domain.user.enums.DeviceType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record OAuthKakaoLoginRequest( + + @NotBlank(message = "인가 μ½”λ“œλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + String code, + + @NotNull(message = "λ””λ°”μ΄μŠ€ νƒ€μž…μ€ ν•„μˆ˜μž…λ‹ˆλ‹€.") + DeviceType deviceType +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/response/OAuthLoginResponse.java b/src/main/java/com/semosan/api/domain/oauth/dto/response/OAuthLoginResponse.java new file mode 100644 index 00000000..d166c5b6 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/response/OAuthLoginResponse.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.oauth.dto.response; + +public record OAuthLoginResponse( + Long userId, + String accessToken, + String refreshToken +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java b/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java new file mode 100644 index 00000000..b53c2bab --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java @@ -0,0 +1,11 @@ +package com.semosan.api.domain.oauth.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "kakao") +public record KakaoProperties( + String clientId, + String redirectUri, + String clientSecret, + String adminKey +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java new file mode 100644 index 00000000..b46b06f3 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java @@ -0,0 +1,61 @@ +package com.semosan.api.domain.oauth.service; + +import com.semosan.api.common.jwt.JwtService; +import com.semosan.api.common.jwt.TokenIssuance; +import com.semosan.api.domain.oauth.client.OAuthAppleClient; +import com.semosan.api.domain.oauth.client.OAuthKakaoClient; +import com.semosan.api.domain.oauth.dto.KakaoTokenResponse; +import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; +import com.semosan.api.domain.oauth.dto.request.OAuthAppleLoginRequest; +import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; +import com.semosan.api.domain.oauth.dto.response.OAuthLoginResponse; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.service.UserService; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthService { + + private final UserService userService; + private final JwtService jwtService; + private final OAuthKakaoClient oAuthKakaoClient; + private final OAuthAppleClient oAuthAppleClient; + + @Transactional + public OAuthLoginResponse kakaoLogin(OAuthKakaoLoginRequest request) { + KakaoTokenResponse kakaoToken = oAuthKakaoClient.getKakaoToken(request.code()); + KakaoUserInfoResponse userInfo = oAuthKakaoClient.getKakaoUserInfo(kakaoToken.accessToken()); + + // DTO νŒŒμ‹±μ€ oauth λ ˆμ΄μ–΄μ—μ„œ 처리 ν›„ 순수 κ°’λ§Œ UserService둜 전달 + KakaoUserInfoResponse.KakaoAccount account = userInfo.kakaoAccount(); + String kakaoId = userInfo.id().toString(); + String email = account != null ? account.email() : null; + String name = account != null && account.profile() != null ? account.profile().nickname() : null; + String profileUrl = account != null && account.profile() != null ? account.profile().profileImageUrl() : null; + + User user = userService.findOrRegisterKakaoUser(kakaoId, email, name, profileUrl, request.deviceType()); + + TokenIssuance tokens = jwtService.issueTokens(user); + return new OAuthLoginResponse(user.getId(), tokens.accessToken(), tokens.refreshToken()); + } + + @Transactional + public OAuthLoginResponse appleLogin(OAuthAppleLoginRequest request) { + Claims claims = oAuthAppleClient.getAppleClaims(request.identityToken()); + + String appleId = claims.getSubject(); + String email = claims.get("email", String.class); + + User user = userService.findOrRegisterAppleUser(appleId, email, request.name(), request.deviceType()); + + TokenIssuance tokens = jwtService.issueTokens(user); + return new OAuthLoginResponse(user.getId(), tokens.accessToken(), tokens.refreshToken()); + } + +} diff --git a/src/main/java/com/semosan/api/domain/user/entity/User.java b/src/main/java/com/semosan/api/domain/user/entity/User.java new file mode 100644 index 00000000..206ae9db --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/entity/User.java @@ -0,0 +1,144 @@ +package com.semosan.api.domain.user.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.user.enums.DeviceType; +import com.semosan.api.domain.user.enums.Gender; +import com.semosan.api.domain.user.enums.OAuthProvider; +import com.semosan.api.domain.user.enums.OnboardingStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Table(name = "users") +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "email", length = 50) + private String email; + + @Column(name = "name", length = 30) + private String name; + + @Column(name = "profile_url", length = 255) + private String profileUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "device_type", columnDefinition = "device_type_enum", nullable = false) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + private DeviceType deviceType; + + @Enumerated(EnumType.STRING) + @Column(name = "onboarding_status", columnDefinition = "onboarding_status_enum", nullable = false) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + private OnboardingStatus onboardingStatus; + + @Column(name = "oauth_id", length = 255, nullable = false) + private String oauthId; + + @Enumerated(EnumType.STRING) + @Column(name = "oauth_provider", columnDefinition = "oauth_provider_enum", nullable = false) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + private OAuthProvider oauthProvider; + + @Column(name = "refresh_token", length = 512) + private String refreshToken; + + @Enumerated(EnumType.STRING) + @Column(name = "gender", columnDefinition = "gender_enum") + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + private Gender gender; + + @Column(name = "age") + private Integer age; + + @Column(name = "weight") + private Double weight; + + @Column(name = "fcm_token", length = 255) + private String fcmToken; + + @Column(name = "is_deleted", nullable = false) + private boolean deleted = false; + + // 카카였 μ‹ κ·œ μœ μ € 생성 + public static User createKakaoUser( + String oauthId, + String email, + String name, + String profileUrl, + DeviceType deviceType + ) { + return User.builder() + .oauthId(oauthId) + .oauthProvider(OAuthProvider.KAKAO) + .email(email) + .name(name) + .profileUrl(profileUrl) + .deviceType(deviceType) + .onboardingStatus(OnboardingStatus.INCOMPLETE) + .deleted(false) + .build(); + } + + // μ• ν”Œ μ‹ κ·œ μœ μ € 생성 + public static User createAppleUser( + String oauthId, + String email, + String name, + DeviceType deviceType + ) { + return User.builder() + .oauthId(oauthId) + .oauthProvider(OAuthProvider.APPLE) + .email(email) + .name(name) + .deviceType(deviceType) + .onboardingStatus(OnboardingStatus.INCOMPLETE) + .deleted(false) + .build(); + } + + // ν…ŒμŠ€νŠΈ μœ μ € 생성 (둜컬 μ „μš©) + public static User createTestUser(String testUserId, DeviceType deviceType) { + return User.builder() + .oauthId(testUserId) + .oauthProvider(OAuthProvider.TEST) + .email(testUserId + "@test.com") + .name("ν…ŒμŠ€νŠΈμœ μ €_" + testUserId) + .deviceType(deviceType) + .onboardingStatus(OnboardingStatus.INCOMPLETE) + .deleted(false) + .build(); + } + + // νƒˆν‡΄ν•œ μœ μ € μž¬κ°€μž… 처리 β€” null이면 κΈ°μ‘΄ κ°’ μœ μ§€ (μ• ν”Œ μž¬κ°€μž… μ‹œ email μœ μ‹€ λ°©μ§€) + public void restore(String email, String name, String profileUrl, DeviceType deviceType) { + if (email != null) this.email = email; + if (name != null) this.name = name; + if (profileUrl != null) this.profileUrl = profileUrl; + this.deviceType = deviceType; + this.onboardingStatus = OnboardingStatus.INCOMPLETE; + this.deleted = false; + } + + // νšŒμ› νƒˆν‡΄ 처리 + public void withdraw() { + this.deleted = true; + this.refreshToken = null; + } + + // λ¦¬ν”„λ ˆμ‹œ 토큰 μ—…λ°μ΄νŠΈ + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + +} diff --git a/src/main/java/com/semosan/api/domain/user/enums/DeviceType.java b/src/main/java/com/semosan/api/domain/user/enums/DeviceType.java new file mode 100644 index 00000000..ebaf20db --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/enums/DeviceType.java @@ -0,0 +1,6 @@ +package com.semosan.api.domain.user.enums; + +public enum DeviceType { + IOS, + ANDROID +} diff --git a/src/main/java/com/semosan/api/domain/user/enums/Gender.java b/src/main/java/com/semosan/api/domain/user/enums/Gender.java new file mode 100644 index 00000000..186f9d62 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/enums/Gender.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.user.enums; + +public enum Gender { + MALE, + FEMALE, + NONE +} diff --git a/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java b/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java new file mode 100644 index 00000000..a656bcab --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.user.enums; + +public enum OAuthProvider { + KAKAO, + APPLE, + TEST +} diff --git a/src/main/java/com/semosan/api/domain/user/enums/OnboardingStatus.java b/src/main/java/com/semosan/api/domain/user/enums/OnboardingStatus.java new file mode 100644 index 00000000..a3b8d2cb --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/enums/OnboardingStatus.java @@ -0,0 +1,6 @@ +package com.semosan.api.domain.user.enums; + +public enum OnboardingStatus { + INCOMPLETE, // μ˜¨λ³΄λ”© λ―Έμ™„λ£Œ + COMPLETE // μ˜¨λ³΄λ”© μ™„λ£Œ +} diff --git a/src/main/java/com/semosan/api/domain/user/repository/UserRepository.java b/src/main/java/com/semosan/api/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..99901107 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.semosan.api.domain.user.repository; + +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.OAuthProvider; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByOauthIdAndOauthProvider(String oauthId, OAuthProvider oauthProvider); + +} diff --git a/src/main/java/com/semosan/api/domain/user/service/UserService.java b/src/main/java/com/semosan/api/domain/user/service/UserService.java new file mode 100644 index 00000000..55090cf8 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/service/UserService.java @@ -0,0 +1,63 @@ +package com.semosan.api.domain.user.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.DeviceType; +import com.semosan.api.domain.user.enums.OAuthProvider; +import com.semosan.api.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + // 카카였 μœ μ € 쑰회 ν›„ μ—†μœΌλ©΄ μ‹ κ·œ 생성, νƒˆν‡΄ μœ μ €λ©΄ 볡ꡬ + @Transactional + public User findOrRegisterKakaoUser( + String kakaoId, String email, String name, String profileUrl, DeviceType deviceType + ) { + return userRepository.findByOauthIdAndOauthProvider(kakaoId, OAuthProvider.KAKAO) + .map(user -> { + if (user.isDeleted()) + user.restore(email, name, profileUrl, deviceType); + return user; + }) + .orElseGet(() -> + userRepository.save(User.createKakaoUser(kakaoId, email, name, profileUrl, deviceType)) + ); + } + + // μ• ν”Œ μœ μ € 쑰회 ν›„ μ—†μœΌλ©΄ μ‹ κ·œ 생성, νƒˆν‡΄ μœ μ €λ©΄ 볡ꡬ + @Transactional + public User findOrRegisterAppleUser(String appleId, String email, String name, DeviceType deviceType) { + return userRepository.findByOauthIdAndOauthProvider(appleId, OAuthProvider.APPLE) + .map(user -> { + if (user.isDeleted()) + user.restore(email, name, null, deviceType); + return user; + }) + .orElseGet(() -> + userRepository.save(User.createAppleUser(appleId, email, name, deviceType)) + ); + } + + // userId둜 μœ μ € 쑰회, μ—†μœΌλ©΄ μ˜ˆμ™Έ + @Transactional(readOnly = true) + public User findById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + } + + // ν…ŒμŠ€νŠΈ μœ μ € 쑰회 ν›„ μ—†μœΌλ©΄ μ‹ κ·œ 생성 + @Transactional + public User findOrCreateTestUser(String testUserId, DeviceType deviceType) { + return userRepository.findByOauthIdAndOauthProvider(testUserId, OAuthProvider.TEST) + .orElseGet(() -> userRepository.save(User.createTestUser(testUserId, deviceType))); + } + +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8a2082b2..d1ab33e9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,6 @@ spring: application: name: api + + profiles: + default: local