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 PUBLIC_SENSITIVE_PREFIXES = List.of( + "/auth/login", + "/auth/register", + "/auth/refresh", + "/auth/send-code", + "/auth/verify-code", + "/auth/kakao" + ); + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String path = request.getRequestURI(); @@ -51,19 +80,14 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons private boolean checkLimit(String keyBody, int limit, HttpServletResponse response) throws Exception { String key = RATE_LIMIT_KEY_PREFIX + keyBody; - long count; - try { - Long currentCount = redisTemplate.opsForValue().increment(key); - if (currentCount != null && currentCount == 1) { - redisTemplate.expire(key, WINDOW_DURATION); - } - count = currentCount != null ? currentCount : 0; - } catch (DataAccessException e) { - // Redis 장애가 전체 API 중단으로 번지지 않도록 fail-open 한다. - log.error("Rate limit 확인 실패 - Redis 장애로 제한을 건너뜁니다. key={}", key, e); + + Long currentCount = incrementQuietly(key); + if (currentCount == null) { + // 카운터를 못 읽었다. 제한을 걸 근거가 없으므로 통과시킨다(fail-open). return true; } + long count = currentCount; if (count > limit) { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType("application/json;charset=UTF-8"); @@ -71,12 +95,45 @@ private boolean checkLimit(String keyBody, int limit, HttpServletResponse respon return false; } - Long ttl = redisTemplate.getExpire(key); - long resetTime = (System.currentTimeMillis() / 1000) + (ttl != null && ttl > 0 ? ttl : 60); + writeRateLimitHeaders(response, key, limit, count); + return true; + } + + /** + * 카운터를 증가시키고 현재 값을 돌려준다. 실패하면 null. + * + *

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> list( return ResponseEntity.ok(userRepository.findAll(pageable).map(AdminUserResponse::from)); } + @GetMapping("/statistics") + @Operation(summary = "사용자 통계 조회") + public ResponseEntity statistics() { + return ResponseEntity.ok(userService.getUserStatistics()); + } + + @GetMapping("/search") + @Operation(summary = "사용자 검색", description = "이름 또는 이메일 키워드로 검색") + public ResponseEntity> search(@RequestParam String keyword, + @RequestParam(required = false) String type) { + List users = (type != null && !type.isEmpty()) + ? userService.searchUsers(keyword, type) + : userService.searchUsers(keyword); + return ResponseEntity.ok(users); + } + + @GetMapping("/active") + @Operation(summary = "활성 사용자 목록") + public ResponseEntity> 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 detail(@PathVariable Long id) { return ResponseEntity.ok(AdminUserResponse.from(findUser(id))); } + // ==================== 변경 ==================== + @PatchMapping("/{id}") @Operation(summary = "사용자 정보 수정", description = "이름·연락처·역할·활성 상태만 변경할 수 있습니다") @Transactional @@ -64,6 +134,33 @@ public ResponseEntity update(@PathVariable Long id, return ResponseEntity.ok(AdminUserResponse.from(userRepository.save(user))); } + @PutMapping("/{id}/role") + @Operation(summary = "사용자 역할 변경", description = "PARENT / ADMIN 등 역할을 변경합니다") + public ResponseEntity updateRole(@PathVariable Long id, + @RequestBody Map request) { + String newRole = request.get("role"); + if (newRole == null || newRole.trim().isEmpty()) { + throw new IllegalArgumentException("역할은 필수입니다"); + } + userService.updateUserRole(id, newRole.trim()); + return ResponseEntity.ok(ApiSuccess.of("사용자 역할이 변경되었습니다")); + } + + @PutMapping("/{id}/activate") + @Operation(summary = "사용자 활성화", description = "비활성화된 사용자를 다시 활성화합니다") + public ResponseEntity activate(@PathVariable Long id) { + userService.activateUser(String.valueOf(id)); + return ResponseEntity.ok(ApiSuccess.of("사용자가 활성화되었습니다")); + } + + @PutMapping("/{id}/reactivate") + @Operation(summary = "탈퇴 계정 복구", + description = "소프트 삭제된 계정을 복구합니다. 탈퇴한 본인은 로그인할 수 없으므로 관리자만 수행할 수 있습니다") + public ResponseEntity reactivate(@PathVariable Long id) { + userService.reactivateUser(String.valueOf(id)); + return ResponseEntity.ok(ApiSuccess.of("계정이 복구되었습니다")); + } + @DeleteMapping("/{id}") @Operation(summary = "사용자 탈퇴 처리", description = "물리 삭제 대신 soft delete 로 비활성화") @Transactional diff --git a/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java b/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java index 804e5b7a..8ac9ae1c 100644 --- a/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java +++ b/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java @@ -1,6 +1,7 @@ package com.carecode.domain.chatbot.controller; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.core.annotation.RateLimit; import com.carecode.core.controller.BaseController; import com.carecode.core.security.CurrentUserFacade; import com.carecode.core.exception.CareServiceException; @@ -35,8 +36,13 @@ public class ChatbotController extends BaseController { private final CurrentUserFacade currentUserFacade; // 챗봇 메시지 전송 + // + // 이 엔드포인트는 요청 한 건이 곧 Claude API 유료 호출이다. 인증만 통과하면 무제한으로 + // 부를 수 있으면 계정 하나로 비용을 얼마든지 발생시킬 수 있어, 호출 단위 상한을 둔다. + // 사람이 실제로 대화하는 속도(분당 수 건)보다는 넉넉하게 잡았다. @PostMapping("/chat") @LogExecutionTime + @RateLimit(requests = 20, windowSeconds = 60, message = "대화 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.") @Operation(summary = "챗봇 메시지 전송", description = "챗봇과 대화를 시작") public ResponseEntity sendMessage( @Parameter(description = "챗봇 요청 정보", required = true) @RequestBody ChatbotMessageRequest request) { diff --git a/src/main/java/com/carecode/domain/community/entity/Post.java b/src/main/java/com/carecode/domain/community/entity/Post.java index de3bfd07..2db27e62 100644 --- a/src/main/java/com/carecode/domain/community/entity/Post.java +++ b/src/main/java/com/carecode/domain/community/entity/Post.java @@ -72,12 +72,20 @@ public class Post { @Builder.Default private List comments = new ArrayList<>(); + /** + * 태그 목록. + * + *

목록 응답에 태그 이름이 들어가므로 한 페이지를 그리면 게시글 수만큼 태그 조회가 나갔다. + * 페이징 쿼리에 컬렉션을 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 tags = new ArrayList<>(); diff --git a/src/main/java/com/carecode/domain/community/mapper/CommunityMapper.java b/src/main/java/com/carecode/domain/community/mapper/CommunityMapper.java index 5108fe79..e43fa213 100644 --- a/src/main/java/com/carecode/domain/community/mapper/CommunityMapper.java +++ b/src/main/java/com/carecode/domain/community/mapper/CommunityMapper.java @@ -13,7 +13,10 @@ import org.springframework.stereotype.Component; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; /** 커뮤니티 DTO 변환 매퍼 클래스 */ @@ -21,9 +24,9 @@ @Component @RequiredArgsConstructor public class CommunityMapper { - + private final CommentRepository commentRepository; - + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); // Post 엔티티를 PostResponse DTO로 변환 @@ -35,7 +38,7 @@ public CommunityPostResponse toPostResponse(Post post) { .content(post.getContent()) .category(post.getCategory().name()) .authorName(post.getAuthorName()) - .authorId(post.getAuthor().getId().toString()) + .authorId(authorId(post)) .isAnonymous(post.getIsAnonymous()) .createdAt(post.getCreatedAt().toString()) .viewCount(post.getViewCount()) @@ -49,11 +52,11 @@ public CommunityPostResponse toPostResponse(Post post) { // Post 엔티티를 PostDetailResponse DTO로 변환 public CommunityPostDetailResponse toPostDetailResponse(Post post) { - List comments = commentRepository.findByPostIdAndParentCommentIsNull(post.getId()); - List commentResponses = comments.stream() - .map(this::toCommentResponse) - .collect(Collectors.toList()); - + // 활성 댓글 전체를 한 번에 읽고 트리는 메모리에서 만든다. + // 예전에는 최상위 댓글만 읽고 답글을 재귀로 따라가서 노드 수만큼 쿼리가 나갔다. + List commentResponses = + toCommentTree(commentRepository.findActiveTreeByPostId(post.getId())); + // PostResponse의 기본 필드들 설정 CommunityPostDetailResponse response = new CommunityPostDetailResponse(); response.setPostId(post.getId()); @@ -61,7 +64,7 @@ public CommunityPostDetailResponse toPostDetailResponse(Post post) { response.setContent(post.getContent()); response.setCategory(post.getCategory().name()); response.setAuthorName(post.getAuthorName()); - response.setAuthorId(post.getAuthor().getId().toString()); + response.setAuthorId(authorId(post)); response.setIsAnonymous(post.getIsAnonymous()); response.setCreatedAt(post.getCreatedAt().format(DATE_FORMATTER)); response.setViewCount(post.getViewCount()); @@ -69,29 +72,67 @@ public CommunityPostDetailResponse toPostDetailResponse(Post post) { response.setCommentCount(post.getCommentCount()); response.setIsLiked(false); // Service 레벨에서 사용자별로 업데이트 필요 (CommunityService.isLikedByUser 참조) response.setIsBookmarked(false); // Service 레벨에서 사용자별로 업데이트 필요 (CommunityService.isBookmarkedByUser 참조) - + // PostDetailResponse의 추가 필드들 설정 response.setComments(commentResponses); - + return response; } - // Comment 엔티티를 CommentResponse DTO로 변환 + /** + * 평평한 댓글 목록을 부모-자식 트리로 조립한다. + * + *

입력은 한 게시글의 활성 댓글 전부다(답글 포함). 부모가 숨김 처리되어 목록에 없으면 + * 답글이 통째로 사라지므로, 그런 고아 답글은 최상위로 올려 응답에서 잃지 않게 한다. + */ + public List toCommentTree(List flatComments) { + if (flatComments == null || flatComments.isEmpty()) { + return List.of(); + } + + Map byId = new LinkedHashMap<>(); + for (Comment comment : flatComments) { + byId.put(comment.getId(), toCommentResponse(comment)); + } + + List roots = new ArrayList<>(); + for (Comment comment : flatComments) { + CommunityCommentResponse dto = byId.get(comment.getId()); + Long parentId = comment.getParentComment() != null ? comment.getParentComment().getId() : null; + + if (parentId == null) { + roots.add(dto); + continue; + } + + CommunityCommentResponse parent = byId.get(parentId); + if (parent != null) { + parent.getReplies().add(dto); + } else { + roots.add(dto); + } + } + return roots; + } + + /** + * Comment 엔티티를 CommentResponse DTO로 변환한다. 답글은 채우지 않는다. + * + *

답글을 여기서 재귀로 채우면 지연 로딩 컬렉션을 노드마다 건드려 N+1 이 된다. + * 트리가 필요한 곳은 {@link #toCommentTree(List)} 를 쓴다. + */ public CommunityCommentResponse toCommentResponse(Comment comment) { - List replies = comment.getReplies().stream() - .map(this::toCommentResponse) - .collect(Collectors.toList()); - return CommunityCommentResponse.builder() .commentId(comment.getId()) .content(comment.getContent()) .authorName(comment.getAuthorName()) - .authorId(comment.getAuthor().getId().toString()) + .authorId(comment.getAuthor() != null && comment.getAuthor().getId() != null + ? comment.getAuthor().getId().toString() : null) .createdAt(comment.getCreatedAt().format(DATE_FORMATTER)) .likeCount(comment.getLikeCount()) .isLiked(false) // Service 레벨에서 사용자별로 업데이트 필요 .parentCommentId(comment.getParentComment() != null ? comment.getParentComment().getId() : null) - .replies(replies) + .replies(new ArrayList<>()) .build(); } @@ -112,7 +153,7 @@ public List toPostResponseList(List posts) { .collect(Collectors.toList()); } - // Comment 엔티티 리스트를 CommentResponse DTO 리스트로 변환 + // Comment 엔티티 리스트를 CommentResponse DTO 리스트로 변환 (평면) public List toCommentResponseList(List comments) { return comments.stream() .map(this::toCommentResponse) @@ -125,4 +166,11 @@ public List toTagResponseList(List tags) { .map(this::toTagResponse) .collect(Collectors.toList()); } -} \ No newline at end of file + + /** 익명 글이라도 작성자 식별자는 소유권 판정에 쓰이므로 그대로 내려준다. */ + private String authorId(Post post) { + return post.getAuthor() != null && post.getAuthor().getId() != null + ? post.getAuthor().getId().toString() + : null; + } +} diff --git a/src/main/java/com/carecode/domain/community/repository/CommentRepository.java b/src/main/java/com/carecode/domain/community/repository/CommentRepository.java index a7721ef0..5dd49e3c 100644 --- a/src/main/java/com/carecode/domain/community/repository/CommentRepository.java +++ b/src/main/java/com/carecode/domain/community/repository/CommentRepository.java @@ -1,6 +1,7 @@ package com.carecode.domain.community.repository; import com.carecode.domain.community.entity.Comment; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,7 +17,21 @@ public interface CommentRepository extends JpaRepository { @Query("SELECT c FROM Comment c WHERE c.post.id = :postId AND c.parentComment IS NULL AND c.isActive = true ORDER BY c.createdAt ASC") List findByPostIdAndParentCommentIsNull(@Param("postId") Long postId); + /** + * 게시글의 활성 댓글 전체를 답글까지 한 번에 가져온다. + * + *

기존에는 최상위 댓글만 조회한 뒤 매퍼가 {@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 findActiveTreeByPostId(@Param("postId") Long postId); + // 게시글의 댓글 수 조회 @Query("SELECT COUNT(c) FROM Comment c WHERE c.post.id = :postId AND c.isActive = true") long countByPostId(@Param("postId") Long postId); -} \ No newline at end of file +} diff --git a/src/main/java/com/carecode/domain/community/repository/PostRepository.java b/src/main/java/com/carecode/domain/community/repository/PostRepository.java index a1f6487b..0f1ef14a 100644 --- a/src/main/java/com/carecode/domain/community/repository/PostRepository.java +++ b/src/main/java/com/carecode/domain/community/repository/PostRepository.java @@ -4,6 +4,7 @@ import com.carecode.domain.community.entity.Tag; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -11,31 +12,56 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; -/** 커뮤니티 게시글 리포지토리 인터페이스 */ +/** + * 커뮤니티 게시글 리포지토리. + * + *

조회 메서드는 모두 {@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 { + /** 게시글 목록 (기본 정렬은 호출부의 Pageable 을 따른다). */ + @EntityGraph(attributePaths = {"author"}) + @Query("SELECT p FROM Post p WHERE p.isActive = true") + Page findAllActive(Pageable pageable); + + /** 상세 조회. 숨김 처리된 글은 ID 를 알아도 열리지 않아야 한다. */ + @EntityGraph(attributePaths = {"author", "tags"}) + @Query("SELECT p FROM Post p WHERE p.id = :postId AND p.isActive = true") + Optional findActiveById(@Param("postId") Long postId); + // 제목 또는 내용으로 검색 + @EntityGraph(attributePaths = {"author"}) @Query("SELECT p FROM Post p WHERE p.isActive = true AND (p.title LIKE %:keyword% OR p.content LIKE %:keyword%)") Page findByKeyword(@Param("keyword") String keyword, Pageable pageable); // 인기 게시글 조회 (좋아요 순) - 페이징 + @EntityGraph(attributePaths = {"author"}) @Query("SELECT p FROM Post p WHERE p.isActive = true ORDER BY p.likeCount DESC, p.createdAt DESC") Page findPopularPosts(Pageable pageable); // 최신 게시글 조회 - 페이징 + @EntityGraph(attributePaths = {"author"}) @Query("SELECT p FROM Post p WHERE p.isActive = true ORDER BY p.createdAt DESC") Page findLatestPosts(Pageable pageable); // 태그별 게시글 목록 조회 + @EntityGraph(attributePaths = {"author"}) @Query("SELECT p FROM Post p JOIN p.tags t WHERE t = :tag AND p.isActive = true") List findByTagsContaining(@Param("tag") Tag tag); long countByAuthorId(Long authorId); - /** 조회수를 DB 에서 원자적으로 증가시킨다 (lost update 방지). */ + /** 조회수를 DB 에서 원자적으로 증가시킨다 (lost update 방지). 숨김 글은 세지 않는다. */ @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query("UPDATE Post p SET p.viewCount = COALESCE(p.viewCount, 0) + 1 WHERE p.id = :postId") + @Query("UPDATE Post p SET p.viewCount = COALESCE(p.viewCount, 0) + 1 WHERE p.id = :postId AND p.isActive = true") int incrementViewCount(@Param("postId") Long postId); -} \ No newline at end of file +} diff --git a/src/main/java/com/carecode/domain/community/service/CommunityService.java b/src/main/java/com/carecode/domain/community/service/CommunityService.java index d98bf1fe..6c3186c4 100644 --- a/src/main/java/com/carecode/domain/community/service/CommunityService.java +++ b/src/main/java/com/carecode/domain/community/service/CommunityService.java @@ -57,38 +57,47 @@ public class CommunityService { private final BookmarkRepository bookmarkRepository; private final CommunityMapper communityMapper; - // 게시글 목록 조회 (페이징) + /** + * 게시글 목록 조회 (페이징). + * + *

findAll 이 아니라 findAllActive 다. 예전에는 필터 없이 전부 읽어서, 신고 누적으로 + * 자동 숨김된 글(ModerationService)이 목록 첫 페이지에 그대로 남아 있었다. + * 인기·최신·검색 쿼리는 처음부터 isActive 를 걸고 있었는데 기본 목록만 빠져 있었다. + */ + @Transactional(readOnly = true) public CommunityPageResponse getAllPosts(int page, int size, String sortBy, String sortDirection) { log.info("게시글 목록 조회 - 페이지: {}, 크기: {}, 정렬: {}, 방향: {}", page, size, sortBy, sortDirection); - try { - Sort sort = com.carecode.core.util.SortUtil.createSort( - sortBy, sortDirection, "createdAt", Sort.Direction.DESC - ); - Pageable pageable = PageRequest.of(page, size, sort); - Page postPage = postRepository.findAll(pageable); - - List postResponses = communityMapper.toPostResponseList(postPage.getContent()); - - return CommunityPageResponse.builder() - .content(postResponses) - .page(postPage.getNumber()) - .size(postPage.getSize()) - .totalElements(postPage.getTotalElements()) - .totalPages(postPage.getTotalPages()) - .first(postPage.isFirst()) - .last(postPage.isLast()) - .hasNext(postPage.hasNext()) - .hasPrevious(postPage.hasPrevious()) - .build(); - } catch (Exception e) { - log.error("게시글 목록 조회 중 오류 발생: {}", e.getMessage()); - throw new CareServiceException("게시글 목록을 조회하는 중 오류가 발생했습니다."); - } + + Sort sort = com.carecode.core.util.SortUtil.createSort( + sortBy, sortDirection, "createdAt", Sort.Direction.DESC + ); + Pageable pageable = PageRequest.of(page, size, sort); + Page postPage = postRepository.findAllActive(pageable); + + List postResponses = communityMapper.toPostResponseList(postPage.getContent()); + + return CommunityPageResponse.builder() + .content(postResponses) + .page(postPage.getNumber()) + .size(postPage.getSize()) + .totalElements(postPage.getTotalElements()) + .totalPages(postPage.getTotalPages()) + .first(postPage.isFirst()) + .last(postPage.isLast()) + .hasNext(postPage.hasNext()) + .hasPrevious(postPage.hasPrevious()) + .build(); } // 레거시 전체 조회 메서드 제거 (페이징 API로 일원화) - // 게시글 상세 조회 + /** + * 게시글 상세 조회. + * + *

숨김 처리된 글은 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 getCommentsByPostId(Long postId) { throw new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId); } - List comments = commentRepository.findByPostIdAndParentCommentIsNull(postId); - return communityMapper.toCommentResponseList(comments); + // 답글까지 한 번에 읽고 트리는 메모리에서 조립한다 (댓글 수만큼 쿼리가 나가던 경로). + List comments = commentRepository.findActiveTreeByPostId(postId); + return communityMapper.toCommentTree(comments); } catch (ResourceNotFoundException e) { throw e; } catch (Exception e) { diff --git a/src/main/java/com/carecode/domain/user/app/UserFacade.java b/src/main/java/com/carecode/domain/user/app/UserFacade.java index f29e2d1b..fa7deb59 100644 --- a/src/main/java/com/carecode/domain/user/app/UserFacade.java +++ b/src/main/java/com/carecode/domain/user/app/UserFacade.java @@ -13,11 +13,6 @@ public class UserFacade { private final UserService userService; - @Transactional(readOnly = true) - public UserDto getUserById(String userId) { - return userService.getUserById(userId); - } - @Transactional(readOnly = true) public UserDto getUserByEmail(String email) { return userService.getUserByEmail(email); @@ -43,10 +38,7 @@ public void deleteUser(String userId) { userService.deleteUser(userId); } - @Transactional - public void reactivateUser(String userId) { - userService.reactivateUser(userId); - } + // 계정 복구는 관리자 전용이라 이 파사드를 거치지 않는다. AdminUserController 참고. @Transactional(readOnly = true) public User getUserEntityByEmail(String email) { diff --git a/src/main/java/com/carecode/domain/user/controller/AuthController.java b/src/main/java/com/carecode/domain/user/controller/AuthController.java index b3cb9912..b7fbcccf 100644 --- a/src/main/java/com/carecode/domain/user/controller/AuthController.java +++ b/src/main/java/com/carecode/domain/user/controller/AuthController.java @@ -82,6 +82,7 @@ public ResponseEntity login(@Parameter(description = "로그인 정보 // 회원가입 @PostMapping("/register") @LogExecutionTime + @RateLimit(requests = 5, windowSeconds = 3600, message = "가입 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.") @Operation(summary = "회원가입", description = "새로운 사용자 등록") public ResponseEntity register(@Parameter(description = "회원가입 정보", required = true) @Valid @RequestBody UserDto request) { UserDto createdUser = userService.createUser(request); @@ -120,7 +121,11 @@ public ResponseEntity refreshToken( String refreshToken = refreshTokenCookieFactory.read(httpRequest) .orElseGet(() -> request != null ? request.getRefreshToken() : null); - if (refreshToken == null || refreshToken.isBlank() || !jwtService.validateToken(refreshToken)) { + // validateToken 이 아니라 validateRefreshToken 이어야 한다. + // 전자는 서명·만료·issuer 만 보므로 Access Token 도 통과한다. 서버 세션 저장소를 쓰지 않는 + // 기본 설정(jwt.refresh-token.store=none)에서는 뒤의 isRegistered 도 항상 true 라, + // 탈취한 1시간짜리 Access Token 을 30일짜리 Refresh Token 으로 바꿀 수 있었다. + if (refreshToken == null || refreshToken.isBlank() || !jwtService.validateRefreshToken(refreshToken)) { return unauthorizedRefresh("유효하지 않은 Refresh Token입니다."); } @@ -192,8 +197,11 @@ public ResponseEntity verifyEmail(@RequestParam String token) { } // 인증 코드 발송 + // 메일 발송은 비용이 들고 수신자에게는 스팸이 된다. 서비스 계층의 주소별 쿨다운(60초)과 별개로, + // 주소만 바꿔가며 대량 발송하는 것을 막기 위해 호출자 단위 상한을 둔다. @PostMapping("/send-code") @LogExecutionTime + @RateLimit(requests = 5, windowSeconds = 600, message = "인증 코드 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.") @Operation(summary = "이메일 인증 코드 발송") public ResponseEntity sendVerificationCode(@RequestParam String email) { emailVerificationService.sendVerificationCode(email); @@ -201,8 +209,11 @@ public ResponseEntity sendVerificationCode(@RequestParam String emai } // 인증 코드 검증 + // 6자리 숫자 코드라 후보가 90만 개뿐이다. 코드당 시도 제한(EmailVerificationService)에 더해 + // 호출 자체를 제한해 다른 주소로 갈아타며 시도하는 것도 막는다. @PostMapping("/verify-code") @LogExecutionTime + @RateLimit(requests = 10, windowSeconds = 600, message = "인증 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.") @Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증") public ResponseEntity verifyCode(@RequestParam String email, @RequestParam String code) { boolean ok = emailVerificationService.verifyCode(email, code); diff --git a/src/main/java/com/carecode/domain/user/controller/UserController.java b/src/main/java/com/carecode/domain/user/controller/UserController.java index 88b89639..b77bf2c5 100644 --- a/src/main/java/com/carecode/domain/user/controller/UserController.java +++ b/src/main/java/com/carecode/domain/user/controller/UserController.java @@ -2,15 +2,16 @@ import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.controller.BaseController; +import com.carecode.core.handler.ApiSuccess; import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.user.app.UserFacade; +import com.carecode.domain.user.dto.request.UserUpdateRequestDto; import com.carecode.domain.user.dto.response.UserDto; import com.carecode.domain.user.dto.response.UserProfileCompletionResponse; import com.carecode.domain.user.dto.response.UserProfileMissingFields; -import com.carecode.domain.user.dto.request.UserUpdateRequestDto; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.service.UserService; -import com.carecode.domain.user.app.UserFacade; import com.carecode.domain.user.mapper.UserMapper; +import com.carecode.domain.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -23,22 +24,27 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; -import java.util.List; import java.util.Map; -import java.util.stream.Collectors; -import com.carecode.core.handler.ApiSuccess; -import com.carecode.domain.user.dto.response.UserStatsResponse; -import com.carecode.domain.user.dto.response.UserSearchResponse; -import com.carecode.domain.user.dto.response.UserListResponse; -import com.carecode.domain.user.dto.response.UserInfoResponse; -/** 통합 사용자 관리 컨트롤러 */ +/** + * 본인 계정 전용 API. + * + *

이 컨트롤러의 모든 엔드포인트는 "로그인한 사람이 자기 자신에게" 하는 동작만 다룬다. + * 예전에는 여기에 역할 변경·사용자 검색·전체 목록 조회 같은 관리 기능이 함께 있었고, + * 클래스 레벨 제약이 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 getUserProfile(@Parameter(description = "사용자 ID", required = true) - @PathVariable String userId) { - UserDto user = userFacade.getUserById(userId); - return ResponseEntity.ok(user); - } + // ==================== 프로필 조회 ==================== - // 현재 사용자 프로필 조회 - @GetMapping("/profile") + @GetMapping({"/profile", "/me"}) @LogExecutionTime - @Operation(summary = "현재 사용자 프로필 조회", description = "현재 로그인한 사용자의 프로필 정보 조회") + @Operation(summary = "내 프로필 조회", description = "현재 로그인한 사용자의 프로필 정보 조회") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity getCurrentUserProfile() { - String currentUserEmail = getCurrentUserEmail(); - UserDto userDto = userFacade.getUserByEmail(currentUserEmail); - - return ResponseEntity.ok(userDto); + return ResponseEntity.ok(userFacade.getUserByEmail(getCurrentUserEmail())); } - // 사용자 프로필 업데이트 - @PutMapping("/{userId}") + @GetMapping({"/profile/completion", "/me/profile-completion"}) @LogExecutionTime - @Operation(summary = "사용자 프로필 업데이트", description = "사용자의 프로필 정보를 업데이트") - @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity updateUserProfile(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, - @Parameter(description = "업데이트할 사용자 정보", required = true) - @Valid @RequestBody UserUpdateRequestDto request) { - User user = userFacade.getUserEntityByEmail(getCurrentUserEmail()); - userMapper.updateUserFromRequest(request, user); - UserDto updated = userMapper.toDto(userService.saveUser(user)); - return ResponseEntity.ok(updated); - } - - // 프로필 완성도 체크 - @GetMapping("/profile/completion") - @LogExecutionTime - @Operation(summary = "프로필 완성도 체크", description = "사용자 프로필의 완성도를 확인") + @Operation(summary = "프로필 완성도 체크", description = "내 프로필의 완성도를 확인") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity checkProfileCompletion() { - String currentUserEmail = getCurrentUserEmail(); - User user = userFacade.getUserEntityByEmail(currentUserEmail); - UserProfileCompletionResponse completion = calculateProfileCompletion(user); - return ResponseEntity.ok(completion); - } - - // 프로필 이미지 업데이트 - @PutMapping("/{userId}/profile-image") - @LogExecutionTime - @Operation(summary = "프로필 이미지 업데이트") - @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity updateProfileImage(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, - @Parameter(description = "프로필 이미지 URL", required = true) @RequestParam String profileImageUrl) { - userFacade.updateProfileImage(userId, profileImageUrl); - return ResponseEntity.ok().build(); - } - - // ==================== - // 사용자 위치 관리 ==================== - - // 사용자 위치 업데이트 - @PutMapping("/{userId}/location") - @LogExecutionTime - @Operation(summary = "사용자 위치 업데이트", description = "사용자의 현재 위치를 업데이트") - @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity updateUserLocation(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, - @Parameter(description = "위도", required = true) @RequestParam Double latitude, - @Parameter(description = "경도", required = true) @RequestParam Double longitude) { - UserDto updatedUser = userFacade.updateUserLocation(userId, latitude, longitude); - return ResponseEntity.ok(updatedUser); - } - - // ==================== 회원 탈퇴 ==================== - // 회원 탈퇴 (계정 비활성화) - @PutMapping("/{userId}/deactivate") - @LogExecutionTime - @Operation(summary = "회원 탈퇴 (계정 비활성화)", description = "사용자 계정을 비활성화합니다. 데이터는 보존되며 필요시 복구 가능") - @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity deactivateUser(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { - userFacade.deactivateUser(userId); - return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다.")); - } - - // 회원 탈퇴 (소프트 삭제) - @DeleteMapping("/{userId}") - @LogExecutionTime - @Operation(summary = "회원 탈퇴 (소프트 삭제)", description = "사용자 계정을 소프트 삭제합니다. 데이터는 보존되며 필요시 복구 가능") - @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity deleteUser(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { - userFacade.deleteUser(userId); - - return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다. 데이터는 보존되며 필요시 복구 가능합니다.")); - } - - // 계정 복구 (비활성화된 계정 재활성화) - @PutMapping("/{userId}/reactivate") - @LogExecutionTime - @Operation(summary = "계정 복구", description = "비활성화된 사용자 계정을 재활성화") - @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity reactivateUser(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { - userFacade.reactivateUser(userId); - - return ResponseEntity.ok(ApiSuccess.of("계정이 성공적으로 복구되었습니다.")); + User user = userFacade.getUserEntityByEmail(getCurrentUserEmail()); + return ResponseEntity.ok(calculateProfileCompletion(user)); } - // ==================== - // 프로필 관리 ==================== + // ==================== 프로필 수정 ==================== - // 프로필 업데이트 (추가 정보 입력) - @PutMapping("/profile") + @PutMapping({"/profile", "/me"}) @LogExecutionTime - @Operation(summary = "프로필 업데이트", description = "사용자의 추가 정보를 입력/업데이트") + @Operation(summary = "내 프로필 수정", description = "이름·연락처·생년월일·성별·주소 등을 수정") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity updateProfile(@Parameter(description = "업데이트할 프로필 정보", required = true) - @Valid @RequestBody UserUpdateRequestDto updateDto) { - String currentUserEmail = getCurrentUserEmail(); - User user = userService.getUserEntityByEmail(currentUserEmail); - - // 프로필 정보 업데이트 - updateUserProfile(user, updateDto); + public ResponseEntity updateProfile( + @Parameter(description = "업데이트할 프로필 정보", required = true) + @Valid @RequestBody UserUpdateRequestDto updateDto) { + User user = userService.getUserEntityByEmail(getCurrentUserEmail()); + userMapper.updateUserFromRequest(updateDto, user); user.setUpdatedAt(LocalDateTime.now()); - - // 저장 - User updatedUser = userService.saveUser(user); - - // DTO 변환 후 응답 - UserDto userDto = convertToDto(updatedUser); - return ResponseEntity.ok(userDto); + return ResponseEntity.ok(userMapper.toDto(userService.saveUser(user))); } - // 닉네임 업데이트 (카카오 닉네임과 별도) - @PatchMapping("/profile/nickname") + @PatchMapping({"/profile/nickname", "/me/nickname"}) @LogExecutionTime - @Operation(summary = "닉네임 업데이트", description = "사용자의 표시 닉네임을 업데이트") + @Operation(summary = "닉네임 변경", description = "표시 닉네임을 변경") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity updateNickname(@Parameter(description = "새로운 닉네임", required = true) - @RequestBody Map request) { - String currentUserEmail = getCurrentUserEmail(); + public ResponseEntity updateNickname( + @Parameter(description = "새로운 닉네임", required = true) @RequestBody Map request) { String newNickname = request.get("nickname"); - + if (newNickname == null || newNickname.trim().isEmpty()) { throw new IllegalArgumentException("닉네임은 필수입니다"); } - - if (newNickname.length() < 2 || newNickname.length() > 10) { + String trimmed = newNickname.trim(); + if (trimmed.length() < 2 || trimmed.length() > 10) { throw new IllegalArgumentException("닉네임은 2-10자 사이여야 합니다"); } - User user = userService.getUserEntityByEmail(currentUserEmail); - user.setName(newNickname.trim()); + User user = userService.getUserEntityByEmail(getCurrentUserEmail()); + user.setName(trimmed); user.setUpdatedAt(LocalDateTime.now()); userService.saveUser(user); return ResponseEntity.ok(ApiSuccess.of("닉네임이 업데이트되었습니다")); } - // ==================== - // 사용자 관리 (관리자용) ==================== - - // 사용자 통계 조회 - @GetMapping("/statistics") + @PutMapping("/me/profile-image") @LogExecutionTime - @Operation(summary = "사용자 통계 조회") + @Operation(summary = "프로필 이미지 변경") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity getUserStatistics() { - UserStatsResponse stats = userService.getUserStatistics(); - return ResponseEntity.ok(stats); + public ResponseEntity updateMyProfileImage( + @Parameter(description = "프로필 이미지 URL", required = true) @RequestParam String profileImageUrl) { + userFacade.updateProfileImage(selfDbId(), profileImageUrl); + return ResponseEntity.ok().build(); } - // 사용자 검색 - @GetMapping("/search") + @PutMapping("/{userId}/profile-image") @LogExecutionTime - @Operation(summary = "사용자 검색", description = "키워드로 사용자 검색") + @Operation(summary = "프로필 이미지 변경 (구 경로)", + description = "본인만 변경할 수 있습니다. 신규 클라이언트는 /users/me/profile-image 를 사용하세요") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity searchUsers(@Parameter(description = "검색 키워드", required = true) @RequestParam String keyword, - @Parameter(description = "검색 타입", required = false) @RequestParam(required = false) String type) { - - List users; - if (type != null && !type.isEmpty()) { - users = userService.searchUsers(keyword, type); - } else { - users = userService.searchUsers(keyword); - } - - UserSearchResponse searchResult = UserSearchResponse.builder() - .users(users.stream().map(this::convertToUserInfo).collect(Collectors.toList())) - .totalCount(users.size()) - .searchKeyword(keyword) - .searchFilters(type != null ? List.of(type) : List.of()) - .build(); - - return ResponseEntity.ok(searchResult); + public ResponseEntity updateProfileImage( + @Parameter(description = "사용자 ID", required = true) @PathVariable String userId, + @Parameter(description = "프로필 이미지 URL", required = true) @RequestParam String profileImageUrl) { + User self = currentUserFacade.requireSelf(userId); + userFacade.updateProfileImage(String.valueOf(self.getId()), profileImageUrl); + return ResponseEntity.ok().build(); } - // 활성 사용자 목록 조회 - @GetMapping("/active") - @LogExecutionTime - @Operation(summary = "활성 사용자 목록", description = "활성화된 사용자 목록 조회") - @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity getActiveUsers() { - List users = userService.getActiveUsers(); - UserListResponse userList = UserListResponse.builder() - .users(users.stream().map(this::convertToUserInfo).collect(Collectors.toList())) - .totalCount(users.size()) - .currentPage(0) - .totalPages(1) - .hasNext(false) - .hasPrevious(false) - .build(); - return ResponseEntity.ok(userList); - } + // ==================== 위치 ==================== - // 사용자 유형별 조회 - @GetMapping("/by-type/{userType}") + @PutMapping("/me/location") @LogExecutionTime - @Operation(summary = "사용자 유형별 조회", description = "특정 유형의 사용자들 조회") + @Operation(summary = "내 위치 갱신", description = "주변 시설 추천에 사용할 현재 위치를 갱신") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity getUsersByType(@Parameter(description = "사용자 유형", required = true) @PathVariable String userType) { - List users = userService.getUsersByType(userType); - UserListResponse userList = UserListResponse.builder() - .users(users.stream().map(this::convertToUserInfo).collect(Collectors.toList())) - .totalCount(users.size()) - .currentPage(0) - .totalPages(1) - .hasNext(false) - .hasPrevious(false) - .build(); - return ResponseEntity.ok(userList); + public ResponseEntity updateMyLocation( + @Parameter(description = "위도", required = true) @RequestParam Double latitude, + @Parameter(description = "경도", required = true) @RequestParam Double longitude) { + return ResponseEntity.ok(userFacade.updateUserLocation(selfDbId(), latitude, longitude)); } - // 지역별 사용자 조회 - @GetMapping("/by-region/{region}") + @PutMapping("/{userId}/location") @LogExecutionTime - @Operation(summary = "지역별 사용자 조회", description = "특정 지역의 사용자들 조회") + @Operation(summary = "위치 갱신 (구 경로)", + description = "본인만 변경할 수 있습니다. 신규 클라이언트는 /users/me/location 을 사용하세요") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity getUsersByRegion(@Parameter(description = "지역", required = true) @PathVariable String region) { - List users = userService.getUsersByRegion(region); - UserListResponse userList = UserListResponse.builder() - .users(users.stream().map(this::convertToUserInfo).collect(Collectors.toList())) - .totalCount(users.size()) - .currentPage(0) - .totalPages(1) - .hasNext(false) - .hasPrevious(false) - .build(); - return ResponseEntity.ok(userList); + public ResponseEntity updateUserLocation( + @Parameter(description = "사용자 ID", required = true) @PathVariable String userId, + @Parameter(description = "위도", required = true) @RequestParam Double latitude, + @Parameter(description = "경도", required = true) @RequestParam Double longitude) { + User self = currentUserFacade.requireSelf(userId); + return ResponseEntity.ok(userFacade.updateUserLocation(String.valueOf(self.getId()), latitude, longitude)); } - // 인증된 사용자 목록 조회 - @GetMapping("/verified") + // ==================== 회원 탈퇴 ==================== + + @PutMapping("/me/deactivate") @LogExecutionTime - @Operation(summary = "인증된 사용자 목록", description = "이메일 인증이 완료된 사용자 목록 조회") + @Operation(summary = "내 계정 비활성화", description = "데이터는 보존되며 관리자를 통해 복구할 수 있습니다") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity getVerifiedUsers() { - List users = userService.getVerifiedUsers(); - UserListResponse userList = UserListResponse.builder() - .users(users.stream().map(this::convertToUserInfo).collect(Collectors.toList())) - .totalCount(users.size()) - .currentPage(0) - .totalPages(1) - .hasNext(false) - .hasPrevious(false) - .build(); - return ResponseEntity.ok(userList); + public ResponseEntity deactivateMe() { + userFacade.deactivateUser(selfDbId()); + return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다.")); } - // 최근 활동 사용자 목록 조회 - @GetMapping("/recently-active") + @PutMapping("/{userId}/deactivate") @LogExecutionTime - @Operation(summary = "최근 활동 사용자 목록", description = "최근에 활동한 사용자 목록 조회") + @Operation(summary = "계정 비활성화 (구 경로)", description = "본인만 요청할 수 있습니다") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity getRecentlyActiveUsers() { - List users = userService.getRecentlyActiveUsers(); - UserListResponse userList = UserListResponse.builder() - .users(users.stream().map(this::convertToUserInfo).collect(Collectors.toList())) - .totalCount(users.size()) - .currentPage(0) - .totalPages(1) - .hasNext(false) - .hasPrevious(false) - .build(); - return ResponseEntity.ok(userList); + public ResponseEntity deactivateUser( + @Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { + User self = currentUserFacade.requireSelf(userId); + userFacade.deactivateUser(String.valueOf(self.getId())); + return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다.")); } - // 사용자 역할 변경 - @PutMapping("/{userId}/role") + @DeleteMapping("/me") @LogExecutionTime - @Operation(summary = "사용자 역할 변경", description = "사용자의 역할을 변경") + @Operation(summary = "회원 탈퇴 (소프트 삭제)", description = "데이터는 보존되며 필요시 복구 가능") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity updateUserRole(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, - @Parameter(description = "새로운 역할", required = true) @RequestBody Map request) { - String newRole = request.get("role"); - if (newRole == null || newRole.trim().isEmpty()) { - throw new IllegalArgumentException("역할은 필수입니다"); - } - - try { - Long id = Long.parseLong(userId); - userService.updateUserRole(id, newRole); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("잘못된 사용자 ID 형식입니다: " + userId); - } - - return ResponseEntity.ok(ApiSuccess.of("사용자 역할이 변경되었습니다")); + public ResponseEntity deleteMe() { + userFacade.deleteUser(selfDbId()); + return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다. 데이터는 보존되며 필요시 복구 가능합니다.")); } - // 사용자 활성화 - @PutMapping("/{userId}/activate") + @DeleteMapping("/{userId}") @LogExecutionTime - @Operation(summary = "사용자 활성화", description = "비활성화된 사용자를 활성화") + @Operation(summary = "회원 탈퇴 (구 경로)", description = "본인만 요청할 수 있습니다") @SecurityRequirement(name = "Bearer Authentication") - public ResponseEntity activateUser(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { - userService.activateUser(userId); - return ResponseEntity.ok(ApiSuccess.of("사용자가 활성화되었습니다")); + public ResponseEntity deleteUser( + @Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { + User self = currentUserFacade.requireSelf(userId); + userFacade.deleteUser(String.valueOf(self.getId())); + return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다. 데이터는 보존되며 필요시 복구 가능합니다.")); } - // ==================== - // 유틸리티 메서드 ==================== + // ==================== 유틸리티 ==================== - // 현재 로그인한 사용자의 이메일을 가져오기 private String getCurrentUserEmail() { return currentUserFacade.requireCurrentUserEmail(); } - // 사용자 프로필 업데이트 - private void updateUserProfile(User user, UserUpdateRequestDto updateDto) { - if (!isBlank(updateDto.getName())) { - user.setName(updateDto.getName().trim()); - } - if (!isBlank(updateDto.getPhoneNumber())) { - user.setPhoneNumber(updateDto.getPhoneNumber().trim()); - } - if (updateDto.getBirthDate() != null) { - user.setBirthDate(updateDto.getBirthDate()); - } - if (updateDto.getGender() != null) { - user.setGender(updateDto.getGender()); - } - if (!isBlank(updateDto.getAddress())) { - user.setAddress(updateDto.getAddress().trim()); - } - if (updateDto.getLatitude() != null) { - user.setLatitude(updateDto.getLatitude()); - } - if (updateDto.getLongitude() != null) { - user.setLongitude(updateDto.getLongitude()); - } - } - - // User Entity를 UserDto로 변환 - private UserDto convertToDto(User user) { - return UserDto.builder() - .id(user.getId()) - .userId(user.getUserId()) - .email(user.getEmail()) - .name(user.getName()) - .phoneNumber(user.getPhoneNumber()) - .birthDate(user.getBirthDate()) - .gender(user.getGender() != null ? user.getGender().name() : null) - .address(user.getAddress()) - .latitude(user.getLatitude()) - .longitude(user.getLongitude()) - .profileImageUrl(user.getProfileImageUrl()) - .role(user.getRole() != null ? user.getRole().name() : null) - .provider(user.getProvider()) - .providerId(user.getProviderId()) - .isActive(user.getIsActive()) - .emailVerified(user.getEmailVerified()) - .lastLoginAt(user.getLastLoginAt()) - .createdAt(user.getCreatedAt()) - .updatedAt(user.getUpdatedAt()) - .build(); + /** 서비스 계층이 DB PK 를 기대하므로, 본인 확인 뒤에는 항상 PK 로 정규화해 넘긴다. */ + private String selfDbId() { + return String.valueOf(currentUserFacade.requireCurrentUserDbId()); } - // 프로필 완성도 계산 private UserProfileCompletionResponse calculateProfileCompletion(User user) { - UserProfileMissingFields missingFields = + UserProfileMissingFields missingFields = UserProfileMissingFields.builder() .needsRealName(isBlank(user.getName()) || user.getName().contains("_")) .needsPhoneNumber(isBlank(user.getPhoneNumber())) @@ -439,23 +223,20 @@ private UserProfileCompletionResponse calculateProfileCompletion(User user) { int totalFields = 5; int completedFields = 0; - + if (!missingFields.isNeedsRealName()) completedFields++; if (!missingFields.isNeedsPhoneNumber()) completedFields++; if (!missingFields.isNeedsBirthDate()) completedFields++; if (!missingFields.isNeedsGender()) completedFields++; if (!missingFields.isNeedsAddress()) completedFields++; - + int percentage = (completedFields * 100) / totalFields; boolean isComplete = completedFields == totalFields; - - String message; - if (isComplete) { - message = "프로필이 완성되었습니다!"; - } else { - message = String.format("프로필 완성도: %d%% (%d개 항목 추가 필요)", percentage, totalFields - completedFields); - } - + + String message = isComplete + ? "프로필이 완성되었습니다!" + : String.format("프로필 완성도: %d%% (%d개 항목 추가 필요)", percentage, totalFields - completedFields); + return UserProfileCompletionResponse.builder() .isComplete(isComplete) .completionPercentage(percentage) @@ -464,34 +245,7 @@ private UserProfileCompletionResponse calculateProfileCompletion(User user) { .build(); } - // 문자열이 비어있는지 확인 private boolean isBlank(String str) { return str == null || str.trim().isEmpty(); } - - // UserDto를 UserInfoResponse로 변환 - private UserInfoResponse convertToUserInfo(UserDto userDto) { - return UserInfoResponse.builder() - .id(userDto.getId()) - .userId(userDto.getUserId()) - .email(userDto.getEmail()) - .name(userDto.getName()) - .phoneNumber(userDto.getPhoneNumber()) - .birthDate(userDto.getBirthDate()) - .gender(userDto.getGender()) - .address(userDto.getAddress()) - .latitude(userDto.getLatitude()) - .longitude(userDto.getLongitude()) - .profileImageUrl(userDto.getProfileImageUrl()) - .role(userDto.getRole()) - .provider(userDto.getProvider()) - .providerId(userDto.getProviderId()) - .isActive(userDto.getIsActive()) - .emailVerified(userDto.getEmailVerified()) - .registrationCompleted(userDto.getRegistrationCompleted()) - .lastLoginAt(userDto.getLastLoginAt()) - .createdAt(userDto.getCreatedAt()) - .updatedAt(userDto.getUpdatedAt()) - .build(); - } } diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserInfoResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserInfoResponse.java deleted file mode 100644 index f44157b6..00000000 --- a/src/main/java/com/carecode/domain/user/dto/response/UserInfoResponse.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.carecode.domain.user.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -/** 사용자 정보 응답 */ -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class UserInfoResponse { - private Long id; - private String userId; - private String email; - private String name; - private String phoneNumber; - private LocalDate birthDate; - private String gender; - private String address; - private Double latitude; - private Double longitude; - private String profileImageUrl; - private String role; - private String provider; - private String providerId; - private Boolean isActive; - private Boolean emailVerified; - private Boolean registrationCompleted; - private LocalDateTime lastLoginAt; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} - diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserListResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserListResponse.java deleted file mode 100644 index cf635d6f..00000000 --- a/src/main/java/com/carecode/domain/user/dto/response/UserListResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.carecode.domain.user.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.List; - -/** 사용자 목록 응답 */ -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class UserListResponse { - private List users; - private long totalCount; - private int currentPage; - private int totalPages; - private boolean hasNext; - private boolean hasPrevious; -} - diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserLoginResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserLoginResponse.java deleted file mode 100644 index 2a5500c1..00000000 --- a/src/main/java/com/carecode/domain/user/dto/response/UserLoginResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.carecode.domain.user.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -/** 로그인 응답 */ -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class UserLoginResponse { - private String accessToken; - private String refreshToken; - private String tokenType; - private Long expiresIn; - private Long refreshExpiresIn; - private UserInfoResponse user; - private boolean isNewUser; -} - diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserNearbyUsersResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserNearbyUsersResponse.java deleted file mode 100644 index 11d1d32f..00000000 --- a/src/main/java/com/carecode/domain/user/dto/response/UserNearbyUsersResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.carecode.domain.user.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.List; - -/** 위치 기반 사용자 응답 */ -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class UserNearbyUsersResponse { - private List users; - private double centerLatitude; - private double centerLongitude; - private double radius; - private int count; -} - diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserSearchResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserSearchResponse.java deleted file mode 100644 index 1cd50846..00000000 --- a/src/main/java/com/carecode/domain/user/dto/response/UserSearchResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.carecode.domain.user.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.List; - -/** 사용자 검색 응답 */ -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class UserSearchResponse { - private List users; - private long totalCount; - private String searchKeyword; - private List searchFilters; -} - diff --git a/src/main/java/com/carecode/domain/user/service/EmailVerificationService.java b/src/main/java/com/carecode/domain/user/service/EmailVerificationService.java index 029aa2ec..1564d0d5 100644 --- a/src/main/java/com/carecode/domain/user/service/EmailVerificationService.java +++ b/src/main/java/com/carecode/domain/user/service/EmailVerificationService.java @@ -6,11 +6,17 @@ import com.carecode.domain.user.repository.UserRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; import org.springframework.data.redis.core.RedisTemplate; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; import java.util.concurrent.TimeUnit; import java.time.LocalDateTime; @@ -19,9 +25,30 @@ import jakarta.mail.internet.MimeMessage; import org.springframework.mail.javamail.MimeMessageHelper; +@Slf4j @Service @RequiredArgsConstructor public class EmailVerificationService { + + private static final String CODE_KEY_PREFIX = "email:verify:"; + private static final String ATTEMPT_KEY_PREFIX = "email:verify:attempt:"; + private static final String COOLDOWN_KEY_PREFIX = "email:verify:cooldown:"; + + /** 코드 유효 시간. 짧을수록 안전하지만 사용자가 메일함을 여는 시간은 줘야 한다. */ + private static final Duration CODE_TTL = Duration.ofMinutes(5); + + /** 한 코드에 허용하는 검증 시도 횟수. 6자리 코드를 무제한으로 맞춰볼 수 없게 한다. */ + private static final int MAX_VERIFY_ATTEMPTS = 5; + + /** 재발송 최소 간격. 같은 주소로 메일을 연타 발송하지 못하게 한다. */ + private static final Duration RESEND_COOLDOWN = Duration.ofSeconds(60); + + /** + * 인증번호는 보안 값이다. Math.random() 은 선형 합동 생성기라 이전 출력에서 다음 값을 + * 예측할 수 있어 인증 수단으로 쓰면 안 된다. + */ + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private final EmailVerificationTokenRepository tokenRepository; private final UserRepository userRepository; private final JavaMailSender mailSender; @@ -30,6 +57,15 @@ public class EmailVerificationService { @Value("${spring.mail.username}") private String fromEmail; + /** + * 인증 링크의 기준 주소. + * + *

예전에는 운영 서버 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 = "" + @@ -96,17 +141,61 @@ public void sendVerificationCode(String email) { helper.setText(htmlContent, true); // true = HTML mailSender.send(mimeMessage); } catch (Exception e) { + // 발송이 실패했는데 쿨다운만 남으면 사용자가 1분간 재시도조차 못 한다. + redisTemplate.delete(cooldownKey); throw new RuntimeException("이메일 발송 중 오류가 발생했습니다.", e); } } + /** + * 인증번호 검증. + * + *

코드가 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 인터셉터 테스트. + * + *

고정하려는 계약은 두 가지다. + *

+ */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("RateLimitInterceptor - 기본 제한") +class RateLimitInterceptorTest { + + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + + private RateLimitInterceptor interceptor; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + interceptor = new RateLimitInterceptor(redisTemplate, new ClientIpResolver(false)); + response = new MockHttpServletResponse(); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(redisTemplate.getExpire(anyString())).thenReturn(60L); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private MockHttpServletRequest request(String path) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", path); + request.setRemoteAddr("203.0.113.10"); + return request; + } + + private void login(String email) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + email, null, List.of(new SimpleGrantedAuthority("ROLE_PARENT")))); + } + + @Test + @DisplayName("한도 안이면 통과하고 남은 한도를 헤더로 알려준다") + void allowsWithinLimit() throws Exception { + when(valueOperations.increment(anyString())).thenReturn(1L); + + assertThat(interceptor.preHandle(request("/community/posts"), response, new Object())).isTrue(); + assertThat(response.getHeader("X-RateLimit-Limit")).isNotNull(); + assertThat(response.getHeader("X-RateLimit-Remaining")).isNotNull(); + } + + @Test + @DisplayName("한도를 넘으면 429 로 끊는다") + void blocksOverLimit() throws Exception { + when(valueOperations.increment(anyString())).thenReturn(9_999L); + + assertThat(interceptor.preHandle(request("/community/posts"), response, new Object())).isFalse(); + assertThat(response.getStatus()).isEqualTo(429); + assertThat(response.getContentAsString()).contains("RATE_LIMIT_EXCEEDED"); + } + + @Test + @DisplayName("Redis 연결이 끊겨도 요청은 통과시킨다") + void failsOpenOnRedisOutage() throws Exception { + when(valueOperations.increment(anyString())) + .thenThrow(new RedisConnectionFailureException("connection refused")); + + assertThat(interceptor.preHandle(request("/community/posts"), response, new Object())).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("카운터 자체를 쓸 수 없는 상태(NPE 등)에서도 통과시킨다") + void failsOpenOnUnexpectedError() throws Exception { + when(redisTemplate.opsForValue()).thenReturn(null); + + assertThat(interceptor.preHandle(request("/community/posts"), response, new Object())).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("TTL 조회가 실패해도 요청을 막지 않는다") + void failsOpenOnTtlLookupError() throws Exception { + when(valueOperations.increment(anyString())).thenReturn(1L); + when(redisTemplate.getExpire(anyString())) + .thenThrow(new RedisConnectionFailureException("connection refused")); + + assertThat(interceptor.preHandle(request("/community/posts"), response, new Object())).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("로그인·가입 경로는 IP 기준 낮은 한도를 적용한다") + void sensitiveEndpointsUseIpKeyAndLowLimit() throws Exception { + login("member@example.com"); + when(valueOperations.increment(startsWith("ratelimit:ip:"))).thenReturn(1L); + + // 로그인한 상태여도 로그인/가입 경로는 IP 기준으로 센다. + assertThat(interceptor.preHandle(request("/auth/login"), response, new Object())).isTrue(); + assertThat(response.getHeader("X-RateLimit-Limit")).isEqualTo("30"); + } + + @Test + @DisplayName("인증 요청은 계정 기준으로 센다") + void authenticatedRequestsUseUserKey() throws Exception { + login("member@example.com"); + when(valueOperations.increment(eq("ratelimit:user:member@example.com"))).thenReturn(1L); + + assertThat(interceptor.preHandle(request("/community/posts"), response, new Object())).isTrue(); + assertThat(response.getHeader("X-RateLimit-Limit")).isEqualTo("300"); + } + + @Test + @DisplayName("미인증 요청은 IP 기준으로 센다") + void anonymousRequestsUseIpKey() throws Exception { + when(valueOperations.increment(startsWith("ratelimit:ip:"))).thenReturn(1L); + + assertThat(interceptor.preHandle(request("/community/posts"), response, new Object())).isTrue(); + assertThat(response.getHeader("X-RateLimit-Limit")).isEqualTo("120"); + } + + @Test + @DisplayName("첫 요청에서만 만료 시간을 건다") + void setsExpiryOnlyOnFirstHit() throws Exception { + when(valueOperations.increment(anyString())).thenReturn(1L); + interceptor.preHandle(request("/community/posts"), response, new Object()); + + org.mockito.Mockito.verify(redisTemplate).expire(anyString(), any(Duration.class)); + } +} diff --git a/src/test/java/com/carecode/core/security/CurrentUserFacadeTest.java b/src/test/java/com/carecode/core/security/CurrentUserFacadeTest.java new file mode 100644 index 00000000..b7a111cc --- /dev/null +++ b/src/test/java/com/carecode/core/security/CurrentUserFacadeTest.java @@ -0,0 +1,185 @@ +package com.carecode.core.security; + +import com.carecode.core.exception.CareServiceException; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +/** + * 본인 확인(소유권) 검증에 대한 테스트. + * + *

{@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 tree = + mapper.toCommentTree(List.of(root1, root2, reply1, reply2, nested)); + + assertThat(tree).hasSize(2); + assertThat(tree).extracting(CommunityCommentResponse::getCommentId).containsExactly(1L, 2L); + + CommunityCommentResponse first = tree.get(0); + assertThat(first.getReplies()).extracting(CommunityCommentResponse::getCommentId) + .containsExactly(3L, 4L); + assertThat(first.getReplies().get(0).getReplies()) + .extracting(CommunityCommentResponse::getCommentId) + .containsExactly(5L); + assertThat(tree.get(1).getReplies()).isEmpty(); + } + + @Test + @DisplayName("부모가 목록에 없는 답글은 버리지 않고 최상위로 올린다") + void keepsOrphanReplies() { + // 부모가 신고로 숨김 처리되면 조회 결과에서 빠진다. 그때 답글까지 사라지면 + // 사용자 입장에서는 자기 댓글이 이유 없이 없어진 것으로 보인다. + Comment hiddenParent = comment(10L, null); + Comment orphan = comment(11L, hiddenParent); + + List tree = mapper.toCommentTree(List.of(orphan)); + + assertThat(tree).extracting(CommunityCommentResponse::getCommentId).containsExactly(11L); + assertThat(tree.get(0).getParentCommentId()).isEqualTo(10L); + } + + @Test + @DisplayName("빈 목록은 빈 트리를 준다") + void handlesEmpty() { + assertThat(mapper.toCommentTree(List.of())).isEmpty(); + assertThat(mapper.toCommentTree(null)).isEmpty(); + } + + @Test + @DisplayName("단건 변환은 답글을 채우지 않는다") + void singleMappingDoesNotTouchReplies() { + // 여기서 답글을 재귀로 채우면 다시 N+1 이 된다. 트리가 필요하면 toCommentTree 를 쓴다. + Comment root = comment(1L, null); + root.setReplies(List.of(comment(2L, root))); + + CommunityCommentResponse response = mapper.toCommentResponse(root); + + assertThat(response.getReplies()).isEmpty(); + } +} diff --git a/src/test/java/com/carecode/domain/community/service/CommunityServiceHiddenPostTest.java b/src/test/java/com/carecode/domain/community/service/CommunityServiceHiddenPostTest.java new file mode 100644 index 00000000..617e3684 --- /dev/null +++ b/src/test/java/com/carecode/domain/community/service/CommunityServiceHiddenPostTest.java @@ -0,0 +1,117 @@ +package com.carecode.domain.community.service; + +import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.domain.community.entity.Post; +import com.carecode.domain.community.mapper.CommunityMapper; +import com.carecode.domain.community.repository.BookmarkRepository; +import com.carecode.domain.community.repository.CommentRepository; +import com.carecode.domain.community.repository.PostLikeRepository; +import com.carecode.domain.community.repository.PostRepository; +import com.carecode.domain.community.repository.TagRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +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 org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 신고 자동 숨김이 실제로 감추는지에 대한 회귀 테스트. + * + *

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 page = new PageImpl<>(List.of(visiblePost())); + when(postRepository.findAllActive(any(Pageable.class))).thenReturn(page); + when(communityMapper.toPostResponseList(any())).thenReturn(List.of()); + + communityService.getAllPosts(0, 10, "createdAt", "DESC"); + + verify(postRepository).findAllActive(any(Pageable.class)); + // findAll 로 돌아가면 숨김 글이 목록에 다시 나타난다. + verify(postRepository, never()).findAll(any(Pageable.class)); + } + + @Test + @DisplayName("숨김 글은 ID 를 알아도 상세가 열리지 않는다") + void hiddenPostDetailIsNotFound() { + // 조회수 증가 쿼리 자체가 isActive 조건을 갖고 있어 숨김 글에는 0건이 반영된다. + when(postRepository.incrementViewCount(1L)).thenReturn(0); + + assertThatThrownBy(() -> communityService.getPostById(1L)) + .isInstanceOf(ResourceNotFoundException.class); + + verify(postRepository, never()).findById(anyLong()); + } + + @Test + @DisplayName("상세 조회는 활성 글만 읽는 쿼리를 쓴다") + void detailUsesActiveOnlyQuery() { + when(postRepository.incrementViewCount(1L)).thenReturn(1); + when(postRepository.findActiveById(1L)).thenReturn(Optional.of(visiblePost())); + + communityService.getPostById(1L); + + verify(postRepository).findActiveById(1L); + verify(postRepository, never()).findById(anyLong()); + } + + @Test + @DisplayName("조회수는 올랐는데 글이 사라진 경우에도 404 로 끝난다") + void handlesRaceBetweenIncrementAndFetch() { + when(postRepository.incrementViewCount(1L)).thenReturn(1); + when(postRepository.findActiveById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> communityService.getPostById(1L)) + .isInstanceOf(ResourceNotFoundException.class); + } +} diff --git a/src/test/java/com/carecode/domain/user/service/EmailVerificationServiceTest.java b/src/test/java/com/carecode/domain/user/service/EmailVerificationServiceTest.java new file mode 100644 index 00000000..d2e01888 --- /dev/null +++ b/src/test/java/com/carecode/domain/user/service/EmailVerificationServiceTest.java @@ -0,0 +1,209 @@ +package com.carecode.domain.user.service; + +import com.carecode.domain.user.repository.EmailVerificationTokenRepository; +import com.carecode.domain.user.repository.UserRepository; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +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.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 이메일 인증번호에 대한 회귀 테스트. + * + *

기존 구현의 문제는 세 가지였다. + *

+ */ +@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 redisTemplate; + @Mock private ValueOperations valueOperations; + + @InjectMocks private EmailVerificationService service; + + @BeforeEach + void setUp() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(mailSender.createMimeMessage()).thenReturn(mock(MimeMessage.class)); + ReflectionTestUtils.setField(service, "fromEmail", "noreply@example.com"); + ReflectionTestUtils.setField(service, "verificationBaseUrl", "https://carecode.example.com"); + } + + private void cooldownFree() { + when(valueOperations.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(true); + } + + @Nested + @DisplayName("발송") + class Sending { + + @Test + @DisplayName("인증번호는 항상 6자리 숫자다") + void codeIsSixDigits() { + cooldownFree(); + + service.sendVerificationCode(EMAIL); + + ArgumentCaptor code = ArgumentCaptor.forClass(String.class); + verify(valueOperations).set(eq(CODE_KEY), code.capture(), anyLong(), eq(TimeUnit.MINUTES)); + + assertThat(code.getValue()).matches("\\d{6}"); + } + + @Test + @DisplayName("연속으로 뽑은 인증번호가 서로 다르다") + void codesVary() { + cooldownFree(); + + java.util.Set codes = new java.util.HashSet<>(); + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + + for (int i = 0; i < 20; i++) { + service.sendVerificationCode(EMAIL); + } + verify(valueOperations, times(20)).set(eq(CODE_KEY), captor.capture(), anyLong(), eq(TimeUnit.MINUTES)); + codes.addAll(captor.getAllValues()); + + // 20번 뽑아서 전부 같은 값이 나오면 난수원이 고장난 것이다. + assertThat(codes).hasSizeGreaterThan(1); + } + + @Test + @DisplayName("쿨다운 중이면 재발송하지 않는다") + void respectsCooldown() { + when(valueOperations.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(false); + + assertThatThrownBy(() -> service.sendVerificationCode(EMAIL)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1분에 한 번"); + + verify(mailSender, never()).send(any(MimeMessage.class)); + } + + @Test + @DisplayName("새 코드를 내면 이전 코드의 시도 횟수는 초기화된다") + void resetsAttemptCounterOnNewCode() { + cooldownFree(); + + service.sendVerificationCode(EMAIL); + + verify(redisTemplate).delete(ATTEMPT_KEY); + } + + @Test + @DisplayName("발송이 실패하면 쿨다운을 풀어 사용자가 다시 시도할 수 있게 한다") + void releasesCooldownWhenSendFails() { + cooldownFree(); + org.mockito.Mockito.doThrow(new RuntimeException("SMTP down")) + .when(mailSender).send(any(MimeMessage.class)); + + assertThatThrownBy(() -> service.sendVerificationCode(EMAIL)) + .isInstanceOf(RuntimeException.class); + + verify(redisTemplate).delete(COOLDOWN_KEY); + } + } + + @Nested + @DisplayName("검증") + class Verifying { + + @Test + @DisplayName("일치하면 통과하고 코드를 소모한다") + void acceptsMatchingCode() { + when(valueOperations.get(CODE_KEY)).thenReturn("123456"); + when(valueOperations.increment(ATTEMPT_KEY)).thenReturn(1L); + + assertThat(service.verifyCode(EMAIL, "123456")).isTrue(); + + verify(redisTemplate).delete(CODE_KEY); + verify(redisTemplate).delete(ATTEMPT_KEY); + } + + @Test + @DisplayName("불일치하면 실패하되 코드는 남겨 사용자가 재시도할 수 있다") + void rejectsWrongCodeButKeepsIt() { + when(valueOperations.get(CODE_KEY)).thenReturn("123456"); + when(valueOperations.increment(ATTEMPT_KEY)).thenReturn(2L); + + assertThat(service.verifyCode(EMAIL, "000000")).isFalse(); + + verify(redisTemplate, never()).delete(CODE_KEY); + } + + @Test + @DisplayName("시도 횟수를 넘기면 코드를 폐기한다") + void discardsCodeAfterTooManyAttempts() { + when(valueOperations.get(CODE_KEY)).thenReturn("123456"); + when(valueOperations.increment(ATTEMPT_KEY)).thenReturn(6L); + + // 정답을 넣어도 통과하지 않는다. 무차별 대입 중이라고 봐야 한다. + assertThat(service.verifyCode(EMAIL, "123456")).isFalse(); + + verify(redisTemplate).delete(CODE_KEY); + verify(redisTemplate).delete(ATTEMPT_KEY); + } + + @Test + @DisplayName("발급된 코드가 없으면 시도 횟수도 세지 않는다") + void noCodeMeansNoAttemptCount() { + when(valueOperations.get(CODE_KEY)).thenReturn(null); + + assertThat(service.verifyCode(EMAIL, "123456")).isFalse(); + + verify(valueOperations, never()).increment(anyString()); + } + + @Test + @DisplayName("null 코드를 넣어도 예외 없이 실패한다") + void handlesNullInput() { + when(valueOperations.get(CODE_KEY)).thenReturn("123456"); + when(valueOperations.increment(ATTEMPT_KEY)).thenReturn(1L); + + assertThatCode(() -> assertThat(service.verifyCode(EMAIL, null)).isFalse()) + .doesNotThrowAnyException(); + } + } +} diff --git a/src/test/java/com/carecode/domain/user/service/UserServiceSignUpTest.java b/src/test/java/com/carecode/domain/user/service/UserServiceSignUpTest.java new file mode 100644 index 00000000..136a64de --- /dev/null +++ b/src/test/java/com/carecode/domain/user/service/UserServiceSignUpTest.java @@ -0,0 +1,158 @@ +package com.carecode.domain.user.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.domain.user.dto.response.UserDto; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.client.RestTemplate; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 회원가입에서 서버가 정해야 하는 값에 대한 회귀 테스트. + * + *

{@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 captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + return captor.getValue(); + } + + @ParameterizedTest(name = "role={0} 으로 가입해도 PARENT 로 저장된다") + @ValueSource(strings = {"ADMIN", "CAREGIVER", "GUEST", "USER"}) + @DisplayName("클라이언트가 보낸 role 은 무시된다") + void clientSuppliedRoleIsIgnored(String requestedRole) { + userService.createUser(signupRequest().role(requestedRole).build()); + + assertThat(captureSaved().getRole()).isEqualTo(UserRole.PARENT); + } + + @Test + @DisplayName("role 을 아예 보내지 않아도 가입에 성공한다") + void missingRoleIsFine() { + // 예전에는 UserRole.valueOf(null) 이 터져 500 이 났다. + UserDto created = userService.createUser(signupRequest().build()); + + assertThat(created).isNotNull(); + assertThat(captureSaved().getRole()).isEqualTo(UserRole.PARENT); + } + + @Test + @DisplayName("클라이언트가 provider 를 붙여도 소셜 계정으로 만들어지지 않는다") + void clientSuppliedProviderIsIgnored() { + userService.createUser(signupRequest() + .provider("kakao") + .providerId("999999") + .build()); + + User saved = captureSaved(); + assertThat(saved.getProvider()).isNull(); + assertThat(saved.getProviderId()).isNull(); + } + + @Test + @DisplayName("이메일 인증 여부는 항상 false 로 시작한다") + void emailVerifiedStartsFalse() { + userService.createUser(signupRequest() + .provider("kakao") // 예전에는 이것만으로 인증 완료 처리됐다 + .emailVerified(true) + .build()); + + assertThat(captureSaved().getEmailVerified()).isFalse(); + } + + @Test + @DisplayName("provider 를 붙여 비밀번호를 건너뛸 수 없다") + void passwordIsAlwaysRequired() { + assertThatThrownBy(() -> userService.createUser( + UserDto.builder() + .email("new@example.com") + .name("신규회원") + .provider("kakao") // 예전에는 이 경우 비밀번호 검사를 건너뛰었다 + .build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비밀번호"); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("비밀번호는 해시로 저장된다") + void passwordIsHashed() { + userService.createUser(signupRequest().build()); + + assertThat(captureSaved().getPassword()) + .isEqualTo("$2a$10$encoded") + .isNotEqualTo("secret123"); + } + + @Test + @DisplayName("이미 가입된 이메일이면 거부한다") + void rejectsDuplicateEmail() { + when(userRepository.findByEmail("new@example.com")) + .thenReturn(Optional.of(User.builder().id(9L).email("new@example.com").build())); + + assertThatThrownBy(() -> userService.createUser(signupRequest().build())) + .isInstanceOf(IllegalArgumentException.class); + + verify(userRepository, never()).save(any(User.class)); + } +} diff --git a/src/test/java/com/carecode/integration/AccessControlContractTest.java b/src/test/java/com/carecode/integration/AccessControlContractTest.java index a76c3e14..eb43a211 100644 --- a/src/test/java/com/carecode/integration/AccessControlContractTest.java +++ b/src/test/java/com/carecode/integration/AccessControlContractTest.java @@ -2,6 +2,7 @@ import com.carecode.CareCodeApplication; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; @@ -10,12 +11,16 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; /** * 어떤 경로가 로그인 없이 열려 있고 어떤 경로가 막혀 있어야 하는지를 코드로 고정한다. @@ -69,6 +74,9 @@ class AccessControlContractTest { @Autowired MockMvc mockMvc; + @Autowired + com.carecode.domain.user.service.JwtService jwtService; + /** * 로그인 전에도 보여야 하는 경로. * @@ -114,7 +122,13 @@ void publicPathsDoNotRequireLogin(String path) throws Exception { // 좋아요 "여부" 는 내 상태라 공개 조회와 구분해야 한다 "/health/hospitals/1/like-status", "/notifications", - "/auth/user/profile" + "/auth/user/profile", + // 본인 계정 API. 로그인 없이 열리면 남의 프로필이 그대로 노출된다 + "/users/profile", + "/users/me", + // 관리 API 는 비로그인부터 막힌다 + "/api/admin/users", + "/api/admin/users/statistics" }) void protectedPathsRequireLogin(String path) throws Exception { MvcResult result = mockMvc.perform(get(path)).andReturn(); @@ -123,4 +137,124 @@ void protectedPathsRequireLogin(String path) throws Exception { .as("%s 는 인증을 요구해야 한다", path) .isEqualTo(401); } + + /** + * 가입 흐름에서 로그인 전에 호출되는 POST 경로. + * + *

이 규칙은 오랫동안 존재하지 않는 {@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. {@code server.forward-headers-strategy: framework} — Spring 이 + * {@link ForwardedHeaderFilter} 를 등록한다. 이 필터가 요청을 감싸서 + * {@code getRemoteAddr()} 자체를 XFF 의 클라이언트 주소로 바꿔친다.
  2. + *
  3. {@link ClientIpResolver} 의 {@code app.rate-limit.trust-forwarded-headers} — + * XFF 를 직접 읽을지 말지를 정한다.
  4. + *
+ * + *

중요한 것은 (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"); + } +}