From ffd20cefb2ccd80e751904558d23f4107b602224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Thu, 18 Sep 2025 18:55:41 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat=20(=20#49=20)=20:=20Security=20excepti?= =?UTF-8?q?on=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/exceptions/SecurityException.kt | 36 +++++++++++++++++++ .../kr/entrydsm/global/exception/ErrorCode.kt | 4 +++ 2 files changed, 40 insertions(+) create mode 100644 casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/security/exceptions/SecurityException.kt diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/security/exceptions/SecurityException.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/security/exceptions/SecurityException.kt new file mode 100644 index 00000000..4013fcc7 --- /dev/null +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/security/exceptions/SecurityException.kt @@ -0,0 +1,36 @@ +package hs.kr.entrydsm.domain.security.exceptions + +import hs.kr.entrydsm.global.exception.DomainException +import hs.kr.entrydsm.global.exception.ErrorCode + +/** + * 보안 관련 최상위 예외 클래스입니다. + * + * 인증 및 인가와 관련된 도메인 예외를 정의합니다. + */ +sealed class SecurityException( + errorCode: ErrorCode, + message: String +) : DomainException(errorCode, message) { + + /** + * 유효하지 않은 토큰일 경우 발생하는 예외입니다. + */ + class InvalidTokenException( + token: String? = null + ) : SecurityException( + errorCode = ErrorCode.SECURITY_INVALID_TOKEN, + message = "Invalid authentication token${if (token != null) ": $token" else ""}" + ) + + /** + * 인증되지 않은 사용자일 경우 발생하는 예외입니다. + */ + class UnauthenticatedException( + context: String? = null + ) : SecurityException( + errorCode = ErrorCode.SECURITY_UNAUTHENTICATED, + message = "User is not authenticated${if (context != null) ": $context" else ""}" + ) +} + diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/global/exception/ErrorCode.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/global/exception/ErrorCode.kt index c804abde..a72149ac 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/global/exception/ErrorCode.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/global/exception/ErrorCode.kt @@ -395,6 +395,10 @@ enum class ErrorCode(val code: String, val description: String) { // School 도메인 오류 (SCH) SCHOOL_INVALID_TYPE("SCH001", "유효하지 않은 학교 유형입니다"), + // Security 도메인 오류 (SEC) + SECURITY_INVALID_TOKEN("SEC001", "유효하지 않은 인증 토큰입니다"), + SECURITY_UNAUTHENTICATED("SEC002", "인증되지 않은 사용자입니다"), + //feign error FEIGN_SERVER_ERROR("FGN001", "외부 API 서버 오류가 발생했습니다"), From 90bf58a396fca777b0d7c397fffaacefba6e71fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Thu, 18 Sep 2025 18:55:57 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat=20(=20#49=20)=20:=20Contract=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/interfaces/SecurityContract.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/security/interfaces/SecurityContract.kt diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/security/interfaces/SecurityContract.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/security/interfaces/SecurityContract.kt new file mode 100644 index 00000000..0817ff5d --- /dev/null +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/security/interfaces/SecurityContract.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.domain.security.interfaces + +import java.util.UUID + +/** + * 보안 관련 기능을 제공하는 계약(Contract)입니다. + * + * 현재 인증된 사용자의 정보를 조회하는 기능을 제공합니다. + */ +interface SecurityContract { + + /** + * 현재 인증된 사용자의 ID를 반환합니다. + * + * @return 현재 사용자 ID + * @throws SecurityException 인증 정보가 없거나 유효하지 않은 경우 + */ + fun getCurrentUserId(): UUID +} From f7e5e71467cac56386957409cacd7ad8d3ad872b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Thu, 18 Sep 2025 18:56:10 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat=20(=20#49=20)=20:=20FilterConfig=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/FilterConfig.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/FilterConfig.kt diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/FilterConfig.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/FilterConfig.kt new file mode 100644 index 00000000..eab399d2 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/FilterConfig.kt @@ -0,0 +1,23 @@ +package hs.kr.entrydsm.application.global.security + +import hs.kr.entrydsm.application.global.security.jwt.JwtFilter +import org.springframework.security.config.annotation.SecurityConfigurerAdapter +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.stereotype.Component + +/** + * 시큐리티 필터 체인 설정을 담당하는 클래스입니다. + * JWT 필터를 Spring Security 필터 체인에 등록합니다. + */ +@Component +class FilterConfig : SecurityConfigurerAdapter() { + + override fun configure(builder: HttpSecurity) { + builder.addFilterBefore( + JwtFilter(), + UsernamePasswordAuthenticationFilter::class.java, + ) + } +} From 9ceae4abfba0a6c3c307be72549b20939429fda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Thu, 18 Sep 2025 18:56:24 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat=20(=20#49=20)=20:=20Jwt=20Filter=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/jwt/JwtFilter.kt | 52 +++++++++++++++++++ .../global/security/jwt/JwtProperties.kt | 10 ++++ 2 files changed, 62 insertions(+) create mode 100644 casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/jwt/JwtFilter.kt create mode 100644 casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/jwt/JwtProperties.kt diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/jwt/JwtFilter.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/jwt/JwtFilter.kt new file mode 100644 index 00000000..d9f46dd1 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/jwt/JwtFilter.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.application.global.security.jwt + +import hs.kr.entrydsm.domain.user.value.UserRole +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.web.filter.OncePerRequestFilter + +/** + * JWT 인증을 처리하는 필터입니다. + * + * Gateway에서 JWT를 파싱하여 헤더로 전달받은 사용자 정보를 + * Spring Security Context에 설정합니다. + */ +class JwtFilter : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val userId: String? = request.getHeader("Request-User-Id") + val role: UserRole? = request.getHeader("Request-User-Role")?.let { + try { + UserRole.valueOf(it) + } catch (e: IllegalArgumentException) { + null + } + } + + if (userId == null || role == null) { + filterChain.doFilter(request, response) + return + } + + val authorities = mutableListOf(SimpleGrantedAuthority("ROLE_${role.name}")) + val userDetails: UserDetails = User(userId, "", authorities) + val authentication: Authentication = + UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities) + + SecurityContextHolder.clearContext() + SecurityContextHolder.getContext().authentication = authentication + + filterChain.doFilter(request, response) + } +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/jwt/JwtProperties.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/jwt/JwtProperties.kt new file mode 100644 index 00000000..d3fe71bd --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/jwt/JwtProperties.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.application.global.security.jwt + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("auth.jwt") +data class JwtProperties( + val secretKey: String, + val header: String, + val prefix: String, +) From a4370bcec0503a85497c0f7e10adb54f89fa2046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Thu, 18 Sep 2025 18:56:54 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat=20(=20#49=20)=20:=20Security=20Adapte?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/SecurityAdapter.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/SecurityAdapter.kt diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/SecurityAdapter.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/SecurityAdapter.kt new file mode 100644 index 00000000..ce425f60 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/security/SecurityAdapter.kt @@ -0,0 +1,31 @@ +package hs.kr.entrydsm.application.global.security + +import hs.kr.entrydsm.domain.security.exceptions.SecurityException +import hs.kr.entrydsm.domain.security.interfaces.SecurityContract +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * Spring Security와 도메인 계층을 연결하는 어댑터입니다. + * + * SecurityContract의 구현체로, Spring Security Context에서 + * 현재 인증된 사용자 정보를 추출하여 도메인 계층에 제공합니다. + */ +@Component +class SecurityAdapter : SecurityContract { + + override fun getCurrentUserId(): UUID { + val authentication = SecurityContextHolder.getContext().authentication + ?: throw SecurityException.UnauthenticatedException("인증 컨텍스트가 존재하지 않습니다") + + val userId = authentication.name + ?: throw SecurityException.UnauthenticatedException("사용자 정보가 존재하지 않습니다") + + try { + return UUID.fromString(userId) + } catch (e: IllegalArgumentException) { + throw SecurityException.InvalidTokenException(userId) + } + } +} From 8498a0cadac04ffb2dc735cd63d96a664fb780de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Thu, 18 Sep 2025 18:58:06 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor=20(=20#49=20)=20:=20userId=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApplicationSubmissionController.kt | 15 +++++---------- .../dto/request/ApplicationSubmissionRequest.kt | 1 - .../usecase/CompleteApplicationUseCase.kt | 4 ++-- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/presentation/ApplicationSubmissionController.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/presentation/ApplicationSubmissionController.kt index be0eef0e..a6673733 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/presentation/ApplicationSubmissionController.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/presentation/ApplicationSubmissionController.kt @@ -4,6 +4,7 @@ import hs.kr.entrydsm.application.domain.application.presentation.dto.request.Ap import hs.kr.entrydsm.application.domain.application.presentation.dto.response.ApplicationSubmissionResponse import hs.kr.entrydsm.application.domain.application.usecase.CompleteApplicationUseCase import hs.kr.entrydsm.application.global.document.application.ApplicationSubmissionApiDocument +import hs.kr.entrydsm.domain.security.interfaces.SecurityContract import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -16,6 +17,7 @@ import java.time.LocalDateTime @RequestMapping("/api/v1") class ApplicationSubmissionController( private val completeApplicationUseCase: CompleteApplicationUseCase, + private val securityContract: SecurityContract, ) : ApplicationSubmissionApiDocument { @PostMapping("/applications") override fun submitApplication( @@ -26,9 +28,8 @@ class ApplicationSubmissionController( return createErrorResponse("요청 데이터가 없습니다", HttpStatus.BAD_REQUEST) } - if (request.userId.isBlank()) { - return createErrorResponse("사용자 ID가 필요합니다", HttpStatus.BAD_REQUEST) - } + // SecurityContract를 통해 현재 사용자 ID 추출 + val userId = securityContract.getCurrentUserId() if (request.application.isEmpty()) { return createErrorResponse("원서 정보가 필요합니다", HttpStatus.BAD_REQUEST) @@ -38,12 +39,6 @@ class ApplicationSubmissionController( return createErrorResponse("성적 정보가 필요합니다", HttpStatus.BAD_REQUEST) } - try { - java.util.UUID.fromString(request.userId) - } catch (e: IllegalArgumentException) { - return createErrorResponse("올바르지 않은 사용자 ID 형식입니다", HttpStatus.BAD_REQUEST) - } - val applicationType = request.application["applicationType"] val educationalStatus = request.application["educationalStatus"] @@ -55,7 +50,7 @@ class ApplicationSubmissionController( return createErrorResponse("학력 상태가 필요합니다", HttpStatus.BAD_REQUEST) } - val response = completeApplicationUseCase.execute(request) + val response = completeApplicationUseCase.execute(userId, request) ResponseEntity.status(HttpStatus.CREATED).body(response) } catch (e: IllegalArgumentException) { createErrorResponse(e.message ?: "잘못된 요청 파라미터입니다", HttpStatus.BAD_REQUEST) diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/presentation/dto/request/ApplicationSubmissionRequest.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/presentation/dto/request/ApplicationSubmissionRequest.kt index aba73b7d..25f5b69e 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/presentation/dto/request/ApplicationSubmissionRequest.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/presentation/dto/request/ApplicationSubmissionRequest.kt @@ -1,7 +1,6 @@ package hs.kr.entrydsm.application.domain.application.presentation.dto.request data class ApplicationSubmissionRequest( - val userId: String, val application: Map, val scores: Map, ) diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/usecase/CompleteApplicationUseCase.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/usecase/CompleteApplicationUseCase.kt index 72999e60..ca77b573 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/usecase/CompleteApplicationUseCase.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/usecase/CompleteApplicationUseCase.kt @@ -18,7 +18,7 @@ class CompleteApplicationUseCase( private val calculator: Calculator, private val applicationPersistenceService: ApplicationPersistenceService, ) { - fun execute(request: ApplicationSubmissionRequest): ApplicationSubmissionResponse { + fun execute(userId: UUID, request: ApplicationSubmissionRequest): ApplicationSubmissionResponse { val applicationType = request.application["applicationType"] as String val educationalStatus = request.application["educationalStatus"] as String val region = request.application["region"] as? String @@ -41,7 +41,7 @@ class CompleteApplicationUseCase( val applicationEntity = applicationPersistenceService.saveApplication( - userId = UUID.fromString(request.userId), + userId = userId, applicationData = request.application, ) From 366ef1a205922a17043c2bb4e8db23133df1b07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Thu, 18 Sep 2025 18:58:23 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor=20(=20#49=20)=20:=20SecurityConfig?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/global/config/SecurityConfig.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/config/SecurityConfig.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/config/SecurityConfig.kt index 3161880e..e5b749c6 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/config/SecurityConfig.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/config/SecurityConfig.kt @@ -1,5 +1,9 @@ package hs.kr.entrydsm.application.global.config +import hs.kr.entrydsm.application.global.security.FilterConfig +import hs.kr.entrydsm.application.global.security.jwt.JwtProperties +import hs.kr.entrydsm.domain.user.value.UserRole +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.config.annotation.web.builders.HttpSecurity @@ -11,7 +15,10 @@ import org.springframework.security.web.SecurityFilterChain * 애플리케이션의 보안 정책과 인증/인가 규칙을 정의합니다. */ @Configuration -class SecurityConfig { +@EnableConfigurationProperties(JwtProperties::class) +class SecurityConfig( + private val filterConfig: FilterConfig, +) { /** * Spring Security 필터 체인을 구성합니다. * HTTP 보안 설정 및 경로별 접근 권한을 정의합니다. @@ -35,8 +42,11 @@ class SecurityConfig { .requestMatchers("/v3/api-docs/**").permitAll() .requestMatchers("/swagger-resources/**").permitAll() .requestMatchers("/webjars/**").permitAll() + .requestMatchers("/admin/**").hasRole(UserRole.ADMIN.name) + .requestMatchers("/api/v1/applications/**").hasRole(UserRole.USER.name) .anyRequest().authenticated() } + .apply(filterConfig) return http.build() }