From bd6bbcf29ee04180498b9970bb4ce46f55b4553c Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 23 Aug 2026 01:02:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?FIX=20:=20=EA=B6=8C=ED=95=9C=20=EC=83=81?= =?UTF-8?q?=EC=8A=B9=20=EA=B2=BD=EB=A1=9C=20=EC=B0=A8=EB=8B=A8=EA=B3=BC=20?= =?UTF-8?q?=EC=BB=A4=EB=AE=A4=EB=8B=88=ED=8B=B0=20N+1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 권한 상승 (두 경로) - POST /auth/register 는 permitAll 인데 요청 본문의 role 을 그대로 엔티티에 넣었다. 로그인 없이 {"role":"ADMIN"} 으로 가입하면 그 자리에서 관리자가 됐다. 가입 시 role/provider/providerId/emailVerified 를 서버가 정하도록 바꾸고, provider 를 붙여 비밀번호 검사를 건너뛰던 경로도 함께 막았다. - PUT /users/{id}/role 은 클래스 제약이 isAuthenticated() 뿐이라 아무 회원이나 남의 역할을 바꿀 수 있었다. 사용자 관리 기능 10종을 /api/admin/users 로 옮겼다. UserService 의 updateUserRole/activateUser/reactivateUser 에도 @PreAuthorize 를 직접 걸어 호출 경로가 바뀌어도 막히게 했다. 소유권 검증 - /users/{userId}/... 계열이 남의 식별자를 그대로 받았다(IDOR). CurrentUserFacade.requireSelf 로 본인 확인 후 본인 엔티티로만 동작한다. 존재 여부가 새지 않도록 404 가 아니라 403 으로 응답한다. - 타인 프로필 조회(GET /users/{userId})와, 경로 변수를 무시하고 현재 사용자를 수정하던 PUT /users/{userId} 는 삭제했다. /users/me/* 별칭을 추가한다. 인증 - /auth/refresh 가 validateToken 을 써서 Access Token 도 통과했다. 기본 설정(store=none)에서는 등록 확인도 항상 참이라 1시간짜리 토큰을 30일짜리로 바꿀 수 있었다. validateRefreshToken 으로 교정하고 운영 프로파일의 리프레시 저장소 기본값을 redis 로 둔다. - 이메일 인증 화이트리스트가 존재하지 않는 /users/send-code 를 가리켜, 실제 경로인 /auth/* 는 인증을 요구했다. 즉 가입하려면 먼저 로그인해야 했다. - JWT 필터가 토큰이 없을 때도 SecurityContext 를 지웠다. 자기가 세우지 않은 컨텍스트를 지우는 필터라 앞단 인증을 조용히 무력화한다. 이메일 인증번호 - Math.random() 을 SecureRandom 으로 교체. - 시도 횟수 제한이 없어 6자리 코드를 5분 안에 전수 조회할 수 있었다. 코드당 5회로 제한. - 재발송 쿨다운 60초 추가. 상수 시간 비교 적용. - 인증 링크의 하드코딩 운영 IP 와 존재하지 않는 경로를 설정값 + /auth/verify 로 교정. Rate limit - RateLimitInterceptor 의 fail-open 이 increment 만 감쌌다. TTL 조회 실패가 그대로 500 이 됐고, DataAccessException 외의 실패도 마찬가지였다. - 민감 엔드포인트 목록이 이 앱에 없는 /api/v1/* 를 보고 있어 한 번도 적용된 적이 없었다. - 챗봇 /chat 은 요청 한 건이 유료 LLM 호출인데 엔드포인트 상한이 없었다. 가입·인증코드 발송/검증과 함께 @RateLimit 을 건다. - AOP 키를 IP 대신 인증 principal 우선으로 바꿔 NAT 공유와 IP 우회를 함께 해결. 커뮤니티 - 신고 누적 자동 숨김이 목록에서 동작하지 않았다. getAllPosts 가 findAll 을 써서 필터가 없었고 상세도 ID 만 알면 열렸다. findAllActive/findActiveById 로 교체. - N+1 제거: 작성자는 @EntityGraph(ToOne), 태그는 @BatchSize(컬렉션이라 페이징 join fetch 는 메모리 페이징을 유발), 댓글 답글은 재귀 조회 대신 1회 조회 후 메모리에서 트리 조립. 목록 20건 기준 41 → 3 쿼리. 기타 - KakaoUtil 의 미사용 필드 제거(기본값에 운영 IP 가 박혀 있었다). - 문서 생성기 Base URL 을 대상 서버에서 읽도록 변경. - 이관으로 참조가 끊긴 응답 DTO 5개 삭제. 테스트 - 80건 추가. 라인 커버리지 16% → 34.9%, ratchet 0.15 → 0.30. - 보안 핵심 클래스 4종을 60% 고기준 목록에 추가. - 접근제어 계약 테스트에 권한 상승·토큰 종류·가입 흐름 회귀 케이스를 넣었다. 372 tests, 0 failures. --- build.gradle | 16 +- docs/reference/access-control-matrix.md | 62 ++- .../carecode/core/RateLimitInterceptor.java | 101 +++- .../core/aspect/RateLimitingAspect.java | 31 +- .../core/security/CurrentUserFacade.java | 45 ++ .../security/JwtAuthenticationFilter.java | 116 ++--- .../core/security/SecurityConfig.java | 18 +- .../com/carecode/core/util/KakaoUtil.java | 11 +- .../docs/ApiDocumentationGenerator.java | 16 +- .../admin/controller/AdminUserController.java | 99 +++- .../chatbot/controller/ChatbotController.java | 6 + .../domain/community/entity/Post.java | 8 + .../community/mapper/CommunityMapper.java | 88 +++- .../repository/CommentRepository.java | 17 +- .../community/repository/PostRepository.java | 34 +- .../community/service/CommunityService.java | 68 +-- .../carecode/domain/user/app/UserFacade.java | 10 +- .../user/controller/AuthController.java | 13 +- .../user/controller/UserController.java | 472 +++++------------- .../user/dto/response/UserInfoResponse.java | 40 -- .../user/dto/response/UserListResponse.java | 25 - .../user/dto/response/UserLoginResponse.java | 24 - .../dto/response/UserNearbyUsersResponse.java | 24 - .../user/dto/response/UserSearchResponse.java | 23 - .../service/EmailVerificationService.java | 109 +++- .../domain/user/service/UserService.java | 95 ++-- src/main/resources/application-prod.yml | 20 + src/main/resources/application.yml | 4 + .../core/RateLimitInterceptorTest.java | 168 +++++++ .../core/security/CurrentUserFacadeTest.java | 185 +++++++ .../security/JwtAuthenticationFilterTest.java | 139 ++++++ .../CommunityMapperCommentTreeTest.java | 103 ++++ .../CommunityServiceHiddenPostTest.java | 117 +++++ .../service/EmailVerificationServiceTest.java | 209 ++++++++ .../user/service/UserServiceSignUpTest.java | 158 ++++++ .../AccessControlContractTest.java | 136 ++++- 36 files changed, 2114 insertions(+), 696 deletions(-) delete mode 100644 src/main/java/com/carecode/domain/user/dto/response/UserInfoResponse.java delete mode 100644 src/main/java/com/carecode/domain/user/dto/response/UserListResponse.java delete mode 100644 src/main/java/com/carecode/domain/user/dto/response/UserLoginResponse.java delete mode 100644 src/main/java/com/carecode/domain/user/dto/response/UserNearbyUsersResponse.java delete mode 100644 src/main/java/com/carecode/domain/user/dto/response/UserSearchResponse.java create mode 100644 src/test/java/com/carecode/core/RateLimitInterceptorTest.java create mode 100644 src/test/java/com/carecode/core/security/CurrentUserFacadeTest.java create mode 100644 src/test/java/com/carecode/core/security/JwtAuthenticationFilterTest.java create mode 100644 src/test/java/com/carecode/domain/community/mapper/CommunityMapperCommentTreeTest.java create mode 100644 src/test/java/com/carecode/domain/community/service/CommunityServiceHiddenPostTest.java create mode 100644 src/test/java/com/carecode/domain/user/service/EmailVerificationServiceTest.java create mode 100644 src/test/java/com/carecode/domain/user/service/UserServiceSignUpTest.java diff --git a/build.gradle b/build.gradle index 4c84bd7b..4d0c7d80 100644 --- a/build.gradle +++ b/build.gradle @@ -138,25 +138,35 @@ tasks.named('jacocoTestCoverageVerification') { violationRules { // 1) 전체 커버리지: 후퇴 방지선(ratchet). - // 현재 라인 커버리지는 약 16% 다. 테스트를 늘릴 때마다 이 값을 함께 올린다. + // 현재 라인 커버리지는 약 34% 다. 테스트를 늘릴 때마다 이 값을 함께 올린다. // (기존 0.01 은 사실상 게이트가 없는 것과 같아 통과해도 의미가 없었다.) rule { element = 'BUNDLE' limit { counter = 'LINE' value = 'COVEREDRATIO' - minimum = 0.15 + minimum = 0.30 } } // 2) 보안·인증 핵심 클래스는 별도의 높은 기준을 적용한다. // 이 경로들이 테스트 없이 수정되면 빌드가 실패해야 한다. + // + // 목록을 고를 때의 기준은 "여기가 조용히 망가지면 사고가 되는가" 다. + // - CurrentUserFacade: 본인 확인(소유권). 뚫리면 남의 계정을 조작할 수 있다. + // - JwtAuthenticationFilter: 누구로 인증되는지를 정한다. + // - RateLimitInterceptor: fail-open 이 깨지면 Redis 장애가 전면 장애가 된다. + // - EmailVerificationService: 인증번호 난수·시도 제한. rule { element = 'CLASS' includes = [ 'com.carecode.domain.user.service.JwtService', 'com.carecode.core.util.ClientIpResolver', - 'com.carecode.core.util.PageRequestUtil' + 'com.carecode.core.util.PageRequestUtil', + 'com.carecode.core.security.CurrentUserFacade', + 'com.carecode.core.security.JwtAuthenticationFilter', + 'com.carecode.core.RateLimitInterceptor', + 'com.carecode.domain.user.service.EmailVerificationService' ] limit { counter = 'LINE' diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md index 74816ffe..798aefa1 100644 --- a/docs/reference/access-control-matrix.md +++ b/docs/reference/access-control-matrix.md @@ -52,7 +52,8 @@ flowchart TD | `/auth/refresh` | 토큰 갱신 | | `/auth/kakao/login`, `/auth/kakao/login-url`, `/auth/kakao/complete-registration` | 카카오 | | `/oauth2/**` | — | -| `/users/send-code`, `/users/verify-code`, `/users/verify` | 이메일 인증 | +| `POST /auth/send-code`, `POST /auth/verify-code` | 이메일 인증번호 발송·검증 | +| `GET /auth/verify` | 메일로 받은 인증 링크 | ### 지원금 @@ -130,6 +131,7 @@ flowchart TD | 경로 | 비고 | |------|------| | `/auth/user/**`, `/auth/logout` | — | +| `/users/**` | **본인 계정 전용.** 경로 변수가 있는 구 경로는 서비스 진입 전에 본인인지 확인한다 | | `/users/privacy/**` | 열람·동의·탈퇴 | | `/children/**` | 자녀 정보 | | `/notifications/**` | — | @@ -165,6 +167,64 @@ flowchart TD | `/api/admin/policy-verification/**` | 금액 수기 검증 | | `/api/admin/reports/**` | 신고 처리 | +### 사용자 관리 (`/users` 에서 이관) + +아래 기능은 원래 `/users` 아래에 있었습니다. 그 컨트롤러의 제약은 `isAuthenticated()` 뿐이라 +**가입만 하면 누구나 자기 역할을 `ADMIN` 으로 바꾸고 관리자 API 전체를 열 수 있었습니다.** +전체 회원 목록·검색도 같은 조건으로 열려 있어 개인정보가 그대로 노출됐습니다. + +| 경로 | 이전 경로 | 용도 | +|------|-----------|------| +| `PUT /api/admin/users/{id}/role` | `PUT /users/{id}/role` | 역할 변경 (**권한 상승 경로**) | +| `PUT /api/admin/users/{id}/activate` | `PUT /users/{id}/activate` | 계정 활성화 | +| `PUT /api/admin/users/{id}/reactivate` | `PUT /users/{id}/reactivate` | 탈퇴 계정 복구 | +| `GET /api/admin/users/statistics` | `GET /users/statistics` | 회원 통계 | +| `GET /api/admin/users/search` | `GET /users/search` | 회원 검색 | +| `GET /api/admin/users/active` | `GET /users/active` | 활성 회원 목록 | +| `GET /api/admin/users/verified` | `GET /users/verified` | 인증 완료 회원 목록 | +| `GET /api/admin/users/recently-active` | `GET /users/recently-active` | 최근 활동 회원 | +| `GET /api/admin/users/by-type/{userType}` | `GET /users/by-type/{userType}` | 유형별 회원 | +| `GET /api/admin/users/by-region/{region}` | `GET /users/by-region/{region}` | 지역별 회원 | + +역할 변경은 URL 규칙에만 의존하지 않습니다. `UserService.updateUserRole` 자체에 +`@PreAuthorize("hasRole('ADMIN')")` 이 붙어 있어, 호출 경로가 어디로 바뀌어도 막힙니다. +계정 활성화·복구도 같습니다. + +### 회원가입 시 서버가 정하는 값 + +`POST /auth/register` 는 `permitAll` 입니다. 따라서 **요청 본문의 어떤 값도 권한에 영향을 주면 안 됩니다.** +예전에는 본문의 `role` 을 그대로 엔티티에 넣어서, 로그인 없이 `{"role":"ADMIN"}` 으로 가입하면 +그 자리에서 관리자가 됐습니다. + +| 필드 | 처리 | +|------|------| +| `role` | 무시하고 항상 `PARENT`. 승격은 `PUT /api/admin/users/{id}/role` 로만 | +| `provider`, `providerId` | 무시하고 `null`. 소셜 가입은 `AuthServiceImpl` 의 별도 경로가 처리 | +| `emailVerified` | 무시하고 `false`. 인증 메일을 통과해야 `true` | +| `password` | 항상 필수. 예전에는 `provider` 를 붙이면 비밀번호 검사를 건너뛸 수 있었음 | + +`UserDto` 를 요청 본문으로 그대로 받고 있어 Swagger 에는 위 필드가 여전히 노출됩니다. +값이 무시된다는 사실은 `UserService.createUser` 가 보장하며, +회귀 테스트는 `UserServiceSignUpTest` 에 있습니다. + +### 본인 확인이 필요한 경로 + +`/users/{userId}/...` 형태로 남아 있는 구 경로는 기존 클라이언트 호환을 위한 것이며, +서비스 진입 전에 `CurrentUserFacade.requireSelf` 로 본인인지 확인합니다. +남의 식별자를 넣으면 **404 가 아니라 403** 입니다. 404 로 응답하면 "그 ID 는 존재하지 않는다"는 +정보가 새어 계정 열거에 쓰입니다. + +| 구 경로 | 신규 경로 | +|---------|-----------| +| `PUT /users/{userId}/location` | `PUT /users/me/location` | +| `PUT /users/{userId}/profile-image` | `PUT /users/me/profile-image` | +| `PUT /users/{userId}/deactivate` | `PUT /users/me/deactivate` | +| `DELETE /users/{userId}` | `DELETE /users/me` | + +`GET /users/{userId}`, `PUT /users/{userId}` 는 삭제했습니다. 전자는 타인 프로필 조회(IDOR), +후자는 경로 변수를 무시하고 현재 사용자를 수정하던 API 라 시그니처가 동작과 달랐습니다. +본인 조회·수정은 `GET/PUT /users/profile` (또는 `/users/me`) 을 사용합니다. + ## 프로파일별 차이 | 경로 | dev / docker | prod | diff --git a/src/main/java/com/carecode/core/RateLimitInterceptor.java b/src/main/java/com/carecode/core/RateLimitInterceptor.java index d0c3d643..a41e5696 100644 --- a/src/main/java/com/carecode/core/RateLimitInterceptor.java +++ b/src/main/java/com/carecode/core/RateLimitInterceptor.java @@ -5,7 +5,6 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; @@ -14,8 +13,21 @@ import org.springframework.web.servlet.HandlerInterceptor; import java.time.Duration; - -/** Rate Limiting 인터셉터 - 인증된 사용자: userId 기반 분당 300회 (NAT/공유 IP 환경 대응) - 미인증 요청: IP 기반 분당 120회 - 민감 */ +import java.util.List; + +/** + * 전 구간 기본 rate limit. + * + *
여기는 어디까지나 하한선이다. 호출 한 건이 비용이 되는 API(챗봇의 LLM 호출)나 + * 무차별 대입 대상(로그인, 인증코드 검증)은 이 값으로 부족하므로 + * {@code @RateLimit} 으로 엔드포인트별 상한을 따로 건다. + */ @Component @Slf4j @RequiredArgsConstructor @@ -25,11 +37,28 @@ public class RateLimitInterceptor implements HandlerInterceptor { private final ClientIpResolver clientIpResolver; private static final String RATE_LIMIT_KEY_PREFIX = "ratelimit:"; - private static final int AUTHENTICATED_LIMIT = 300; // 인증 사용자 (userId 기준) - private static final int ANONYMOUS_LIMIT = 120; // 미인증 (IP 기준) - private static final int PUBLIC_SENSITIVE_LIMIT = 30; // 회원가입 등 민감 엔드포인트 + private static final int AUTHENTICATED_LIMIT = 300; // 인증 사용자 (userId 기준) + private static final int ANONYMOUS_LIMIT = 120; // 미인증 (IP 기준) + private static final int PUBLIC_SENSITIVE_LIMIT = 30; // 로그인·가입 등 민감 엔드포인트 private static final Duration WINDOW_DURATION = Duration.ofMinutes(1); + /** + * 낮은 한도를 적용할 공개 엔드포인트. + * + *
예전에는 {@code /api/v1/contact}, {@code /api/v1/auth/signup} 을 보고 있었다.
+ * 이 애플리케이션에는 {@code /api/v1} 로 매핑된 컨트롤러가 하나도 없어서
+ * (BaseController 의 {@code @RequestMapping("/api/v1")} 은 하위 클래스가 전부 덮어쓴다)
+ * 민감 엔드포인트 등급이 한 번도 적용된 적이 없었다.
+ */
+ private static final List Redis 장애가 전체 API 중단으로 번지면 안 된다. 예전에는 {@code DataAccessException}
+ * 만 잡았는데, 그 바깥에서 나는 실패(연결 팩토리가 없어 {@code opsForValue()} 가 null 이거나
+ * 직렬화 단계에서 나는 오류 등)는 그대로 500 이 됐다. 여기서는 어떤 런타임 실패든 통과시킨다.
+ */
+ private Long incrementQuietly(String key) {
+ try {
+ Long currentCount = redisTemplate.opsForValue().increment(key);
+ if (currentCount != null && currentCount == 1L) {
+ redisTemplate.expire(key, WINDOW_DURATION);
+ }
+ return currentCount;
+ } catch (RuntimeException e) {
+ log.error("Rate limit 확인 실패 - 제한을 건너뜁니다. key={}", key, e);
+ return null;
+ }
+ }
+
+ /** 남은 한도 안내 헤더. 이 조회가 실패해도 요청 자체는 막지 않는다. */
+ private void writeRateLimitHeaders(HttpServletResponse response, String key, int limit, long count) {
+ long ttlSeconds = 60;
+ try {
+ Long ttl = redisTemplate.getExpire(key);
+ if (ttl != null && ttl > 0) {
+ ttlSeconds = ttl;
+ }
+ } catch (RuntimeException e) {
+ log.debug("Rate limit TTL 조회 실패 - 기본값으로 헤더를 채웁니다. key={}", key);
+ }
+
response.setHeader("X-RateLimit-Limit", String.valueOf(limit));
response.setHeader("X-RateLimit-Remaining", String.valueOf(Math.max(0, limit - count)));
- response.setHeader("X-RateLimit-Reset", String.valueOf(resetTime));
- return true;
+ response.setHeader("X-RateLimit-Reset", String.valueOf((System.currentTimeMillis() / 1000) + ttlSeconds));
}
/** SecurityContext에서 인증된 사용자 ID 추출. 미인증이면 null. */
@@ -95,8 +152,6 @@ private String getClientIp(HttpServletRequest request) {
/** 공개 API 중 민감한 엔드포인트 (낮은 rate limit 적용) */
private boolean isPublicSensitiveEndpoint(String path) {
- return path.startsWith("/api/v1/contact") ||
- path.startsWith("/api/v1/auth/signup");
+ return PUBLIC_SENSITIVE_PREFIXES.stream().anyMatch(path::startsWith);
}
}
-
diff --git a/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java b/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java
index 6a4090de..9a473be7 100644
--- a/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java
+++ b/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java
@@ -11,6 +11,9 @@
import org.aspectj.lang.annotation.Aspect;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@@ -54,6 +57,15 @@ public Object rateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) thro
return joinPoint.proceed();
}
+ /**
+ * 호출자별 카운터 키를 만든다.
+ *
+ * 로그인한 요청은 IP 가 아니라 계정으로 센다. IP 로만 세면 (1) 같은 회사·학교·통신사 NAT
+ * 뒤의 사용자들이 한도를 나눠 쓰게 되고, (2) 챗봇처럼 계정 단위로 비용이 나가는 API 에서
+ * 한 사람이 IP 만 바꿔가며 한도를 초과할 수 있다.
+ *
+ * 비로그인 요청(로그인·회원가입·인증코드 발송)은 계정이 없으므로 IP 로 센다.
+ */
private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
String methodName = joinPoint.getSignature().toShortString();
@@ -61,6 +73,11 @@ private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
return methodName;
}
+ String principal = currentPrincipal();
+ if (principal != null) {
+ return methodName + ":user:" + principal;
+ }
+
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
@@ -68,6 +85,18 @@ private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
}
HttpServletRequest request = attributes.getRequest();
- return methodName + ":" + clientIpResolver.resolve(request);
+ return methodName + ":ip:" + clientIpResolver.resolve(request);
+ }
+
+ /** 인증된 호출자의 식별자. 비인증이면 null. */
+ private String currentPrincipal() {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null
+ || !authentication.isAuthenticated()
+ || authentication instanceof AnonymousAuthenticationToken) {
+ return null;
+ }
+ String name = authentication.getName();
+ return (name == null || name.isBlank() || "anonymousUser".equals(name)) ? null : name;
}
}
diff --git a/src/main/java/com/carecode/core/security/CurrentUserFacade.java b/src/main/java/com/carecode/core/security/CurrentUserFacade.java
index b048dafe..53a663ca 100644
--- a/src/main/java/com/carecode/core/security/CurrentUserFacade.java
+++ b/src/main/java/com/carecode/core/security/CurrentUserFacade.java
@@ -4,6 +4,7 @@
import com.carecode.domain.user.entity.User;
import com.carecode.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -11,6 +12,7 @@
import org.springframework.stereotype.Component;
/** Resolves the authenticated user from SecurityContextHolder and the persistence layer */
+@Slf4j
@Component
@RequiredArgsConstructor
public class CurrentUserFacade {
@@ -48,4 +50,47 @@ public String requireCurrentUserId() {
public Long requireCurrentUserDbId() {
return requireCurrentUser().getId();
}
+
+ public boolean isAdmin() {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ return authentication != null
+ && authentication.getAuthorities().stream()
+ .anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
+ }
+
+ /**
+ * 경로에 실린 사용자 식별자가 로그인한 본인인지 확인하고, 본인이면 엔티티를 돌려준다.
+ *
+ * 경로 변수는 발급된 {@code userId}(문자열)일 수도 있고 DB PK 일 수도 있다.
+ * 서비스 계층이 두 형태를 모두 받아 조회하므로 검증도 두 형태를 모두 인정한다.
+ *
+ * 남의 식별자를 넣었을 때 404 가 아니라 403 을 주는 이유는, 404 로 응답하면
+ * "그 ID 는 존재하지 않는다"는 정보가 새어 계정 열거에 쓰이기 때문이다.
+ */
+ public User requireSelf(String pathUserId) {
+ User current = requireCurrentUser();
+ if (matches(current, pathUserId)) {
+ return current;
+ }
+ log.warn("본인이 아닌 사용자 자원 접근 시도 - 요청자={}, 대상={}", current.getUserId(), pathUserId);
+ throw new CareServiceException("FORBIDDEN", "본인의 정보만 조회·변경할 수 있습니다.");
+ }
+
+ /** 본인이거나 관리자면 통과. 관리 화면과 본인 화면이 같은 엔드포인트를 쓰는 경우에만 사용한다. */
+ public User requireSelfOrAdmin(String pathUserId) {
+ if (isAdmin()) {
+ return requireCurrentUser();
+ }
+ return requireSelf(pathUserId);
+ }
+
+ private boolean matches(User current, String pathUserId) {
+ if (pathUserId == null || pathUserId.isBlank()) {
+ return false;
+ }
+ if (pathUserId.equals(current.getUserId())) {
+ return true;
+ }
+ return current.getId() != null && pathUserId.equals(String.valueOf(current.getId()));
+ }
}
diff --git a/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java b/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java
index 7b7db105..d50630f4 100644
--- a/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java
+++ b/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java
@@ -17,7 +17,7 @@
import java.io.IOException;
import java.util.Collections;
-/** JWT 인증 필터 요청에서 JWT 토큰을 추출하고 검증하여 인증 정보를 설정 */
+/** JWT 인증 필터. 요청에서 JWT 토큰을 추출하고 검증하여 인증 정보를 설정한다. */
@Slf4j
@Component
@RequiredArgsConstructor
@@ -28,78 +28,84 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
-
- String requestURI = request.getRequestURI();
- log.debug("JWT 필터 실행: {}", requestURI);
-
+
+ log.debug("JWT 필터 실행: {}", request.getRequestURI());
+
try {
- String token = extractTokenFromRequest(request);
- log.debug("추출된 토큰: {}", token != null ? "존재함" : "없음");
-
- if (StringUtils.hasText(token)) {
- log.debug("토큰 유효성 검증 시작");
- // Access Token 만 허용한다. Refresh Token 으로는 API 인증이 되지 않아야 한다.
- boolean isValid = jwtService.validateAccessToken(token);
- log.debug("토큰 유효성 검증 결과: {}", isValid);
-
- if (isValid) {
- String userId = jwtService.getUserIdFromToken(token);
- String email = jwtService.getEmailFromToken(token);
- String role = jwtService.getRoleFromToken(token);
-
- if (!StringUtils.hasText(email) || !StringUtils.hasText(role)) {
- // role 이 없으면 "ROLE_null" 권한으로 인증되던 문제를 차단한다.
- log.warn("JWT 인증 거부: 필수 클레임 누락 (email={}, role={})", email, role);
- SecurityContextHolder.clearContext();
- filterChain.doFilter(request, response);
- return;
- }
-
- // 인증 정보 생성 - email을 principal로 사용 (일관성을 위해)
- UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
- email, // principal을 email로 설정 (일관성)
- null, // credentials는 null
- Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role))
- );
-
- // SecurityContext에 인증 정보 설정
- SecurityContextHolder.getContext().setAuthentication(authentication);
-
- log.debug("JWT 인증 성공: userId={}, email={}, role={}", userId, email, role);
- } else {
- log.debug("JWT 토큰이 유효하지 않음");
- SecurityContextHolder.clearContext();
- }
- } else {
- log.debug("JWT 토큰이 없음");
- SecurityContextHolder.clearContext();
- }
+ authenticateIfTokenPresent(request);
} catch (Exception e) {
+ // 토큰 처리 중 예상치 못한 예외는 인증 실패로 취급한다.
log.error("JWT 인증 필터 오류: {}", e.getMessage(), e);
SecurityContextHolder.clearContext();
}
-
+
filterChain.doFilter(request, response);
}
+ /**
+ * Authorization 헤더에 토큰이 있을 때만 인증을 시도한다.
+ *
+ * 토큰이 아예 없으면 아무것도 건드리지 않는다. 예전에는 이 경우에도
+ * {@code SecurityContextHolder.clearContext()} 를 호출했다. 운영에서는 JWT 외에
+ * 인증 수단이 없어 결과가 같았지만, 자기가 세우지 않은 컨텍스트를 지우는 필터라
+ * 앞단에서 인증을 넣어주는 경로를 전부 조용히 무력화한다
+ * (테스트의 {@code @WithMockUser}, 나중에 세션·OAuth2 를 병행할 때의 인증 등).
+ * 실제로 접근제어 테스트가 이유 없이 401 로 떨어져 드러난 문제다.
+ */
+ private void authenticateIfTokenPresent(HttpServletRequest request) {
+ String token = extractTokenFromRequest(request);
+
+ if (!StringUtils.hasText(token)) {
+ log.debug("JWT 토큰 없음 - 기존 인증 정보를 유지한 채 통과시킵니다");
+ return;
+ }
+
+ // Access Token 만 허용한다. Refresh Token 으로는 API 인증이 되지 않아야 한다.
+ if (!jwtService.validateAccessToken(token)) {
+ log.debug("JWT 토큰이 유효하지 않음");
+ SecurityContextHolder.clearContext();
+ return;
+ }
+
+ String userId = jwtService.getUserIdFromToken(token);
+ String email = jwtService.getEmailFromToken(token);
+ String role = jwtService.getRoleFromToken(token);
+
+ if (!StringUtils.hasText(email) || !StringUtils.hasText(role)) {
+ // role 이 없으면 "ROLE_null" 권한으로 인증되던 문제를 차단한다.
+ log.warn("JWT 인증 거부: 필수 클레임 누락 (email={}, role={})", email, role);
+ SecurityContextHolder.clearContext();
+ return;
+ }
+
+ // principal 은 email 로 통일한다 (CurrentUserFacade 가 email 로 사용자를 찾는다).
+ UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
+ email,
+ null,
+ Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role))
+ );
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+
+ log.debug("JWT 인증 성공: userId={}, email={}, role={}", userId, email, role);
+ }
+
// 요청에서 JWT 토큰 추출
private String extractTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
- String token = bearerToken.substring(7);
- return token;
+ return bearerToken.substring(7);
}
-
+
return null;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
-
- // 다음 경로들은 JWT 인증을 건너뜀. 주의: /admin 은 여기서 제외하면 안 된다. 세션 기반 어드민 체인(SecurityConfig 참고)이 별도로 처리하며
- boolean shouldNotFilter = path.startsWith("/swagger-ui") ||
+
+ // 토큰을 볼 필요가 없는 공개 경로. 여기서 빠져도 인가는 SecurityConfig 가 담당한다.
+ return path.startsWith("/swagger-ui") ||
path.startsWith("/api-docs") ||
path.startsWith("/v3/api-docs") ||
path.startsWith("/actuator") ||
@@ -113,7 +119,5 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce
path.startsWith("/oauth2") ||
path.startsWith("/login/oauth2") ||
path.equals("/kakao-callback.html");
-
- return shouldNotFilter;
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java
index 89b16e95..37e2429c 100644
--- a/src/main/java/com/carecode/core/security/SecurityConfig.java
+++ b/src/main/java/com/carecode/core/security/SecurityConfig.java
@@ -110,9 +110,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/kakao-callback.html").permitAll()
// 이메일 인증 관련 엔드포인트 (공개 접근)
- .requestMatchers("/users/send-code", "/users/verify-code", "/users/verify").permitAll()
-
- // 관리자 API (ADMIN 권한 필요)
+ //
+ // 주의: 이 규칙은 오랫동안 /users/send-code 등을 가리키고 있었는데, 실제 엔드포인트는
+ // AuthController 의 /auth/* 다. 존재하지 않는 경로를 열어두고 진짜 경로는 아래
+ // anyRequest().authenticated() 에 걸려 있어서, 가입 전 인증코드 발송과
+ // 메일로 받은 인증 링크 클릭이 전부 401 이었다. 경로를 실제 매핑에 맞춘다.
+ .requestMatchers(HttpMethod.POST, "/auth/send-code", "/auth/verify-code").permitAll()
+ .requestMatchers(HttpMethod.GET, "/auth/verify").permitAll()
+
+ // 관리자 API (ADMIN 권한 필요).
+ // /api/** 인증 규칙보다 반드시 먼저 선언해야 한다.
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// 공개 API 엔드포인트
@@ -182,6 +189,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 인증이 필요한 API 엔드포인트 (로그인/회원가입 제외)
.requestMatchers("/auth/user/**").authenticated()
.requestMatchers("/auth/logout").authenticated()
+ // /users/** 는 전부 "본인 계정" API 다. 남의 계정을 다루는 관리 기능은
+ // /api/admin/users 로 옮겼으므로 여기에는 인증만 요구하면 충분하다.
+ .requestMatchers("/users/**").authenticated()
+ .requestMatchers("/children/**").authenticated()
+ .requestMatchers("/chatbot/**").authenticated()
.requestMatchers("/api/**").authenticated()
.requestMatchers("/facilities/search").authenticated()
.requestMatchers("/facilities/*/bookings/**").authenticated()
diff --git a/src/main/java/com/carecode/core/util/KakaoUtil.java b/src/main/java/com/carecode/core/util/KakaoUtil.java
index 4ec50358..5f155024 100644
--- a/src/main/java/com/carecode/core/util/KakaoUtil.java
+++ b/src/main/java/com/carecode/core/util/KakaoUtil.java
@@ -33,12 +33,11 @@ public class KakaoUtil {
@Value("${kakao.redirect-uri}")
private String redirectUri;
- @Value("${server.port:8081}")
- private String serverPort;
-
- @Value("${server.address:13.209.36.209}")
- private String serverAddress;
-
+ // serverPort/serverAddress 필드가 있었으나 어디에서도 쓰이지 않았다.
+ // 특히 serverAddress 의 기본값에 운영 IP 가 박혀 있었고, server.address 는 원래
+ // "서버가 바인딩할 주소"를 뜻하는 Boot 표준 속성이라 용도도 맞지 않았다.
+ // 카카오 리다이렉트 주소는 kakao.redirect-uri 하나로 관리한다.
+
// ObjectMapper 설정 - 알 수 없는 필드 무시
private final ObjectMapper objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
diff --git a/src/main/java/com/carecode/docs/ApiDocumentationGenerator.java b/src/main/java/com/carecode/docs/ApiDocumentationGenerator.java
index ce436832..4244cbdb 100644
--- a/src/main/java/com/carecode/docs/ApiDocumentationGenerator.java
+++ b/src/main/java/com/carecode/docs/ApiDocumentationGenerator.java
@@ -103,10 +103,24 @@ private void generateApiInfo(StringBuilder asciiDoc, JsonNode swaggerJson) {
asciiDoc.append("* **설명**: ").append(info.get("description").asText()).append("\n");
}
}
- asciiDoc.append("* **Base URL**: `http://13.209.36.209:8081`\n");
+ // Base URL 은 문서를 뽑은 서버에서 읽는다. 예전에는 특정 운영 IP 가 박혀 있어서,
+ // 스테이징에서 문서를 생성해도 운영 주소가 찍혔다.
+ asciiDoc.append("* **Base URL**: `").append(resolveBaseUrl(swaggerJson)).append("`\n");
asciiDoc.append("* **인증**: JWT Bearer Token\n\n");
}
+ /** springdoc 이 내려주는 servers[0].url 을 쓰고, 없으면 상대 경로로 표기한다. */
+ private String resolveBaseUrl(JsonNode swaggerJson) {
+ JsonNode servers = swaggerJson.get("servers");
+ if (servers != null && servers.isArray() && !servers.isEmpty()) {
+ JsonNode url = servers.get(0).get("url");
+ if (url != null && !url.asText().isBlank()) {
+ return url.asText();
+ }
+ }
+ return "/";
+ }
+
// 인증 정보 생성
private void generateAuthenticationInfo(StringBuilder asciiDoc) {
asciiDoc.append("== 인증\n\n");
diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java b/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java
index 4b1b7890..749e8ad5 100644
--- a/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java
+++ b/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java
@@ -1,10 +1,14 @@
package com.carecode.domain.admin.controller;
import com.carecode.core.exception.UserNotFoundException;
+import com.carecode.core.handler.ApiSuccess;
import com.carecode.domain.admin.dto.AdminUserResponse;
import com.carecode.domain.admin.dto.AdminUserUpdateRequest;
+import com.carecode.domain.user.dto.response.UserDto;
+import com.carecode.domain.user.dto.response.UserStatsResponse;
import com.carecode.domain.user.entity.User;
import com.carecode.domain.user.repository.UserRepository;
+import com.carecode.domain.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -13,19 +17,37 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
-/** 어드민 사용자 관리 API. 접근 제어는 SecurityConfig 의 /api/admin/** → hasRole("ADMIN") 규칙이 담당한다. */
+/**
+ * 어드민 사용자 관리 API.
+ *
+ * 접근 제어를 이중으로 건다. SecurityConfig 의 {@code /api/admin/**} → {@code hasRole("ADMIN")} URL 규칙과,
+ * 클래스 레벨 {@code @PreAuthorize}. URL 규칙 하나에만 의존하면 경로가 바뀌거나 앞선 매처가
+ * 이 경로를 삼켰을 때 조용히 열린다. 실제로 이 프로젝트는 {@code /health/**} 가
+ * {@code /hospitals/**} 를 삼켜 공개 API 가 통째로 막힌 전례가 있다.
+ *
+ * 여기 있는 조회·변경 기능은 원래 {@code /users} 에 있었다. 그쪽은 클래스 제약이
+ * {@code isAuthenticated()} 뿐이어서, 아무 회원이나 남의 역할을 ADMIN 으로 바꾸거나
+ * 전체 회원 개인정보를 열람할 수 있었다.
+ */
@RestController
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
+@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "어드민 - 사용자", description = "관리자 전용 사용자 관리 API")
public class AdminUserController {
private final UserRepository userRepository;
+ private final UserService userService;
+
+ // ==================== 조회 ====================
@GetMapping
@Operation(summary = "사용자 목록 조회")
@@ -34,12 +56,60 @@ public ResponseEntity 목록 응답에 태그 이름이 들어가므로 한 페이지를 그리면 게시글 수만큼 태그 조회가 나갔다.
+ * 페이징 쿼리에 컬렉션을 join fetch 하면 Hibernate 가 전체 행을 읽어 메모리에서 페이징하므로
+ * (HHH000104) 그 방향 대신 배치 로딩을 쓴다. 한 페이지의 태그를 IN 절 한 번으로 가져온다.
+ */
@ManyToMany
@JoinTable(
name = "TBL_POST_TAGS",
joinColumns = @JoinColumn(name = "POST_ID"),
inverseJoinColumns = @JoinColumn(name = "TAG_ID")
)
+ @org.hibernate.annotations.BatchSize(size = 100)
@Builder.Default
private List 입력은 한 게시글의 활성 댓글 전부다(답글 포함). 부모가 숨김 처리되어 목록에 없으면
+ * 답글이 통째로 사라지므로, 그런 고아 답글은 최상위로 올려 응답에서 잃지 않게 한다.
+ */
+ public List 답글을 여기서 재귀로 채우면 지연 로딩 컬렉션을 노드마다 건드려 N+1 이 된다.
+ * 트리가 필요한 곳은 {@link #toCommentTree(List)} 를 쓴다.
+ */
public CommunityCommentResponse toCommentResponse(Comment comment) {
- List 기존에는 최상위 댓글만 조회한 뒤 매퍼가 {@code comment.getReplies()} 를 재귀로 훑어서,
+ * 댓글 트리의 노드 수만큼 쿼리가 나갔다. 깊이가 깊은 글일수록 급격히 나빠진다.
+ * 평평하게 한 번에 읽고 트리는 메모리에서 조립한다(CommunityMapper 참고).
+ *
+ * {@code parentComment} 를 함께 fetch 하는 이유는 응답에 부모 ID 가 들어가는데,
+ * 프록시의 {@code getId()} 호출이 초기화를 유발해 다시 N+1 이 되기 때문이다.
+ */
+ @EntityGraph(attributePaths = {"author", "parentComment"})
+ @Query("SELECT c FROM Comment c WHERE c.post.id = :postId AND c.isActive = true ORDER BY c.createdAt ASC")
+ List 조회 메서드는 모두 {@code isActive = true} 를 건다. 신고 누적으로 자동 숨김된 글
+ * (ModerationService)이 목록·상세에 다시 나타나면 숨김 기능이 사실상 없는 것과 같다.
+ *
+ * {@code @EntityGraph(author)} 는 N+1 방지용이다. 응답 DTO 가 작성자 ID 를 쓰는데,
+ * 필드 접근 엔티티의 프록시는 {@code getId()} 호출만으로도 초기화되어 게시글 수만큼
+ * 사용자 조회가 나갔다. author 는 ToOne 이라 페이징과 함께 fetch 해도 안전하다.
+ * (태그는 컬렉션이라 fetch join 대신 Post 쪽 {@code @BatchSize} 로 처리한다.)
+ */
@Repository
public interface PostRepository extends JpaRepository findAll 이 아니라 findAllActive 다. 예전에는 필터 없이 전부 읽어서, 신고 누적으로
+ * 자동 숨김된 글(ModerationService)이 목록 첫 페이지에 그대로 남아 있었다.
+ * 인기·최신·검색 쿼리는 처음부터 isActive 를 걸고 있었는데 기본 목록만 빠져 있었다.
+ */
+ @Transactional(readOnly = true)
public CommunityPageResponse 숨김 처리된 글은 ID 를 알아도 열리지 않아야 한다. 목록에서만 감추고 상세를 열어두면
+ * 링크가 이미 퍼진 글에 대해서는 숨김이 아무 효과가 없다.
+ * (increment 쿼리 자체가 isActive 조건을 갖고 있어 숨김 글은 조회수도 오르지 않는다.)
+ */
public CommunityPostDetailResponse getPostById(Long postId) {
log.info("게시글 상세 조회 - 게시글 ID: {}", postId);
@@ -98,7 +107,7 @@ public CommunityPostDetailResponse getPostById(Long postId) {
throw new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId);
}
- Post post = postRepository.findById(postId)
+ Post post = postRepository.findActiveById(postId)
.orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId));
return communityMapper.toPostDetailResponse(post);
@@ -171,8 +180,9 @@ public List 이 컨트롤러의 모든 엔드포인트는 "로그인한 사람이 자기 자신에게" 하는 동작만 다룬다.
+ * 예전에는 여기에 역할 변경·사용자 검색·전체 목록 조회 같은 관리 기능이 함께 있었고,
+ * 클래스 레벨 제약이 isAuthenticated() 뿐이라 아무 회원이나
+ * PUT /users/{id}/role 로 자신을 ADMIN 으로 올릴 수 있었다.
+ * 관리 기능은 전부 /api/admin/users 로 옮겼다.
+ *
+ * 경로 변수 userId 가 남아 있는 엔드포인트는 기존 클라이언트 호환을 위해 형태만 유지하며,
+ * 실제로는 {@link CurrentUserFacade#requireSelf} 로 본인인지 확인한 뒤 본인 엔티티로만 동작한다.
+ * 신규 클라이언트는 /users/me/... 별칭을 사용한다.
+ */
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Slf4j
@PreAuthorize("isAuthenticated()")
-@Tag(name = "사용자 관리", description = "통합 사용자 관리 API (프로필, 통계, 위치)")
+@Tag(name = "사용자", description = "본인 계정 관리 API (프로필, 위치, 탈퇴)")
public class UserController extends BaseController {
private final UserService userService;
@@ -46,389 +52,167 @@ public class UserController extends BaseController {
private final UserMapper userMapper;
private final CurrentUserFacade currentUserFacade;
- // ====================
- // 사용자 프로필 ====================
-
- // 사용자 프로필 조회
- @GetMapping("/{userId}")
- @LogExecutionTime
- @Operation(summary = "사용자 프로필 조회", description = "특정 사용자의 프로필 정보 조회")
- @SecurityRequirement(name = "Bearer Authentication")
- public ResponseEntity 예전에는 운영 서버 IP 와 포트가 소스에 박혀 있었고, 경로도 실제 매핑에 없는
+ * {@code /users/verify} 였다. 즉 메일의 링크를 눌러도 인증이 되지 않았다.
+ */
+ @Value("${app.auth.email-verification.base-url:http://localhost:8082}")
+ private String verificationBaseUrl;
+
public void sendVerificationEmail(User user) {
String token = UUID.randomUUID().toString();
EmailVerificationToken verificationToken = EmailVerificationToken.builder()
@@ -41,8 +77,8 @@ public void sendVerificationEmail(User user) {
tokenRepository.save(verificationToken);
String subject = "[CareCode] 이메일 인증 안내";
- String text = "아래 링크를 클릭하여 이메일 인증을 완료해 주세요.\n" +
- "http://13.209.36.209:8081/users/verify?token=" + token;
+ String text = "아래 링크를 클릭하여 이메일 인증을 완료해 주세요.\n"
+ + trimTrailingSlash(verificationBaseUrl) + "/auth/verify?token=" + token;
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(user.getEmail());
@@ -69,9 +105,18 @@ public boolean verifyEmail(String token) {
}
public void sendVerificationCode(String email) {
- String code = String.valueOf((int)(Math.random() * 900000) + 100000); // 6자리 숫자
- redisTemplate.opsForValue().set("email:verify:" + email, code, 5, TimeUnit.MINUTES);
- // HTML 이메일로 code 발송
+ // 재발송 쿨다운. 메일 발송은 비용이 들고, 수신자 입장에서는 그대로 스팸이 된다.
+ String cooldownKey = COOLDOWN_KEY_PREFIX + email;
+ Boolean acquired = redisTemplate.opsForValue().setIfAbsent(cooldownKey, "1", RESEND_COOLDOWN);
+ if (Boolean.FALSE.equals(acquired)) {
+ throw new IllegalArgumentException("인증번호는 1분에 한 번만 요청할 수 있습니다.");
+ }
+
+ String code = generateCode();
+ redisTemplate.opsForValue().set(CODE_KEY_PREFIX + email, code, CODE_TTL.toMinutes(), TimeUnit.MINUTES);
+ // 새 코드를 냈으니 이전 코드에 쌓인 시도 횟수는 의미가 없다.
+ redisTemplate.delete(ATTEMPT_KEY_PREFIX + email);
+
String subject = "[CareCode] 이메일 인증번호 안내";
String htmlContent =
" 코드가 6자리 숫자라 후보가 90만 개뿐이다. 시도 횟수를 세지 않으면 유효 시간 5분 안에
+ * 전수 조회가 가능하므로, 코드당 {@value #MAX_VERIFY_ATTEMPTS} 회를 넘기면 코드를 폐기한다.
+ */
public boolean verifyCode(String email, String code) {
- String key = "email:verify:" + email;
- String savedCode = redisTemplate.opsForValue().get(key);
- if (savedCode != null && savedCode.equals(code)) {
- redisTemplate.delete(key);
+ String codeKey = CODE_KEY_PREFIX + email;
+ String attemptKey = ATTEMPT_KEY_PREFIX + email;
+
+ String savedCode = redisTemplate.opsForValue().get(codeKey);
+ if (savedCode == null) {
+ return false;
+ }
+
+ Long attempts = redisTemplate.opsForValue().increment(attemptKey);
+ if (attempts != null && attempts == 1L) {
+ // 코드와 수명을 맞춰 둬야 카운터만 남아 다음 코드까지 막는 일이 없다.
+ redisTemplate.expire(attemptKey, CODE_TTL);
+ }
+ if (attempts != null && attempts > MAX_VERIFY_ATTEMPTS) {
+ log.warn("이메일 인증번호 시도 횟수 초과 - 코드를 폐기합니다. email={}", email);
+ redisTemplate.delete(codeKey);
+ redisTemplate.delete(attemptKey);
+ return false;
+ }
+
+ // 코드 길이가 노출되지 않도록 상수 시간 비교를 쓴다.
+ boolean matched = MessageDigest.isEqual(
+ savedCode.getBytes(StandardCharsets.UTF_8),
+ code == null ? new byte[0] : code.getBytes(StandardCharsets.UTF_8));
+
+ if (matched) {
+ redisTemplate.delete(codeKey);
+ redisTemplate.delete(attemptKey);
return true;
}
return false;
}
-}
\ No newline at end of file
+
+ /** 000000~999999 를 균등하게 뽑는다. 앞자리가 0 이어도 6자리를 유지한다. */
+ private String generateCode() {
+ return String.format("%06d", SECURE_RANDOM.nextInt(1_000_000));
+ }
+
+ private String trimTrailingSlash(String url) {
+ if (url == null || url.isBlank()) {
+ return "";
+ }
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+}
diff --git a/src/main/java/com/carecode/domain/user/service/UserService.java b/src/main/java/com/carecode/domain/user/service/UserService.java
index 70b143af..ebfb2428 100644
--- a/src/main/java/com/carecode/domain/user/service/UserService.java
+++ b/src/main/java/com/carecode/domain/user/service/UserService.java
@@ -14,6 +14,7 @@
import com.carecode.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -39,6 +40,9 @@
@Transactional(readOnly = true)
public class UserService {
+ /** 자가 가입으로 만들어질 수 있는 유일한 역할. 그 이상은 관리자만 부여한다. */
+ private static final UserRole SELF_SIGNUP_ROLE = UserRole.PARENT;
+
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final RestTemplate restTemplate;
@@ -233,41 +237,45 @@ public UserDto createUser(UserDto userDto) {
throw new IllegalArgumentException("이미 존재하는 이메일입니다: " + userDto.getEmail());
}
- // OAuth 사용자와 일반 사용자 구분
- String encodedPassword = null;
- if (userDto.getProvider() == null || userDto.getProvider().isEmpty()) {
- // 일반 회원가입 사용자 - 비밀번호 필수
- if (userDto.getPassword() == null || userDto.getPassword().trim().isEmpty()) {
- throw new IllegalArgumentException("일반 회원가입 사용자는 비밀번호가 필수입니다.");
- }
- encodedPassword = passwordEncoder.encode(userDto.getPassword());
+ // 이메일 회원가입이므로 비밀번호는 항상 필수다.
+ if (userDto.getPassword() == null || userDto.getPassword().trim().isEmpty()) {
+ throw new IllegalArgumentException("비밀번호는 필수입니다.");
}
- // OAuth 사용자는 비밀번호가 null (카카오에서 인증)
-
- // role이 USER인 경우 PARENT로 변경
- String role = userDto.getRole();
- if ("USER".equals(role)) {
- role = "PARENT";
+ String encodedPassword = passwordEncoder.encode(userDto.getPassword());
+
+ // 클라이언트가 보낸 role 은 신뢰하지 않는다.
+ //
+ // 예전에는 요청 본문의 role 을 그대로 썼다. 이 엔드포인트(POST /auth/register)는
+ // permitAll 이므로, 로그인조차 없이 {"role":"ADMIN"} 으로 가입하면 그 자리에서
+ // 관리자가 됐다. 가입은 언제나 일반 사용자로 끝나야 하고, 승격은 관리자만 할 수 있는
+ // 별도 경로(PUT /api/admin/users/{id}/role)로만 가능해야 한다.
+ if (userDto.getRole() != null && !SELF_SIGNUP_ROLE.name().equals(userDto.getRole())) {
+ log.warn("회원가입 요청의 role 을 무시합니다 - 요청값={}, 적용값={}",
+ userDto.getRole(), SELF_SIGNUP_ROLE);
}
-
+
+ // provider/providerId 도 마찬가지다. 소셜 가입은 AuthServiceImpl 의 별도 경로가 처리하며
+ // 그쪽에서 provider 를 직접 지정한다. 여기서 클라이언트가 provider 를 붙일 수 있게 두면
+ // 비밀번호 없이(로그인 불가하지만) 임의 이메일·임의 providerId 로 계정을 미리 만들어 둘 수 있고,
+ // 이메일 인증도 건너뛴 것으로 표시됐다.
User user = User.builder()
.email(userDto.getEmail())
- .password(encodedPassword) // OAuth 사용자는 null
+ .password(encodedPassword)
.name(userDto.getName())
.phoneNumber(userDto.getPhoneNumber())
.birthDate(userDto.getBirthDate())
.gender(userDto.getGender() != null ? Gender.valueOf(userDto.getGender()) : null)
.address(userDto.getAddress())
.profileImageUrl(userDto.getProfileImageUrl())
- .role(UserRole.valueOf(role))
- .provider(userDto.getProvider()) // OAuth 제공자 정보
- .providerId(userDto.getProviderId()) // OAuth 제공자 ID
+ .role(SELF_SIGNUP_ROLE)
+ .provider(null)
+ .providerId(null)
.isActive(true)
- .emailVerified(userDto.getProvider() != null) // OAuth 사용자는 이메일 인증 완료로 간주
+ .emailVerified(false) // 인증 메일을 통과해야 true 가 된다
.build();
-
+
User savedUser = userRepository.save(user);
- eventLogger.log(EventType.SIGNED_UP, savedUser.getId(), userDto.getProvider());
+ eventLogger.log(EventType.SIGNED_UP, savedUser.getId(), null);
return convertToDto(savedUser);
}
@@ -308,9 +316,9 @@ public void deactivateUser(String userId) {
}
}
- // 사용자 활성화 (String ID)
+ // 사용자 활성화 (String ID) — 남의 계정 상태를 바꾸는 동작이라 관리자만 호출할 수 있다.
@Transactional
- @RequireAuthentication
+ @PreAuthorize("hasRole('ADMIN')")
public void activateUser(String userId) {
log.info("사용자 활성화: 사용자ID={}", userId);
@@ -326,18 +334,35 @@ public void activateUser(String userId) {
}
}
- // 사용자 역할 변경
+ /**
+ * 사용자 역할 변경.
+ *
+ * 권한 상승의 유일한 경로라 컨트롤러 매핑에만 의존하지 않는다. 호출 경로가 어디로 바뀌든
+ * ADMIN 이 아니면 여기서 막힌다. (예전에는 이 메서드가 {@code PUT /users/{id}/role} 로
+ * 노출돼 있었고 그 경로의 제약이 "로그인만 하면 됨" 이어서, 아무 회원이나 자신을
+ * ADMIN 으로 올릴 수 있었다.)
+ *
+ * 알 수 없는 역할 문자열이 오면 {@code UserRole.valueOf} 가 IllegalArgumentException 을
+ * 던지고, 전역 핸들러가 400 으로 변환한다.
+ */
@Transactional
- @RequireAuthentication
+ @PreAuthorize("hasRole('ADMIN')")
public void updateUserRole(Long userId, String newRole) {
log.info("사용자 역할 변경: 사용자ID={}, 새 역할={}", userId, newRole);
-
+
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + userId));
-
- user.setRole(UserRole.valueOf(newRole));
+
+ UserRole role;
+ try {
+ role = UserRole.valueOf(newRole.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("지원하지 않는 역할입니다: " + newRole);
+ }
+
+ user.setRole(role);
user.setUpdatedAt(LocalDateTime.now());
-
+
userRepository.save(user);
}
@@ -512,8 +537,16 @@ public void deleteUser(String userId) {
userRepository.save(user);
}
- // 사용자 계정 복구 (재활성화)
+ /**
+ * 탈퇴 계정 복구. 관리자 전용이다.
+ *
+ * 탈퇴한 본인은 로그인 자체가 되지 않으므로(비활성 계정은 인증에서 걸린다)
+ * "본인이 스스로 복구한다"는 흐름은 성립하지 않는다. 그런데도 이 기능이
+ * 로그인만 하면 되는 경로에 열려 있어, 아무 회원이나 남이 탈퇴시킨 계정을
+ * 되살릴 수 있었다.
+ */
@Transactional
+ @PreAuthorize("hasRole('ADMIN')")
public void reactivateUser(String userId) {
User user;
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 40925346..bbe43fef 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -14,6 +14,26 @@ spring:
maximum-pool-size: ${DB_POOL_MAX_SIZE:20}
minimum-idle: ${DB_POOL_MIN_IDLE:10}
+app:
+ rate-limit:
+ # 운영은 Nginx 리버스 프록시 뒤에 있다. 여기서 XFF 를 신뢰하지 않으면 모든 요청이
+ # 프록시 IP 하나로 집계되어, IP 기준 제한(로그인·인증코드 발송)이 전역 카운터처럼 동작한다.
+ # 프록시를 거치지 않고 앱 포트가 직접 노출되면 헤더 위조가 가능해지므로,
+ # 보안그룹/방화벽에서 8082 를 프록시에서만 접근하도록 막아둔 상태여야 한다.
+ trust-forwarded-headers: ${RATE_LIMIT_TRUST_XFF:true}
+
+ auth:
+ email-verification:
+ # 인증 메일에 넣을 링크의 기준 주소. 예전에는 소스에 운영 IP 가 박혀 있었다.
+ base-url: ${EMAIL_VERIFICATION_BASE_URL:}
+
+jwt:
+ refresh-token:
+ # 운영은 반드시 서버 측 세션 저장소를 쓴다.
+ # none 이면 로그아웃이 아무 것도 폐기하지 못하고(무작동), 탈취된 리프레시 토큰을
+ # 만료 전까지 회수할 방법이 없다. 로컬/CI 는 Redis 없이 뜰 수 있도록 기본값을 none 으로 둔다.
+ store: ${JWT_REFRESH_TOKEN_STORE:redis}
+
# 운영에서는 API 문서를 노출하지 않는다.
springdoc:
swagger-ui:
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 3dcf49ca..5dda6970 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -115,6 +115,10 @@ app:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://127.0.0.1:3000}
auth:
+ email-verification:
+ # 인증 메일 링크의 기준 주소. 비워두면 로컬 기본값을 쓴다.
+ # 실제 매핑은 GET /auth/verify 다 (예전에는 소스에 운영 IP 와 /users/verify 가 박혀 있었다).
+ base-url: ${EMAIL_VERIFICATION_BASE_URL:http://localhost:8082}
refresh-cookie:
# 리프레시 토큰을 HttpOnly 쿠키로 내려 XSS 로 세션이 통째로 털리는 것을 막는다.
# 프런트가 다른 오리진에 있으므로 기본값은 SameSite=None + Secure 다.
diff --git a/src/test/java/com/carecode/core/RateLimitInterceptorTest.java b/src/test/java/com/carecode/core/RateLimitInterceptorTest.java
new file mode 100644
index 00000000..510859bb
--- /dev/null
+++ b/src/test/java/com/carecode/core/RateLimitInterceptorTest.java
@@ -0,0 +1,168 @@
+package com.carecode.core;
+
+import com.carecode.core.util.ClientIpResolver;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.data.redis.RedisConnectionFailureException;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.time.Duration;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
+import static org.mockito.Mockito.when;
+
+/**
+ * 전 구간 기본 rate limit 인터셉터 테스트.
+ *
+ * 고정하려는 계약은 두 가지다.
+ * {@code /users/{userId}/...} 계열은 경로에 남의 식별자를 넣어도 그대로 동작했다.
+ * 남의 위치를 바꾸고, 남의 계정을 탈퇴시키고, 남의 프로필 이미지를 갈아끼울 수 있었다.
+ * 그 경로들이 다시 열리지 않도록 여기서 계약을 고정한다.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@DisplayName("CurrentUserFacade - 본인 확인")
+class CurrentUserFacadeTest {
+
+ private static final String ME_EMAIL = "me@example.com";
+
+ @Mock private UserRepository userRepository;
+
+ @InjectMocks private CurrentUserFacade currentUserFacade;
+
+ private User me;
+
+ @BeforeEach
+ void setUp() {
+ me = User.builder()
+ .id(1L)
+ .userId("user_me")
+ .email(ME_EMAIL)
+ .name("나")
+ .build();
+
+ when(userRepository.findByEmailAndDeletedAtIsNull(ME_EMAIL)).thenReturn(Optional.of(me));
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ private void loginAs(String email, String role) {
+ SecurityContextHolder.getContext().setAuthentication(
+ new UsernamePasswordAuthenticationToken(
+ email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role))));
+ }
+
+ @Nested
+ @DisplayName("requireSelf")
+ class RequireSelf {
+
+ @Test
+ @DisplayName("발급된 userId 로 본인을 지목하면 통과한다")
+ void acceptsOwnUserId() {
+ loginAs(ME_EMAIL, "PARENT");
+
+ assertThat(currentUserFacade.requireSelf("user_me")).isSameAs(me);
+ }
+
+ @Test
+ @DisplayName("DB PK 로 본인을 지목해도 통과한다")
+ void acceptsOwnDatabaseId() {
+ loginAs(ME_EMAIL, "PARENT");
+
+ // 서비스 계층이 두 형태를 모두 받아 조회하므로, 검증도 두 형태를 모두 인정해야 한다.
+ assertThat(currentUserFacade.requireSelf("1")).isSameAs(me);
+ }
+
+ @Test
+ @DisplayName("남의 userId 를 넣으면 403 으로 막힌다")
+ void rejectsOtherUserId() {
+ loginAs(ME_EMAIL, "PARENT");
+
+ assertThatThrownBy(() -> currentUserFacade.requireSelf("user_someone_else"))
+ .isInstanceOf(CareServiceException.class)
+ .hasFieldOrPropertyWithValue("errorCode", "FORBIDDEN");
+ }
+
+ @Test
+ @DisplayName("남의 DB PK 를 넣어도 막힌다")
+ void rejectsOtherDatabaseId() {
+ loginAs(ME_EMAIL, "PARENT");
+
+ assertThatThrownBy(() -> currentUserFacade.requireSelf("2"))
+ .isInstanceOf(CareServiceException.class)
+ .hasFieldOrPropertyWithValue("errorCode", "FORBIDDEN");
+ }
+
+ @Test
+ @DisplayName("관리자여도 requireSelf 로는 남을 건드릴 수 없다")
+ void adminIsNotExemptFromRequireSelf() {
+ // 관리 기능은 /api/admin/** 로 분리했다. 본인 전용 경로에 관리자 우회를 두면
+ // 그 경로가 다시 관리 API 처럼 쓰이기 시작한다.
+ loginAs(ME_EMAIL, "ADMIN");
+
+ assertThatThrownBy(() -> currentUserFacade.requireSelf("999"))
+ .isInstanceOf(CareServiceException.class);
+ }
+
+ @Test
+ @DisplayName("빈 식별자는 본인으로 인정하지 않는다")
+ void rejectsBlankId() {
+ loginAs(ME_EMAIL, "PARENT");
+
+ assertThatThrownBy(() -> currentUserFacade.requireSelf(""))
+ .isInstanceOf(CareServiceException.class);
+ assertThatThrownBy(() -> currentUserFacade.requireSelf(null))
+ .isInstanceOf(CareServiceException.class);
+ }
+
+ @Test
+ @DisplayName("비로그인 상태면 401 로 막힌다")
+ void rejectsAnonymous() {
+ SecurityContextHolder.getContext().setAuthentication(
+ new AnonymousAuthenticationToken("key", "anonymousUser",
+ List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
+
+ assertThatThrownBy(() -> currentUserFacade.requireSelf("1"))
+ .isInstanceOf(CareServiceException.class)
+ .hasFieldOrPropertyWithValue("errorCode", "UNAUTHORIZED");
+ }
+ }
+
+ @Nested
+ @DisplayName("requireSelfOrAdmin")
+ class RequireSelfOrAdmin {
+
+ @Test
+ @DisplayName("관리자는 남의 식별자로도 통과한다")
+ void adminPasses() {
+ loginAs(ME_EMAIL, "ADMIN");
+
+ assertThat(currentUserFacade.requireSelfOrAdmin("999")).isSameAs(me);
+ }
+
+ @Test
+ @DisplayName("일반 사용자는 본인이 아니면 막힌다")
+ void nonAdminBlocked() {
+ loginAs(ME_EMAIL, "PARENT");
+
+ assertThatThrownBy(() -> currentUserFacade.requireSelfOrAdmin("999"))
+ .isInstanceOf(CareServiceException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("isAdmin")
+ class IsAdmin {
+
+ @Test
+ @DisplayName("ROLE_ADMIN 권한이 있을 때만 참이다")
+ void detectsAdminRole() {
+ loginAs(ME_EMAIL, "ADMIN");
+ assertThat(currentUserFacade.isAdmin()).isTrue();
+
+ SecurityContextHolder.clearContext();
+ loginAs(ME_EMAIL, "PARENT");
+ assertThat(currentUserFacade.isAdmin()).isFalse();
+ }
+ }
+}
diff --git a/src/test/java/com/carecode/core/security/JwtAuthenticationFilterTest.java b/src/test/java/com/carecode/core/security/JwtAuthenticationFilterTest.java
new file mode 100644
index 00000000..c17fa073
--- /dev/null
+++ b/src/test/java/com/carecode/core/security/JwtAuthenticationFilterTest.java
@@ -0,0 +1,139 @@
+package com.carecode.core.security;
+
+import com.carecode.domain.user.service.JwtService;
+import jakarta.servlet.FilterChain;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * JWT 필터가 SecurityContext 를 다루는 방식에 대한 테스트.
+ *
+ * 예전 구현은 토큰이 없을 때도 {@code SecurityContextHolder.clearContext()} 를 호출했다.
+ * 운영에서는 JWT 외에 인증 수단이 없어 결과가 같았지만, "자기가 세우지 않은 컨텍스트를
+ * 지우는" 필터라 앞단에서 인증을 넣어주는 경로를 전부 무력화한다.
+ * 실제로 접근제어 테스트가 인증을 넣어도 401 로 떨어져 드러났다.
+ */
+@DisplayName("JwtAuthenticationFilter - SecurityContext 취급")
+class JwtAuthenticationFilterTest {
+
+ private static final String SECRET = "testJwtSecretKeyForFilterTestsMustBeAtLeast256BitsLong0123456789";
+
+ private JwtService jwtService;
+ private JwtAuthenticationFilter filter;
+
+ @BeforeEach
+ void setUp() {
+ jwtService = new JwtService();
+ ReflectionTestUtils.setField(jwtService, "secret", SECRET);
+ ReflectionTestUtils.setField(jwtService, "issuer", "carecode-test");
+ ReflectionTestUtils.setField(jwtService, "accessTokenExpiration", 3_600_000L);
+ ReflectionTestUtils.setField(jwtService, "refreshTokenExpiration", 2_592_000_000L);
+ filter = new JwtAuthenticationFilter(jwtService);
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ private void presetAuthentication() {
+ SecurityContextHolder.getContext().setAuthentication(
+ new UsernamePasswordAuthenticationToken(
+ "preset@example.com", null, List.of(new SimpleGrantedAuthority("ROLE_PARENT"))));
+ }
+
+ private MockHttpServletRequest request(String bearer) {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/community/posts");
+ if (bearer != null) {
+ request.addHeader("Authorization", "Bearer " + bearer);
+ }
+ return request;
+ }
+
+ @Test
+ @DisplayName("토큰이 없으면 기존 인증 정보를 지우지 않는다")
+ void keepsExistingAuthenticationWhenNoToken() throws Exception {
+ presetAuthentication();
+ FilterChain chain = mock(FilterChain.class);
+
+ MockHttpServletRequest req = request(null);
+ MockHttpServletResponse res = new MockHttpServletResponse();
+ filter.doFilter(req, res, chain);
+
+ assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull();
+ assertThat(SecurityContextHolder.getContext().getAuthentication().getName())
+ .isEqualTo("preset@example.com");
+ verify(chain).doFilter(req, res);
+ }
+
+ @Test
+ @DisplayName("유효한 Access Token 이면 email 을 principal 로 인증을 세운다")
+ void authenticatesWithAccessToken() throws Exception {
+ String token = jwtService.generateAccessToken("u-1", "user@example.com", "PARENT");
+
+ filter.doFilter(request(token), new MockHttpServletResponse(), mock(FilterChain.class));
+
+ var auth = SecurityContextHolder.getContext().getAuthentication();
+ assertThat(auth).isNotNull();
+ assertThat(auth.getName()).isEqualTo("user@example.com");
+ assertThat(auth.getAuthorities()).extracting("authority").containsExactly("ROLE_PARENT");
+ }
+
+ @Test
+ @DisplayName("Refresh Token 으로는 인증되지 않고, 앞서 있던 인증도 폐기된다")
+ void refreshTokenIsRejected() throws Exception {
+ presetAuthentication();
+ String refreshToken = jwtService.generateRefreshToken("u-1", "user@example.com");
+
+ filter.doFilter(request(refreshToken), new MockHttpServletResponse(), mock(FilterChain.class));
+
+ // 토큰을 제시했는데 그게 유효하지 않다면, 그 요청은 비인증으로 처리해야 한다.
+ assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
+ }
+
+ @Test
+ @DisplayName("변조된 토큰이면 인증하지 않는다")
+ void rejectsTamperedToken() throws Exception {
+ filter.doFilter(request("not-a-jwt"), new MockHttpServletResponse(), mock(FilterChain.class));
+
+ assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
+ }
+
+ @Test
+ @DisplayName("role 클레임이 없는 토큰은 거부한다")
+ void rejectsTokenWithoutRole() throws Exception {
+ // role 이 없으면 "ROLE_null" 권한으로 인증되던 문제를 막는다.
+ String noRoleToken = jwtService.generateAccessToken("u-1", "user@example.com", null);
+
+ filter.doFilter(request(noRoleToken), new MockHttpServletResponse(), mock(FilterChain.class));
+
+ assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
+ }
+
+ @Test
+ @DisplayName("토큰 유무와 무관하게 필터 체인은 항상 이어진다")
+ void alwaysContinuesChain() throws Exception {
+ FilterChain chain = mock(FilterChain.class);
+ MockHttpServletRequest req = request("not-a-jwt");
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ filter.doFilter(req, res, chain);
+
+ // 인가 판단은 SecurityConfig 가 한다. 필터가 직접 응답을 끊으면 공개 경로까지 막힌다.
+ verify(chain).doFilter(req, res);
+ }
+}
diff --git a/src/test/java/com/carecode/domain/community/mapper/CommunityMapperCommentTreeTest.java b/src/test/java/com/carecode/domain/community/mapper/CommunityMapperCommentTreeTest.java
new file mode 100644
index 00000000..e1e1bb0e
--- /dev/null
+++ b/src/test/java/com/carecode/domain/community/mapper/CommunityMapperCommentTreeTest.java
@@ -0,0 +1,103 @@
+package com.carecode.domain.community.mapper;
+
+import com.carecode.domain.community.dto.response.CommunityCommentResponse;
+import com.carecode.domain.community.entity.Comment;
+import com.carecode.domain.community.repository.CommentRepository;
+import com.carecode.domain.user.entity.User;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 댓글 트리 조립에 대한 테스트.
+ *
+ * 예전 매퍼는 최상위 댓글만 조회한 뒤 {@code comment.getReplies()} 를 재귀로 따라갔다.
+ * 지연 로딩 컬렉션이라 노드 하나마다 쿼리가 나갔고, 댓글이 많은 글일수록 급격히 느려졌다.
+ * 지금은 평평한 목록을 한 번에 읽어 메모리에서 트리를 만든다. 그 조립이 정확한지 고정한다.
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("CommunityMapper - 댓글 트리 조립")
+class CommunityMapperCommentTreeTest {
+
+ @Mock private CommentRepository commentRepository;
+
+ @InjectMocks private CommunityMapper mapper;
+
+ private static Comment comment(long id, Comment parent) {
+ return Comment.builder()
+ .id(id)
+ .content("내용 " + id)
+ .authorName("작성자")
+ .author(User.builder().id(100L).userId("user_a").build())
+ .likeCount(0)
+ .isActive(true)
+ .createdAt(LocalDateTime.of(2026, 1, 1, 0, 0).plusMinutes(id))
+ .parentComment(parent)
+ .build();
+ }
+
+ @Test
+ @DisplayName("부모-자식 관계를 그대로 복원한다")
+ void buildsTree() {
+ Comment root1 = comment(1L, null);
+ Comment root2 = comment(2L, null);
+ Comment reply1 = comment(3L, root1);
+ Comment reply2 = comment(4L, root1);
+ Comment nested = comment(5L, reply1);
+
+ List ModerationService 는 신고가 임계치를 넘으면 {@code post.isActive = false} 로 글을 감춘다.
+ * 그런데 기본 목록 조회는 {@code findAll} 을 써서 필터가 없었고, 상세 조회도 {@code findById} 라
+ * 링크만 알면 그대로 열렸다. 즉 숨김 기능이 사실상 동작하지 않았다.
+ * (인기·최신·검색 쿼리는 처음부터 isActive 를 걸고 있어서 더 눈에 띄지 않았다.)
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@DisplayName("CommunityService - 숨김 글 노출 방지")
+class CommunityServiceHiddenPostTest {
+
+ @Mock private PostRepository postRepository;
+ @Mock private CommentRepository commentRepository;
+ @Mock private TagRepository tagRepository;
+ @Mock private UserRepository userRepository;
+ @Mock private PostLikeRepository postLikeRepository;
+ @Mock private BookmarkRepository bookmarkRepository;
+ @Mock private CommunityMapper communityMapper;
+
+ @InjectMocks private CommunityService communityService;
+
+ private Post visiblePost() {
+ return Post.builder()
+ .id(1L)
+ .title("제목")
+ .content("내용")
+ .author(User.builder().id(10L).userId("user_a").build())
+ .isActive(true)
+ .createdAt(LocalDateTime.now())
+ .build();
+ }
+
+ @Test
+ @DisplayName("목록 조회는 활성 글만 읽는 쿼리를 쓴다")
+ void listUsesActiveOnlyQuery() {
+ Page 기존 구현의 문제는 세 가지였다.
+ * {@code POST /auth/register} 는 permitAll 이다. 그런데 예전 구현은 요청 본문의
+ * {@code role} 을 그대로 엔티티에 넣었다. 즉 로그인조차 없이
+ * {@code {"role":"ADMIN"}} 으로 가입하면 그 자리에서 관리자가 됐다.
+ * {@code provider} 도 클라이언트가 붙일 수 있어, 비밀번호 없이 임의 이메일·임의 providerId 로
+ * 계정을 미리 만들면서 이메일 인증까지 통과한 것으로 표시할 수 있었다.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@DisplayName("UserService - 회원가입 시 서버 결정 값")
+class UserServiceSignUpTest {
+
+ @Mock private UserRepository userRepository;
+ @Mock private PasswordEncoder passwordEncoder;
+ @Mock private RestTemplate restTemplate;
+ @Mock private EventLogger eventLogger;
+
+ @InjectMocks private UserService userService;
+
+ @BeforeEach
+ void setUp() {
+ when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
+ when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$encoded");
+ when(userRepository.save(any(User.class))).thenAnswer(inv -> {
+ User u = inv.getArgument(0);
+ u.setId(1L);
+ u.setUserId("user_generated");
+ return u;
+ });
+ }
+
+ private UserDto.UserDtoBuilder signupRequest() {
+ return UserDto.builder()
+ .email("new@example.com")
+ .password("secret123")
+ .name("신규회원");
+ }
+
+ private User captureSaved() {
+ ArgumentCaptor 이 규칙은 오랫동안 존재하지 않는 {@code /users/send-code} 를 가리키고 있었다.
+ * 실제 매핑인 {@code /auth/send-code} 는 화이트리스트에 없어 {@code anyRequest().authenticated()}
+ * 에 걸렸고, 그 결과 "가입하려면 먼저 로그인해야 하는" 상태였다.
+ * 메일로 받은 인증 링크({@code GET /auth/verify}) 역시 같은 이유로 401 이었다.
+ */
+ @ParameterizedTest(name = "{0} 은 가입 전에 호출할 수 있어야 한다")
+ @ValueSource(strings = {"/auth/send-code", "/auth/verify-code"})
+ void signupFlowPostPathsDoNotRequireLogin(String path) throws Exception {
+ MvcResult result = mockMvc.perform(post(path)).andReturn();
+
+ // 파라미터가 없어 400 이 날 수는 있어도, 인증을 요구해서는 안 된다.
+ assertThat(result.getResponse().getStatus())
+ .as("%s 는 비로그인 접근이 가능해야 한다", path)
+ .isNotIn(401, 403);
+ }
+
+ @Test
+ @DisplayName("이메일 인증 링크는 로그인 없이 열린다")
+ void emailVerificationLinkIsPublic() throws Exception {
+ MvcResult result = mockMvc.perform(get("/auth/verify").param("token", "dummy-token")).andReturn();
+
+ assertThat(result.getResponse().getStatus()).isNotIn(401, 403);
+ }
+
+ /**
+ * 권한 상승 회귀 방지.
+ *
+ * {@code PUT /users/{id}/role} 은 클래스 제약이 {@code isAuthenticated()} 뿐이어서,
+ * 가입만 하면 누구나 자신을 ADMIN 으로 올리고 {@code /api/admin/**} 전체를 열 수 있었다.
+ * 해당 매핑은 삭제했고, 역할 변경은 관리자 전용 경로로만 남겼다.
+ */
+ @ParameterizedTest(name = "{0} 매핑은 더 이상 존재하지 않는다")
+ @ValueSource(strings = {
+ "/users/1/role",
+ "/users/1/activate",
+ "/users/1/reactivate"
+ })
+ @WithMockUser(username = "attacker@example.com", roles = "PARENT")
+ void privilegedMappingsRemovedFromUserApi(String path) throws Exception {
+ MvcResult result = mockMvc.perform(
+ put(path).contentType(MediaType.APPLICATION_JSON).content("{\"role\":\"ADMIN\"}")).andReturn();
+
+ assertThat(result.getResponse().getStatus())
+ .as("%s 는 매핑이 없어야 한다 (404/405)", path)
+ .isIn(404, 405);
+ }
+
+ /** 사용자 목록·검색은 전체 회원 개인정보다. 로그인만 했다고 열리면 안 된다. */
+ @ParameterizedTest(name = "{0} 은 일반 회원에게 403 이다")
+ @ValueSource(strings = {
+ "/api/admin/users",
+ "/api/admin/users/statistics",
+ "/api/admin/users/search?keyword=a",
+ "/api/admin/users/active",
+ "/api/admin/users/verified",
+ "/api/admin/users/by-region/서울"
+ })
+ @WithMockUser(username = "member@example.com", roles = "PARENT")
+ void adminQueriesRejectNonAdmin(String path) throws Exception {
+ MvcResult result = mockMvc.perform(get(path)).andReturn();
+
+ assertThat(result.getResponse().getStatus())
+ .as("%s 는 ADMIN 이 아니면 막혀야 한다", path)
+ .isEqualTo(403);
+ }
+
+ /**
+ * 토큰 종류 혼동 회귀 방지.
+ *
+ * {@code /auth/refresh} 는 서명·만료만 보는 {@code validateToken} 을 쓰고 있었다.
+ * 그 검증은 Access Token 도 통과시키고, 서버 세션 저장소를 쓰지 않는 기본 설정
+ * ({@code jwt.refresh-token.store=none}) 에서는 뒤따르는 등록 여부 확인도 항상 참이라,
+ * 탈취한 1시간짜리 Access Token 을 30일짜리 Refresh Token 으로 바꿀 수 있었다.
+ */
+ @Test
+ @DisplayName("Access Token 으로는 토큰을 갱신할 수 없다")
+ void accessTokenCannotBeUsedToRefresh() throws Exception {
+ String accessToken = jwtService.generateAccessToken("u-1", "victim@example.com", "PARENT");
+
+ MvcResult result = mockMvc.perform(post("/auth/refresh")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"refreshToken\":\"" + accessToken + "\"}"))
+ .andReturn();
+
+ assertThat(result.getResponse().getStatus()).isEqualTo(401);
+ }
+
+ @Test
+ @DisplayName("Refresh Token 은 갱신 경로에서 토큰 종류 검증을 통과한다")
+ void refreshTokenPassesTypeCheck() throws Exception {
+ String refreshToken = jwtService.generateRefreshToken("u-1", "victim@example.com");
+
+ MvcResult result = mockMvc.perform(post("/auth/refresh")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"refreshToken\":\"" + refreshToken + "\"}"))
+ .andReturn();
+
+ // 해당 사용자가 DB 에 없으므로 최종 응답은 404 다. 중요한 건 "토큰 종류 때문에 401" 이
+ // 아니라는 점이다. 여기까지 왔다는 것은 종류 검증을 통과했다는 뜻이다.
+ assertThat(result.getResponse().getStatus())
+ .as("정상 Refresh Token 이 종류 검증에서 막히면 안 된다")
+ .isNotEqualTo(401);
+ }
+
+ @Test
+ @DisplayName("일반 회원은 관리자 경로로도 역할을 바꿀 수 없다")
+ void nonAdminCannotEscalateThroughAdminPath() throws Exception {
+ MvcResult result = mockMvc.perform(put("/api/admin/users/1/role")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"role\":\"ADMIN\"}")
+ .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
+ .user("member@example.com").roles("PARENT")))
+ .andReturn();
+
+ assertThat(result.getResponse().getStatus()).isEqualTo(403);
+ }
}
From 44537f51071d21f1e27c7cb1c3630fe2c0e00795 Mon Sep 17 00:00:00 2001
From: RosieOh <20172207@gm.hannam.ac.kr>
Date: Sun, 23 Aug 2026 02:19:47 +0900
Subject: [PATCH 2/2] =?UTF-8?q?FIX=20:=20=EC=9D=B8=EC=A6=9D=20=EB=A9=94?=
=?UTF-8?q?=EC=9D=BC=20=EB=A7=81=ED=81=AC=EC=9D=98=20=EB=B9=88=20=EA=B8=B0?=
=?UTF-8?q?=EC=A4=80=20=EC=A3=BC=EC=86=8C=EC=99=80=20XFF=20=EC=8B=A0?=
=?UTF-8?q?=EB=A2=B0=20=EA=B2=BD=EB=A1=9C=20=EC=A0=95=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
리뷰 중 확인한 두 가지를 고친다.
1) 운영 프로파일의 EMAIL_VERIFICATION_BASE_URL 기본값이 빈 문자열이었다.
환경변수를 넣지 않으면 인증 링크가 "/auth/verify?token=..." 라는 상대 경로로
나가 메일에서 클릭할 수 없다. 사용자는 이유도 모른 채 인증에 실패한다.
기본값을 없애 기동 시점에 실패하게 한다. DB_PASSWORD, JWT_SECRET 과 같은 취급이다.
2) app.rate-limit.trust-forwarded-headers 가 XFF 신뢰를 혼자 결정한다고 적어 뒀는데
사실이 아니었다. server.forward-headers-strategy: framework 가 등록하는
ForwardedHeaderFilter 가 요청을 감싸 getRemoteAddr() 자체를 헤더의 클라이언트
주소로 바꿔친다. 따라서 trust=false 의 폴백도 이미 헤더에서 온 값을 돌려준다.
"trust=false 니까 헤더 위조에 안전하다" 고 오판하기 쉬운 지점이라
실제 동작을 ForwardedClientIpTest 에 고정하고 주석을 정정했다.
376 tests, 0 failures.
---
.../carecode/core/util/ClientIpResolver.java | 10 +-
src/main/resources/application-prod.yml | 23 ++--
src/main/resources/application.yml | 4 +
.../core/util/ForwardedClientIpTest.java | 101 ++++++++++++++++++
4 files changed, 131 insertions(+), 7 deletions(-)
create mode 100644 src/test/java/com/carecode/core/util/ForwardedClientIpTest.java
diff --git a/src/main/java/com/carecode/core/util/ClientIpResolver.java b/src/main/java/com/carecode/core/util/ClientIpResolver.java
index a1ecfc8e..6e5dc846 100644
--- a/src/main/java/com/carecode/core/util/ClientIpResolver.java
+++ b/src/main/java/com/carecode/core/util/ClientIpResolver.java
@@ -5,7 +5,15 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
-/** 클라이언트 IP 해석기. X-Forwarded-For 는 클라이언트가 임의로 붙일 수 있는 헤더다 */
+/**
+ * 클라이언트 IP 해석기. X-Forwarded-For 는 클라이언트가 임의로 붙일 수 있는 헤더다.
+ *
+ * 주의: 이 클래스의 설정만으로 XFF 신뢰 여부가 결정되지 않는다.
+ * {@code server.forward-headers-strategy: framework} 가 켜져 있으면 Spring 의
+ * ForwardedHeaderFilter 가 요청을 감싸 {@code getRemoteAddr()} 자체를 헤더의 클라이언트
+ * 주소로 바꿔친다. 그래서 {@code trustForwardedHeaders=false} 의 폴백 경로도 이미
+ * 헤더에서 온 값을 돌려준다. 실제 동작은 ForwardedClientIpTest 에 고정해 두었다.
+ */
@Slf4j
@Component
public class ClientIpResolver {
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index bbe43fef..a17391ed 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -16,16 +16,27 @@ spring:
app:
rate-limit:
- # 운영은 Nginx 리버스 프록시 뒤에 있다. 여기서 XFF 를 신뢰하지 않으면 모든 요청이
- # 프록시 IP 하나로 집계되어, IP 기준 제한(로그인·인증코드 발송)이 전역 카운터처럼 동작한다.
- # 프록시를 거치지 않고 앱 포트가 직접 노출되면 헤더 위조가 가능해지므로,
- # 보안그룹/방화벽에서 8082 를 프록시에서만 접근하도록 막아둔 상태여야 한다.
+ # 명시적으로 켜 둔다. 다만 이 값이 XFF 신뢰 여부를 혼자 결정하지는 않는다.
+ #
+ # 실질적인 스위치는 application.yml 의 server.forward-headers-strategy: framework 다.
+ # 그 설정이 등록하는 ForwardedHeaderFilter 가 요청을 감싸 getRemoteAddr() 자체를
+ # X-Forwarded-For 의 클라이언트 주소로 바꿔친다. 그래서 이 값을 false 로 둬도
+ # 폴백인 getRemoteAddr() 이 이미 헤더에서 온 값이라 결과가 같다.
+ # (실측: ForwardedClientIpTest)
+ #
+ # 두 설정 모두 "앱 포트에 리버스 프록시만 접근할 수 있다" 는 전제 위에서만 안전하다.
+ # 배포가 -p 8082:8082 로 모든 인터페이스에 바인딩하므로, 보안그룹/방화벽이 유일한
+ # 방어선이다. 확인 항목은 이슈 #90 참고.
trust-forwarded-headers: ${RATE_LIMIT_TRUST_XFF:true}
auth:
email-verification:
- # 인증 메일에 넣을 링크의 기준 주소. 예전에는 소스에 운영 IP 가 박혀 있었다.
- base-url: ${EMAIL_VERIFICATION_BASE_URL:}
+ # 인증 메일 링크의 기준 주소. 기본값을 두지 않는다.
+ #
+ # 비워 두면 링크가 "/auth/verify?token=..." 라는 상대 경로로 나가 메일에서 클릭할 수 없고,
+ # 사용자는 이유도 모른 채 이메일 인증에 실패한다. 그런 침묵보다 기동 실패가 낫다.
+ # DB_PASSWORD, JWT_SECRET 과 같은 취급이다.
+ base-url: ${EMAIL_VERIFICATION_BASE_URL}
jwt:
refresh-token:
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 5dda6970..0debc386 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -164,6 +164,10 @@ app:
rate-limit:
# 신뢰할 수 있는 프록시 뒤에 있을 때만 X-Forwarded-For 를 사용한다.
# 프록시가 없는데 true 로 두면 헤더 위조로 rate limit 을 우회할 수 있다.
+ #
+ # 다만 이 값이 XFF 신뢰를 혼자 결정하지는 않는다. 위 server.forward-headers-strategy
+ # 가 등록하는 ForwardedHeaderFilter 가 getRemoteAddr() 을 이미 헤더 값으로 바꿔치므로,
+ # false 로 둬도 폴백 경로가 같은 값을 돌려준다. (실측: ForwardedClientIpTest, 이슈 #90)
trust-forwarded-headers: ${RATE_LIMIT_TRUST_XFF:false}
storage:
# 다중 인스턴스로 확장할 때는 FileStorageService 의 S3 구현체로 교체한다.
diff --git a/src/test/java/com/carecode/core/util/ForwardedClientIpTest.java b/src/test/java/com/carecode/core/util/ForwardedClientIpTest.java
new file mode 100644
index 00000000..f02aefbd
--- /dev/null
+++ b/src/test/java/com/carecode/core/util/ForwardedClientIpTest.java
@@ -0,0 +1,101 @@
+package com.carecode.core.util;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.filter.ForwardedHeaderFilter;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 프록시 뒤에서 클라이언트 IP 가 실제로 어떻게 결정되는지 고정한다.
+ *
+ * 이 프로젝트에는 X-Forwarded-For 를 다루는 지점이 두 곳 있고, 둘의 관계가
+ * 설정만 봐서는 드러나지 않는다.
+ *
+ * 중요한 것은 (1)이 이미 켜져 있으면 (2)를 꺼도 XFF 를 신뢰하는 결과가 된다는 점이다.
+ * {@code trust=false} 의 폴백인 {@code getRemoteAddr()} 이 이미 필터가 바꿔친 값이기 때문이다.
+ * 즉 실질적인 XFF 신뢰 스위치는 {@code server.forward-headers-strategy} 쪽이고,
+ * {@code trust-forwarded-headers} 는 이름이 시사하는 만큼의 통제력을 갖고 있지 않다.
+ *
+ * 이 사실을 모르면 "trust=false 니까 헤더 위조에 안전하다" 고 잘못 판단하게 된다.
+ * 그래서 추측 대신 여기에 실제 동작을 박아둔다. 관련 정리는 이슈 #90.
+ */
+@DisplayName("프록시 뒤 클라이언트 IP 해석")
+class ForwardedClientIpTest {
+
+ private static final String PROXY_IP = "10.0.0.5";
+ private static final String CLIENT_IP = "203.0.113.77";
+
+ private MockHttpServletRequest requestBehindProxy() {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/auth/login");
+ request.setRemoteAddr(PROXY_IP);
+ request.addHeader("X-Forwarded-For", CLIENT_IP);
+ request.addHeader("X-Forwarded-Proto", "https");
+ request.addHeader("Host", "api.example.com");
+ return request;
+ }
+
+ /** ForwardedHeaderFilter 통과 후의 요청을 돌려준다. 운영에서 컨트롤러가 보는 요청이다. */
+ private HttpServletRequest afterForwardedFilter(MockHttpServletRequest request) throws Exception {
+ ForwardedHeaderFilter filter = new ForwardedHeaderFilter();
+ MockFilterChain chain = new MockFilterChain();
+ filter.doFilter(request, new MockHttpServletResponse(), chain);
+ return (HttpServletRequest) chain.getRequest();
+ }
+
+ @Test
+ @DisplayName("ForwardedHeaderFilter 가 remote addr 을 XFF 의 클라이언트 주소로 바꿔친다")
+ void forwardedFilterRewritesRemoteAddr() throws Exception {
+ HttpServletRequest wrapped = afterForwardedFilter(requestBehindProxy());
+
+ assertThat(wrapped.getScheme()).isEqualTo("https");
+ assertThat(wrapped.getRemoteAddr())
+ .as("필터가 이미 클라이언트 주소를 복원하므로, 이후 단계는 프록시 IP 를 보지 않는다")
+ .isEqualTo(CLIENT_IP);
+ }
+
+ @Test
+ @DisplayName("필터가 켜져 있으면 trust=false 여도 클라이언트별로 구분된다")
+ void trustFlagIsRedundantWhenForwardedFilterIsEnabled() throws Exception {
+ HttpServletRequest wrapped = afterForwardedFilter(requestBehindProxy());
+
+ // 두 설정이 같은 결과를 낸다. rate limit 이 "프록시 IP 하나로 집계되는" 상황은
+ // forward-headers-strategy 가 꺼져 있을 때만 발생한다.
+ assertThat(new ClientIpResolver(false).resolve(wrapped)).isEqualTo(CLIENT_IP);
+ assertThat(new ClientIpResolver(true).resolve(wrapped)).isEqualTo(CLIENT_IP);
+ }
+
+ @Test
+ @DisplayName("필터가 없으면 trust 설정이 실제로 갈린다")
+ void withoutForwardedFilterTheFlagMatters() {
+ MockHttpServletRequest raw = requestBehindProxy();
+
+ assertThat(new ClientIpResolver(false).resolve(raw)).isEqualTo(PROXY_IP);
+ assertThat(new ClientIpResolver(true).resolve(raw)).isEqualTo(CLIENT_IP);
+ }
+
+ @Test
+ @DisplayName("헤더는 위조할 수 있다 — 앱 포트가 프록시 뒤에 있어야만 의미가 있다")
+ void forwardedHeaderIsAttackerControlled() throws Exception {
+ MockHttpServletRequest direct = new MockHttpServletRequest("POST", "/auth/login");
+ direct.setRemoteAddr("198.51.100.9"); // 공격자의 실제 주소
+ direct.addHeader("X-Forwarded-For", "1.2.3.4"); // 공격자가 직접 붙인 값
+
+ // 프록시를 거치지 않은 요청에도 필터는 헤더를 그대로 믿는다.
+ // 요청마다 값을 바꾸면 IP 기준 제한(로그인 시도, 인증코드 발송)이 무력화된다.
+ // 따라서 이 구성은 "앱 포트에 프록시만 접근 가능" 이라는 전제와 한 쌍이다.
+ assertThat(afterForwardedFilter(direct).getRemoteAddr()).isEqualTo("1.2.3.4");
+ }
+}
> search(@RequestParam String keyword,
+ @RequestParam(required = false) String type) {
+ List
> activeUsers() {
+ return ResponseEntity.ok(userService.getActiveUsers());
+ }
+
+ @GetMapping("/verified")
+ @Operation(summary = "이메일 인증 완료 사용자 목록")
+ public ResponseEntity
> verifiedUsers() {
+ return ResponseEntity.ok(userService.getVerifiedUsers());
+ }
+
+ @GetMapping("/recently-active")
+ @Operation(summary = "최근 활동 사용자 목록")
+ public ResponseEntity
> recentlyActiveUsers() {
+ return ResponseEntity.ok(userService.getRecentlyActiveUsers());
+ }
+
+ @GetMapping("/by-type/{userType}")
+ @Operation(summary = "사용자 유형별 조회")
+ public ResponseEntity
> usersByType(@PathVariable String userType) {
+ return ResponseEntity.ok(userService.getUsersByType(userType));
+ }
+
+ @GetMapping("/by-region/{region}")
+ @Operation(summary = "지역별 사용자 조회")
+ public ResponseEntity
> usersByRegion(@PathVariable String region) {
+ return ResponseEntity.ok(userService.getUsersByRegion(region));
+ }
+
@GetMapping("/{id}")
@Operation(summary = "사용자 상세 조회")
public ResponseEntity
+ *
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@DisplayName("RateLimitInterceptor - 기본 제한")
+class RateLimitInterceptorTest {
+
+ @Mock private StringRedisTemplate redisTemplate;
+ @Mock private ValueOperations
+ *
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@DisplayName("EmailVerificationService - 인증번호")
+class EmailVerificationServiceTest {
+
+ private static final String EMAIL = "user@example.com";
+ private static final String CODE_KEY = "email:verify:" + EMAIL;
+ private static final String ATTEMPT_KEY = "email:verify:attempt:" + EMAIL;
+ private static final String COOLDOWN_KEY = "email:verify:cooldown:" + EMAIL;
+
+ @Mock private EmailVerificationTokenRepository tokenRepository;
+ @Mock private UserRepository userRepository;
+ @Mock private JavaMailSender mailSender;
+ @Mock private RedisTemplate
+ *
+ *
+ *