Skip to content
Open
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
28 changes: 28 additions & 0 deletions docs/reference/access-control-matrix.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,34 @@ flowchart TD
**404·403 은 운영 알림을 보내지 않습니다.** 장애가 아니기 때문입니다.
초기에는 봇이 없는 URL 을 긁을 때마다 알림이 울렸고, 그러면 진짜 장애가 소음에 묻힙니다.

## 자동 점검

`EndpointAuthorizationCoverageTest` 가 **컨트롤러 매핑 전수**를 SecurityConfig 의 인가 판단과 대조합니다.

기존 `AccessControlContractTest` 는 경로를 하나씩 적어 확인하는 방식이라,
새 엔드포인트가 생기면 누군가 목록에 추가해야만 검사됩니다. 실제로 그래서 놓쳤습니다.

| 놓친 사례 | 원인 |
|-----------|------|
| `/health/**` 가 `/hospitals/**` 를 삼킴 | 병원 공개 조회가 통째로 401 |
| 이메일 인증 화이트리스트가 `/users/*` | 실제 매핑은 `/auth/*` — 가입 흐름 401 |
| `/health/hospitals/likes` | `/health/hospitals/*` 에 먹혀 개인 목록이 공개 |
| `/community/posts/liked`, `/bookmarked` | 게시글 상세 와일드카드에 먹힘 |

넷 다 **규칙 자체는 멀쩡해 보이는데 매칭 순서 때문에 의도와 다르게 동작**한 경우입니다.

그래서 기본값을 뒤집었습니다.

> 모든 매핑은 인증이 필요하다. 공개는 테스트의 `INTENTIONALLY_PUBLIC` 에 적힌 것만.

목록에 없는 경로가 공개로 열리면 테스트가 깨지고, **무엇이 열렸는지 경로와 컨트롤러 이름을 찍어줍니다.**
반대로 목록에 적어뒀는데 실제로는 막혀 있어도 깨집니다(이메일 인증 사례가 그랬습니다).

실제 요청을 보내지 않고 `AuthorizationFilter` 의 판단만 평가하므로,
DB 변경이나 외부 API 호출 같은 부작용이 없습니다.

매핑을 하나도 읽지 못하면 아무것도 검사하지 않은 채 통과하므로, 훑은 매핑 수의 하한도 함께 확인합니다.

## 경로를 추가할 때

1. SecurityConfig 에 규칙을 넣습니다. **와일드카드보다 구체적인 경로를 먼저** 선언합니다.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,19 +75,30 @@ public ResponseEntity<ErrorResponse> handleResourceNotFoundException(ResourceNot
.body(errorResponse);
}

// BusinessException 처리 (하위 호환성 유지)
/**
* BusinessException 처리 (하위 호환성 유지).
*
* <p>예전에는 여기서 ErrorCode 를 {@code INVALID_INPUT} 으로, 상태를 400 으로 고정했다.
* {@code BusinessException} 은 {@code CareCodeException} 의 하위 타입이라 자기 ErrorCode 와
* HttpStatus 를 이미 들고 있는데, 그 값을 통째로 버린 것이다.
*
* <p>그래서 세션 만료·권한 없음이 전부 400 으로 나갔다.
* 프런트 인터셉터는 <b>401 에서만</b> 토큰을 갱신하고 로그인으로 보내므로,
* 만료된 세션으로 건강기록이나 알림에 접근하면 갱신도 재로그인도 일어나지 않고
* "입력값이 유효하지 않습니다" 만 보였다. (해당 경로 9곳: HealthService 6, NotificationService 2, JwtService 1)
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex, WebRequest request) {
log.warn("BusinessException 발생: {}", ex.getMessage());
log.warn("BusinessException 발생: {} - {}", ex.getErrorCode().getCode(), ex.getMessage());

ErrorResponse errorResponse = ErrorResponse.of(
ErrorCode.INVALID_INPUT,
ex.getErrorCode(),
ex.getMessage(),
request.getDescription(false)
);

return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.status(ex.getHttpStatus())
.body(errorResponse);
}

Expand Down
22 changes: 21 additions & 1 deletion src/main/java/com/carecode/core/security/SecurityConfig.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,7 +146,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers(HttpMethod.POST, "/facilities/*/rating").authenticated()
.requestMatchers("/facilities/*/rating").permitAll()

// 돌봄시설 공공데이터 API (공개 접근)
// 돌봄시설 공공데이터 API — 조회만 공개다.
//
// 동기화는 외부 공공데이터 API 를 페이지 단위로 호출하고 DB 에 쓴다.
// 공개로 두면 누구나 공공데이터 일일 한도를 태우고 DB 를 두드릴 수 있다.
// (이 프로젝트는 "공공데이터 한도 초과" 를 운영 알림으로 잡고 있는데,
// 그 상황을 외부에서 마음대로 만들 수 있는 셈이다.)
// swagger/sync 는 GET 이라 브라우저 접속이나 크롤러만으로도 실행된다.
//
// 같은 기능이 POST /api/admin/public-data/facilities/sync 로 이미 있다.
.requestMatchers("/api/public/care-facilities/sync-all").hasRole("ADMIN")
.requestMatchers("/api/public/care-facilities/swagger/sync").hasRole("ADMIN")
.requestMatchers("/api/public/care-facilities/**").permitAll()

// 병원 조회는 로그인 전에도 보여야 한다. 실제 경로가 /health/hospitals/** 라
Expand All@@ -155,6 +165,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers(HttpMethod.GET, "/health/hospitals/nearby").permitAll()
.requestMatchers(HttpMethod.GET, "/health/hospitals/popular").permitAll()
.requestMatchers(HttpMethod.GET, "/health/hospitals/type/*").permitAll()
// "내가 찜한 병원" 은 개인 목록이다. 경로가 한 세그먼트라 바로 아래
// /health/hospitals/* 와일드카드에 먼저 걸리므로 그보다 앞에 선언해야 한다.
// (병원 상세 /health/hospitals/{id} 와 같은 모양이라 눈에 잘 띄지 않는다.)
.requestMatchers(HttpMethod.GET, "/health/hospitals/likes").authenticated()
.requestMatchers(HttpMethod.GET, "/health/hospitals/*").permitAll()
.requestMatchers(HttpMethod.GET, "/health/hospitals/*/reviews").permitAll()
.requestMatchers(HttpMethod.GET, "/health/hospitals/*/likes").permitAll()
Expand DownExpand Up@@ -182,6 +196,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

// 커뮤니티 API - 조회는 공개, 작성/수정/삭제는 인증 필요
.requestMatchers(HttpMethod.GET, "/community/posts").permitAll() // 게시글 목록 조회
// "내가 좋아요/북마크한 글" 은 개인 목록이다. 경로가 한 세그먼트라
// 아래 게시글 상세 와일드카드에 먼저 걸리므로 그보다 앞에 선언한다.
// (현재는 컨트롤러가 현재 사용자를 다시 확인해 401 을 내지만,
// 나중에 userId 파라미터를 받도록 바뀌면 그대로 남의 목록이 열린다.)
.requestMatchers(HttpMethod.GET, "/community/posts/liked").authenticated()
.requestMatchers(HttpMethod.GET, "/community/posts/bookmarked").authenticated()
.requestMatchers(HttpMethod.GET, "/community/posts/*").permitAll() // 게시글 상세 조회
.requestMatchers(HttpMethod.GET, "/community/search").permitAll() // 게시글 검색
.requestMatchers(HttpMethod.GET, "/community/popular").permitAll() // 인기 게시글
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,10 +133,10 @@ public ResponseEntity<ChatbotFeedbackDtoResponse> processFeedback(
@LogExecutionTime
@Operation(summary = "의도 타입별 메시지 조회")
public ResponseEntity<List<ChatbotChatHistoryDtoResponse>> getMessagesByIntentType(
@Parameter(description = "사용자 ID", required = true) @RequestParam String userId,
@Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId,
@Parameter(description = "의도 타입 (GREETING, QUESTION, COMPLAINT, THANKS, GOODBYE, HEALTH_INFO, UNKNOWN)", required = true) @RequestParam String intentType) {
List<ChatbotChatHistoryDtoResponse> messages = chatbotFacade.getMessagesByIntentType(
userId, com.carecode.domain.chatbot.entity.ChatMessage.IntentType.valueOf(intentType));
currentUserId(), com.carecode.domain.chatbot.entity.ChatMessage.IntentType.valueOf(intentType));
return ResponseEntity.ok(messages);
}

Expand All@@ -145,11 +145,11 @@ public ResponseEntity<List<ChatbotChatHistoryDtoResponse>> getMessagesByIntentTy
@LogExecutionTime
@Operation(summary = "기간별 메시지 조회")
public ResponseEntity<List<ChatbotChatHistoryDtoResponse>> getMessagesByDateRange(
@Parameter(description = "사용자 ID", required = true) @RequestParam String userId,
@Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId,
@Parameter(description = "시작일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String startDate,
@Parameter(description = "종료일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String endDate) {
List<ChatbotChatHistoryDtoResponse> messages = chatbotFacade.getMessagesByDateRange(
userId, java.time.LocalDateTime.parse(startDate), java.time.LocalDateTime.parse(endDate));
currentUserId(), java.time.LocalDateTime.parse(startDate), java.time.LocalDateTime.parse(endDate));
return ResponseEntity.ok(messages);
}

Expand All@@ -158,9 +158,9 @@ public ResponseEntity<List<ChatbotChatHistoryDtoResponse>> getMessagesByDateRang
@LogExecutionTime
@Operation(summary = "도움됨 여부별 메시지 조회")
public ResponseEntity<List<ChatbotChatHistoryDtoResponse>> getMessagesByHelpfulStatus(
@Parameter(description = "사용자 ID", required = true) @RequestParam String userId,
@Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId,
@Parameter(description = "도움됨 여부", required = true) @RequestParam Boolean isHelpful) {
List<ChatbotChatHistoryDtoResponse> messages = chatbotFacade.getMessagesByHelpfulStatus(userId, isHelpful);
List<ChatbotChatHistoryDtoResponse> messages = chatbotFacade.getMessagesByHelpfulStatus(currentUserId(), isHelpful);
return ResponseEntity.ok(messages);
}

Expand All@@ -169,9 +169,9 @@ public ResponseEntity<List<ChatbotChatHistoryDtoResponse>> getMessagesByHelpfulS
@LogExecutionTime
@Operation(summary = "키워드로 메시지 검색")
public ResponseEntity<List<ChatbotChatHistoryDtoResponse>> searchMessagesByKeyword(
@Parameter(description = "사용자 ID", required = true) @RequestParam String userId,
@Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId,
@Parameter(description = "검색 키워드", required = true) @RequestParam String keyword) {
List<ChatbotChatHistoryDtoResponse> messages = chatbotFacade.searchMessagesByKeyword(userId, keyword);
List<ChatbotChatHistoryDtoResponse> messages = chatbotFacade.searchMessagesByKeyword(currentUserId(), keyword);
return ResponseEntity.ok(messages);
}

Expand All@@ -180,10 +180,10 @@ public ResponseEntity<List<ChatbotChatHistoryDtoResponse>> searchMessagesByKeywo
@LogExecutionTime
@Operation(summary = "상태별 세션 조회", description = "특정 상태의 세션 조회")
public ResponseEntity<List<ChatbotSessionDtoResponse>> getSessionsByStatus(
@Parameter(description = "사용자 ID", required = true) @RequestParam String userId,
@Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId,
@Parameter(description = "세션 상태 (ACTIVE, INACTIVE, CLOSED)", required = true) @RequestParam String status) {
List<ChatbotSessionDtoResponse> sessions = chatbotFacade.getSessionsByStatus(
userId, com.carecode.domain.chatbot.entity.ChatSession.SessionStatus.valueOf(status));
currentUserId(), com.carecode.domain.chatbot.entity.ChatSession.SessionStatus.valueOf(status));
return ResponseEntity.ok(sessions);
}

Expand All@@ -192,11 +192,11 @@ public ResponseEntity<List<ChatbotSessionDtoResponse>> getSessionsByStatus(
@LogExecutionTime
@Operation(summary = "기간별 세션 조회", description = "특정 기간의 세션 조회")
public ResponseEntity<List<ChatbotSessionDtoResponse>> getSessionsByDateRange(
@Parameter(description = "사용자 ID", required = true) @RequestParam String userId,
@Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId,
@Parameter(description = "시작일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String startDate,
@Parameter(description = "종료일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String endDate) {
List<ChatbotSessionDtoResponse> sessions = chatbotFacade.getSessionsByDateRange(
userId, java.time.LocalDateTime.parse(startDate), java.time.LocalDateTime.parse(endDate));
currentUserId(), java.time.LocalDateTime.parse(startDate), java.time.LocalDateTime.parse(endDate));
return ResponseEntity.ok(sessions);
}

Expand All@@ -205,10 +205,23 @@ public ResponseEntity<List<ChatbotSessionDtoResponse>> getSessionsByDateRange(
@LogExecutionTime
@Operation(summary = "사용자별 세션 수 조회")
public ResponseEntity<Map<String, Long>> getSessionCountByUser(
@Parameter(description = "사용자 ID", required = true) @RequestParam String userId) {
long count = chatbotFacade.getSessionCountByUser(userId);
@Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId) {
long count = chatbotFacade.getSessionCountByUser(currentUserId());
Map<String, Long> response = new java.util.HashMap<>();
response.put("sessionCount", count);
return ResponseEntity.ok(response);
}
}
/**
* 조회 대상은 언제나 로그인한 본인이다.
*
* <p>아래 조회들은 요청 파라미터의 {@code userId} 를 그대로 파사드에 넘기고 있었다.
* 즉 로그인만 하면 남의 {@code userId} 를 적어 <b>다른 사람의 상담 내역과 세션</b>을
* 그대로 읽을 수 있었다. 챗봇 대화에는 아이 건강·가정 사정이 담긴다.
*
* <p>파라미터는 기존 클라이언트 호환을 위해 남겨 두되 사용하지 않는다.
* HealthController·NotificationController 가 같은 방식으로 처리한다.
*/
private String currentUserId() {
return currentUserFacade.requireCurrentUserId();
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
package com.carecode.core.handler;

import com.carecode.core.exception.BusinessException;
import com.carecode.core.exception.ErrorCode;
import com.carecode.core.ops.OperationalAlerter;
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.CsvSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.ResponseEntity;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.mock.web.MockHttpServletRequest;

import static org.assertj.core.api.Assertions.assertThat;

/**
* BusinessException 이 자기 ErrorCode 의 상태 코드로 응답하는지 고정한다.
*
* <p>예전 핸들러는 ErrorCode 를 {@code INVALID_INPUT} 으로, 상태를 400 으로 못 박았다.
* {@code BusinessException} 은 {@code CareCodeException} 의 하위 타입이라 자기 ErrorCode 와
* HttpStatus 를 이미 들고 있는데 그 값을 통째로 버린 것이다.
*
* <p>영향이 큰 쪽은 인증이다. 프런트 인터셉터는 <b>401 에서만</b> 토큰을 갱신하고
* 로그인으로 보낸다(`src/apis/interceptor.ts`). 세션 만료가 400 으로 나가면
* 갱신도 재로그인도 일어나지 않고 "입력값이 유효하지 않습니다" 만 보인다.
* 실제로 그런 경로가 9곳 있었다 — HealthService 6, NotificationService 2, JwtService 1.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("전역 예외 핸들러 - BusinessException 상태 코드")
class BusinessExceptionStatusTest {

@Mock private OperationalAlerter alerter;

private ResponseEntity<ErrorResponse> handle(BusinessException ex) {
CustomizedResponseEntityExceptionHandler handler =
new CustomizedResponseEntityExceptionHandler(alerter);
return handler.handleBusinessException(ex, new ServletWebRequest(new MockHttpServletRequest()));
}

@ParameterizedTest(name = "{0} → {1}")
@CsvSource({
"UNAUTHORIZED, 401",
"FORBIDDEN, 403",
"INVALID_INPUT, 400"
})
@DisplayName("ErrorCode 가 정한 상태 코드로 응답한다")
void usesErrorCodeStatus(ErrorCode errorCode, int expectedStatus) {
ResponseEntity<ErrorResponse> response =
handle(new BusinessException(errorCode, "메시지"));

assertThat(response.getStatusCode().value()).isEqualTo(expectedStatus);
}

@Test
@DisplayName("세션 만료는 401 이어야 프런트가 토큰을 갱신한다")
void expiredSessionIsUnauthorized() {
// JwtService.refreshTokens 가 던지는 것과 같은 예외다.
ResponseEntity<ErrorResponse> response =
handle(new BusinessException(ErrorCode.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."));

assertThat(response.getStatusCode().value())
.as("400 이면 프런트 인터셉터가 갱신도 로그인 리다이렉트도 하지 않는다")
.isEqualTo(401);
}

@Test
@DisplayName("권한 없음은 403 이어야 입력 오류와 구분된다")
void forbiddenIsNotBadRequest() {
// HealthService 의 "해당 건강 기록에 접근할 권한이 없습니다" 와 같은 예외다.
ResponseEntity<ErrorResponse> response =
handle(new BusinessException(ErrorCode.FORBIDDEN, "해당 건강 기록에 접근할 권한이 없습니다."));

assertThat(response.getStatusCode().value()).isEqualTo(403);
}

@Test
@DisplayName("메시지 없는 생성자는 기존대로 400 이다")
void legacyConstructorStaysBadRequest() {
// BusinessException(String) 은 ErrorCode.INVALID_INPUT 을 쓴다. 기존 동작이 바뀌면 안 된다.
assertThat(handle(new BusinessException("입력이 잘못됐습니다")).getStatusCode().value())
.isEqualTo(400);
}

@Test
@DisplayName("응답 본문의 코드도 ErrorCode 를 따른다")
void bodyCarriesErrorCode() {
ResponseEntity<ErrorResponse> response =
handle(new BusinessException(ErrorCode.FORBIDDEN, "권한 없음"));

assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getCode()).isEqualTo(ErrorCode.FORBIDDEN.getCode());
}
}
Loading
Loading