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/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 +} 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 서버 오류가 발생했습니다"), 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, ) 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() } 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, + ) + } +} 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) + } + } +} 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, +)