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
Original file line number Diff line number Diff line change
@@ -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 ""}"
)
}

Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 서버 오류가 발생했습니다"),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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"]

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package hs.kr.entrydsm.application.domain.application.presentation.dto.request

data class ApplicationSubmissionRequest(
val userId: String,
val application: Map<String, Any>,
val scores: Map<String, Any>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,7 +41,7 @@ class CompleteApplicationUseCase(

val applicationEntity =
applicationPersistenceService.saveApplication(
userId = UUID.fromString(request.userId),
userId = userId,
applicationData = request.application,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 보안 설정 및 경로별 접근 권한을 정의합니다.
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DefaultSecurityFilterChain, HttpSecurity>() {

override fun configure(builder: HttpSecurity) {
builder.addFilterBefore(
JwtFilter(),
UsernamePasswordAuthenticationFilter::class.java,
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Loading