Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions build.gradle
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand Down
62 changes: 61 additions & 1 deletion docs/reference/access-control-matrix.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` | 메일로 받은 인증 링크 |

### 지원금

Expand DownExpand Up@@ -130,6 +131,7 @@ flowchart TD
| 경로 | 비고 |
|------|------|
| `/auth/user/**`, `/auth/logout` | — |
| `/users/**` | **본인 계정 전용.** 경로 변수가 있는 구 경로는 서비스 진입 전에 본인인지 확인한다 |
| `/users/privacy/**` | 열람·동의·탈퇴 |
| `/children/**` | 자녀 정보 |
| `/notifications/**` | — |
Expand DownExpand Up@@ -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 |
Expand Down
101 changes: 78 additions & 23 deletions src/main/java/com/carecode/core/RateLimitInterceptor.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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.
*
* <ul>
* <li>인증된 사용자: userId 기반 분당 300회 (NAT/공유 IP 환경 대응)</li>
* <li>미인증 요청: IP 기반 분당 120회</li>
* <li>민감 엔드포인트(로그인·가입·인증코드): IP 기반 분당 30회</li>
* </ul>
*
* <p>여기는 어디까지나 하한선이다. 호출 한 건이 비용이 되는 API(챗봇의 LLM 호출)나
* 무차별 대입 대상(로그인, 인증코드 검증)은 이 값으로 부족하므로
* {@code @RateLimit} 으로 엔드포인트별 상한을 따로 건다.
*/
@Component
@Slf4j
@RequiredArgsConstructor
Expand All@@ -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);

/**
* 낮은 한도를 적용할 공개 엔드포인트.
*
* <p>예전에는 {@code /api/v1/contact}, {@code /api/v1/auth/signup} 을 보고 있었다.
* 이 애플리케이션에는 {@code /api/v1} 로 매핑된 컨트롤러가 하나도 없어서
* (BaseController 의 {@code @RequestMapping("/api/v1")} 은 하위 클래스가 전부 덮어쓴다)
* 민감 엔드포인트 등급이 한 번도 적용된 적이 없었다.
*/
private static final List<String> 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();
Expand All@@ -51,32 +80,60 @@ 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");
response.getWriter().write("{\"success\":false,\"message\":\"요청이 너무 많습니다. 잠시 후 다시 시도해주세요.\",\"errorCode\":\"RATE_LIMIT_EXCEEDED\"}");
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.
*
* <p>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. */
Expand All@@ -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);
}
}

31 changes: 30 additions & 1 deletion src/main/java/com/carecode/core/aspect/RateLimitingAspect.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -54,20 +57,46 @@ public Object rateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) thro
return joinPoint.proceed();
}

/**
* 호출자별 카운터 키를 만든다.
*
* <p>로그인한 요청은 IP 가 아니라 계정으로 센다. IP 로만 세면 (1) 같은 회사·학교·통신사 NAT
* 뒤의 사용자들이 한도를 나눠 쓰게 되고, (2) 챗봇처럼 계정 단위로 비용이 나가는 API 에서
* 한 사람이 IP 만 바꿔가며 한도를 초과할 수 있다.
*
* <p>비로그인 요청(로그인·회원가입·인증코드 발송)은 계정이 없으므로 IP 로 센다.
*/
private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
String methodName = joinPoint.getSignature().toShortString();

if (!rateLimit.perUser()) {
return methodName;
}

String principal = currentPrincipal();
if (principal != null) {
return methodName + ":user:" + principal;
}

ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return methodName;
}

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;
}
}
45 changes: 45 additions & 0 deletions src/main/java/com/carecode/core/security/CurrentUserFacade.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,13 +4,15 @@
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;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

/** Resolves the authenticated user from SecurityContextHolder and the persistence layer */
@Slf4j
@Component
@RequiredArgsConstructor
public class CurrentUserFacade {
Expand DownExpand Up@@ -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()));
}

/**
* 경로에 실린 사용자 식별자가 로그인한 본인인지 확인하고, 본인이면 엔티티를 돌려준다.
*
* <p>경로 변수는 발급된 {@code userId}(문자열)일 수도 있고 DB PK 일 수도 있다.
* 서비스 계층이 두 형태를 모두 받아 조회하므로 검증도 두 형태를 모두 인정한다.
*
* <p>남의 식별자를 넣었을 때 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()));
}
}
Loading
Loading