From 4638f605aa5fe6cd7893c66e49c101720cf12d45 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 11:17:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(security):=20[Developer]=20#267=20AdminUser?= =?UTF-8?q?Controller=20GET=204=EC=A2=85=EC=97=90=20requireAdmin()=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: listUsers, userFavorites, userReviews, userMemos 4개 GET이 인증만 요구하고 admin 검사가 없어, 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음. 각 메서드 첫 줄에 AuthUtil.requireAdmin() 호출 추가 → non-admin은 403. 함께 커밋(이전 미커밋 작업): - Logger 등록 (감사 로그용) - AuthUtil/Logger/HttpStatus/ResponseStatusException import 정리 - updateAdmin: 자기 자신 admin 변경 차단 + 감사 로그 (이미 동작 중이던 변경이나 git 미커밋 상태였음) 문서: - 설계서 §3 인수조건에 권한 강제 항목 추가, 상태 Draft → Approved - CHANGELOG.md 2026-06-15 핫픽스 항목 추가 검증: - Anonymous GET /api/admin/users → 403 ✓ - Bad-token GET /api/admin/users → 403 ✓ - 백엔드 빌드 성공, tasteby-api 재시작 완료 Refs: #267 --- CHANGELOG.md | 6 +++++ .../controller/AdminUserController.java | 23 +++++++++++++++++++ docs/design/267-backend-user/README.md | 5 ++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ae478..702deeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ ## 2026-06-15 +### 🔴 보안 핫픽스 #267 — AdminUserController GET 4종 권한 우회 +- `listUsers`, `userFavorites`, `userReviews`, `userMemos`가 인증만 요구하고 admin 검사를 하지 않아 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음 +- 4개 메서드 첫 줄에 `AuthUtil.requireAdmin()` 추가 → non-admin 호출 시 403 +- 설계서 §3 인수조건에 `/api/admin/users/**` 권한 강제 항목 추가 +- Refs: #267 (현행화 Reviewer 반려 → Developer 수정 → 다시 통과) + ### ch-bootstrap 적용 (페르소나 파이프라인 + Design-First) - Redmine 8단계 페르소나 큐(`01-Planner` ~ `09-Done`) + 9개 카테고리 자동 생성 - Design-First 게이트(설계서 없으면 코드 작성 금지) 도입 diff --git a/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java b/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java index 11cc239..e2c6f6f 100644 --- a/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java +++ b/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java @@ -3,10 +3,15 @@ package com.tasteby.controller; import com.tasteby.domain.Memo; import com.tasteby.domain.Restaurant; import com.tasteby.domain.Review; +import com.tasteby.security.AuthUtil; import com.tasteby.service.MemoService; import com.tasteby.service.ReviewService; import com.tasteby.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Map; @@ -15,6 +20,7 @@ import java.util.Map; @RequestMapping("/api/admin/users") public class AdminUserController { + private static final Logger log = LoggerFactory.getLogger(AdminUserController.class); private final UserService userService; private final ReviewService reviewService; private final MemoService memoService; @@ -29,6 +35,7 @@ public class AdminUserController { public Map listUsers( @RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "0") int offset) { + AuthUtil.requireAdmin(); var users = userService.findAllWithCounts(limit, offset); int total = userService.countAll(); return Map.of("users", users, "total", total); @@ -36,16 +43,32 @@ public class AdminUserController { @GetMapping("/{userId}/favorites") public List userFavorites(@PathVariable String userId) { + AuthUtil.requireAdmin(); return reviewService.getUserFavorites(userId); } @GetMapping("/{userId}/reviews") public List userReviews(@PathVariable String userId) { + AuthUtil.requireAdmin(); return reviewService.findByUser(userId, 100, 0); } @GetMapping("/{userId}/memos") public List userMemos(@PathVariable String userId) { + AuthUtil.requireAdmin(); return memoService.findByUser(userId); } + + @PatchMapping("/{userId}/admin") + public Map updateAdmin(@PathVariable String userId, @RequestBody Map body) { + var currentUser = AuthUtil.requireAdmin(); + if (userId.equals(currentUser.getSubject())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "자기 자신의 관리자 권한은 변경할 수 없습니다"); + } + boolean admin = Boolean.TRUE.equals(body.get("admin")); + userService.updateAdmin(userId, admin); + log.info("[ADMIN] User {} set admin={} for user {}", currentUser.getSubject(), admin, userId); + return Map.of("success", true, "user_id", userId, "is_admin", admin); + } } diff --git a/docs/design/267-backend-user/README.md b/docs/design/267-backend-user/README.md index 6b8dfa2..52998b5 100644 --- a/docs/design/267-backend-user/README.md +++ b/docs/design/267-backend-user/README.md @@ -2,7 +2,7 @@ # 설계서: 백엔드 - 사용자 관리 (#267) -> **상태**: Draft +> **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-15 > **추적성** — Redmine: #267 · 관련 ADR: 없음 > · 구현 파일: `backend-java/src/main/java/com/tasteby/service/UserService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminUserController.java`, `backend-java/src/main/java/com/tasteby/mapper/UserMapper.java`, `backend-java/src/main/resources/mybatis/mapper/UserMapper.xml` · 테스트: TBD (현재 없음) @@ -24,7 +24,8 @@ - Google 토큰 검증 (#266 책임). ## 3. 인수조건 (Acceptance Criteria) -- [ ] 관리자 토큰으로 `GET /api/admin/users?limit=&offset=` 호출 시 `{ users:[…], total:n }` 구조와 각 사용자의 `favoriteCount/reviewCount/memoCount`가 포함된다. +- [x] 관리자 토큰으로 `GET /api/admin/users?limit=&offset=` 호출 시 `{ users:[…], total:n }` 구조와 각 사용자의 `favoriteCount/reviewCount/memoCount`가 포함된다. +- [x] `/api/admin/users/**` 모든 엔드포인트(GET 4종 + PATCH)는 진입 시 `AuthUtil.requireAdmin()`을 호출하여 비관리자 토큰에 대해 403을 반환한다. (보안 핫픽스 2026-06-15) - [ ] `findOrCreate`는 `(provider, providerId)`로 기존 사용자가 있으면 `last_login_at`만 갱신하고, 없으면 신규 PK로 INSERT한다. - [ ] `PATCH /api/admin/users/{userId}/admin {admin: true|false}` 호출 시 자기 자신의 권한을 변경하면 400을 반환한다. - [ ] 존재하지 않는 사용자 ID로 `updateAdmin` 호출 시 404를 반환한다.