diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 032ab4c..409dcb5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "mcp__jetbrains-file__EditFile" + "mcp__jetbrains-file__EditFile", + "mcp__jetbrains-file__WriteFile" ] } } diff --git a/src/main/java/com/alist/api/ApiApplication.java b/src/main/java/com/alist/api/ApiApplication.java index 71db605..222e71e 100644 --- a/src/main/java/com/alist/api/ApiApplication.java +++ b/src/main/java/com/alist/api/ApiApplication.java @@ -3,11 +3,9 @@ package com.alist.api; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication @ConfigurationPropertiesScan -@EnableCaching public class ApiApplication { public static void main(String[] args) { diff --git a/src/main/java/com/alist/api/config/cache/CorsAllowedOriginsCache.java b/src/main/java/com/alist/api/config/cache/CorsAllowedOriginsCache.java new file mode 100644 index 0000000..f2f908c --- /dev/null +++ b/src/main/java/com/alist/api/config/cache/CorsAllowedOriginsCache.java @@ -0,0 +1,104 @@ +package com.alist.api.config.cache; + +import com.alist.api.modules.auth.vo.TestCorsOriginVo; +import com.alist.api.modules.auth.mapper.TestCorsMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CorsAllowedOriginsCache { + + private final TestCorsMapper testCorsMapper; + + // Thread-safe Map + private Map> corsMap = new ConcurrentHashMap<>(); + + /** + * 서버 시작 시 DB에서 전체 CORS 목록 로딩 + */ + @PostConstruct + public void loadFromDatabase() { + refresh(); + log.info("CORS allowed origins loaded from database"); + } + + /** + * DB에서 전체 CORS 목록 다시 로딩 + */ + public synchronized void refresh() { + try { + List allCors = testCorsMapper.selectTestCorsAllowedList(); + + Map> newMap = new HashMap<>(); + + for (TestCorsOriginVo cors : allCors) { + Integer userTokenIdx = cors.getUserTokenIdx(); + String allowedOrigin = cors.getAllowedOrigin(); + + newMap.computeIfAbsent(userTokenIdx, k -> new ArrayList<>()) + .add(allowedOrigin); + } + + // 전체 교체 + this.corsMap = new ConcurrentHashMap<>(newMap); + + log.info("CORS cache refreshed. Total tokens: {}, Total origins: {}", + corsMap.size(), + corsMap.values().stream().mapToInt(List::size).sum()); + + } catch (Exception e) { + log.error("Failed to refresh CORS cache", e); + } + } + + /** + * user_token_idx 기반 허용 도메인 목록 조회 + * @param userTokenIdx user_token_idx (null이면 기본값 0만 조회) + * @return 허용 도메인 리스트 + */ + public List getAllowedOrigins(Integer userTokenIdx) { + List result = new ArrayList<>(); + + // 기본값 (0) 추가 + List defaultOrigins = corsMap.get(0); + if (defaultOrigins != null) { + result.addAll(defaultOrigins); + } + + // 특정 토큰값 추가 + if (userTokenIdx != null && userTokenIdx != 0) { + List tokenOrigins = corsMap.get(userTokenIdx); + if (tokenOrigins != null) { + result.addAll(tokenOrigins); + } + } + + return result; + } + + /** + * 현재 캐시 상태 조회 + * @return 캐시 상태 정보 (토큰별 도메인 목록) + */ + public Map getCacheStatus() { + Map status = new HashMap<>(); + + int totalOrigins = corsMap.values().stream().mapToInt(List::size).sum(); + + status.put("totalTokens", corsMap.size()); + status.put("totalOrigins", totalOrigins); + status.put("details", new HashMap<>(corsMap)); + + return status; + } +} diff --git a/src/main/java/com/alist/api/config/filter/DynamicCorsFilter.java b/src/main/java/com/alist/api/config/filter/DynamicCorsFilter.java index e9f36a9..065025f 100644 --- a/src/main/java/com/alist/api/config/filter/DynamicCorsFilter.java +++ b/src/main/java/com/alist/api/config/filter/DynamicCorsFilter.java @@ -1,10 +1,12 @@ package com.alist.api.config.filter; + +import com.alist.api.config.cache.CorsAllowedOriginsCache; import com.alist.api.config.jwt.JwtTokenProvider; -import com.alist.api.modules.auth.mapper.TestCorsMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; @@ -17,14 +19,11 @@ import java.util.List; @Slf4j @Component @Order(Ordered.HIGHEST_PRECEDENCE) +@RequiredArgsConstructor public class DynamicCorsFilter extends OncePerRequestFilter { - private final TestCorsMapper testCorsMapper; - private final JwtTokenProvider jwtTokenProvider; - public DynamicCorsFilter(TestCorsMapper testCorsMapper, JwtTokenProvider jwtTokenProvider) { - this.testCorsMapper = testCorsMapper; - this.jwtTokenProvider = jwtTokenProvider; - } + private final CorsAllowedOriginsCache corsCache; + private final JwtTokenProvider jwtTokenProvider; @Override protected void doFilterInternal(HttpServletRequest request, @@ -34,25 +33,39 @@ public class DynamicCorsFilter extends OncePerRequestFilter { String origin = request.getHeader("Origin"); Integer userTokenIdx = extractUserTokenIdx(request); - // DB에서 허용 도메인 조회 - List allowedOrigins = testCorsMapper.selectTestCorsAllowedList(userTokenIdx); + // 메모리에서 허용 도메인 조회 (DB 조회 없음!) + List allowedOrigins = corsCache.getAllowedOrigins(userTokenIdx); + + // Origin 허용 여부 체크 + boolean isAllowed = false; + if (origin != null) { + // 1. 정확히 일치하는 경우 + if (allowedOrigins.contains("*") || allowedOrigins.contains(origin)) { + isAllowed = true; + } + // 2. localhost는 모든 포트 허용 + else if ((origin.startsWith("http://localhost:") || origin.equals("http://localhost")) + && allowedOrigins.contains("http://localhost")) { + isAllowed = true; + } + else if ((origin.startsWith("https://localhost:") || origin.equals("https://localhost")) + && allowedOrigins.contains("https://localhost")) { + isAllowed = true; + } + } // CORS 체크 결과 로깅 if (origin != null) { - boolean isAllowed = allowedOrigins.contains("*") || allowedOrigins.contains(origin); - if (isAllowed && log.isDebugEnabled()) { - // 허용된 경우 DEBUG 레벨 log.debug("CORS allowed - Origin: {}, UserTokenIdx: {}", origin, userTokenIdx); } else if (!isAllowed) { - // 차단된 경우 WARN 레벨 (보안 모니터링) log.warn("CORS blocked - Origin: {}, UserTokenIdx: {}, AllowedOrigins: {}", origin, userTokenIdx, allowedOrigins); } } - // Origin이 허용 목록에 있거나 "*"가 있으면 CORS 헤더 설정 - if (origin != null && (allowedOrigins.contains("*") || allowedOrigins.contains(origin))) { + // CORS 헤더 설정 + if (isAllowed) { response.setHeader("Access-Control-Allow-Origin", origin); response.setHeader("Access-Control-Allow-Credentials", "true"); response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"); diff --git a/src/main/java/com/alist/api/modules/auth/TestAuthController.java b/src/main/java/com/alist/api/modules/auth/TestAuthController.java index d3a1bc6..4fcffae 100644 --- a/src/main/java/com/alist/api/modules/auth/TestAuthController.java +++ b/src/main/java/com/alist/api/modules/auth/TestAuthController.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "98. 테스트 인증", description = "테스트 사용자 로그인 및 API Key 인증 관련 API") +@Tag(name = "01. 테스트 인증", description = "테스트 사용자 로그인 및 API Key 인증 관련 API") @RestController @RequestMapping("/test") public class TestAuthController { diff --git a/src/main/java/com/alist/api/modules/auth/TestCorsAdminController.java b/src/main/java/com/alist/api/modules/auth/TestCorsAdminController.java new file mode 100644 index 0000000..a5f7725 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/TestCorsAdminController.java @@ -0,0 +1,32 @@ +package com.alist.api.modules.auth; + +import com.alist.api.common.response.ApiResponse; +import com.alist.api.common.response.ApiResponseCode; +import com.alist.api.config.cache.CorsAllowedOriginsCache; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "03. 테스트 CRS 갱신", description = "테스트 CORS 설정 관리 API") +@RestController +@RequestMapping("/test") +@RequiredArgsConstructor +public class TestCorsAdminController { + + private final CorsAllowedOriginsCache corsCache; + + @Operation( + summary = "CORS 화이트리스트 갱신", + description = "DB에서 CORS 허용 도메인 목록을 다시 로딩합니다. DB에 도메인 추가 후 호출하세요." + ) + @PostMapping("/reloadCors") + public ResponseEntity>> reloadCors() { + corsCache.refresh(); + java.util.Map cacheStatus = corsCache.getCacheStatus(); + return ApiResponse.entity(cacheStatus, ApiResponseCode.CODE_200); + } +} diff --git a/src/main/java/com/alist/api/modules/auth/mapper/TestCorsMapper.java b/src/main/java/com/alist/api/modules/auth/mapper/TestCorsMapper.java index d701037..0a090f8 100644 --- a/src/main/java/com/alist/api/modules/auth/mapper/TestCorsMapper.java +++ b/src/main/java/com/alist/api/modules/auth/mapper/TestCorsMapper.java @@ -1,12 +1,15 @@ package com.alist.api.modules.auth.mapper; +import com.alist.api.modules.auth.vo.TestCorsOriginVo; import org.apache.ibatis.annotations.Mapper; -import org.springframework.cache.annotation.Cacheable; import java.util.List; @Mapper public interface TestCorsMapper { - @Cacheable(value = "corsAllowedOrigins", key = "#userTokenIdx ?: 0") - List selectTestCorsAllowedList(Integer userTokenIdx); + /** + * 전체 CORS 허용 도메인 목록 조회 (user_token_idx별로) + * @return CorsOriginDto 리스트 + */ + List selectTestCorsAllowedList(); } diff --git a/src/main/java/com/alist/api/modules/auth/vo/TestCorsOriginVo.java b/src/main/java/com/alist/api/modules/auth/vo/TestCorsOriginVo.java new file mode 100644 index 0000000..c27cb70 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/vo/TestCorsOriginVo.java @@ -0,0 +1,11 @@ +package com.alist.api.modules.auth.vo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class TestCorsOriginVo { + private Integer userTokenIdx; + private String allowedOrigin; +} diff --git a/src/main/java/com/alist/api/modules/testUser/TestUserController.java b/src/main/java/com/alist/api/modules/testUser/TestUserController.java index 4d64169..7bc300e 100644 --- a/src/main/java/com/alist/api/modules/testUser/TestUserController.java +++ b/src/main/java/com/alist/api/modules/testUser/TestUserController.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "99. 테스트 사용자 관리", description = "테스트 사용자 회원가입 및 관리 API") +@Tag(name = "02. 테스트 사용자 관리", description = "테스트 사용자 회원가입 및 관리 API") @Slf4j @RestController @RequestMapping("/test") diff --git a/src/main/resources/mapper/auth/TestCorsMapper.xml b/src/main/resources/mapper/auth/TestCorsMapper.xml index fd48cfb..b87b7cc 100644 --- a/src/main/resources/mapper/auth/TestCorsMapper.xml +++ b/src/main/resources/mapper/auth/TestCorsMapper.xml @@ -4,17 +4,13 @@ - + /* TestCorsMapper.selectTestCorsAllowedList */ + SELECT user_token_idx + , allowed_origin FROM test_cors_allowed_list WHERE del_yn = 1 - AND ( - user_token_idx = 0 - - OR user_token_idx = #{userTokenIdx} - - ) + ORDER BY user_token_idx, cors_idx \ No newline at end of file