Skip to content

Commit 45e9855

Browse files
authored
Merge pull request #50 from EntryDSM/feature/49-userId
feature/49-userId
2 parents a6bca72 + 366ef1a commit 45e9855

11 files changed

Lines changed: 193 additions & 14 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package hs.kr.entrydsm.domain.security.exceptions
2+
3+
import hs.kr.entrydsm.global.exception.DomainException
4+
import hs.kr.entrydsm.global.exception.ErrorCode
5+
6+
/**
7+
* 보안 관련 최상위 예외 클래스입니다.
8+
*
9+
* 인증 및 인가와 관련된 도메인 예외를 정의합니다.
10+
*/
11+
sealed class SecurityException(
12+
errorCode: ErrorCode,
13+
message: String
14+
) : DomainException(errorCode, message) {
15+
16+
/**
17+
* 유효하지 않은 토큰일 경우 발생하는 예외입니다.
18+
*/
19+
class InvalidTokenException(
20+
token: String? = null
21+
) : SecurityException(
22+
errorCode = ErrorCode.SECURITY_INVALID_TOKEN,
23+
message = "Invalid authentication token${if (token != null) ": $token" else ""}"
24+
)
25+
26+
/**
27+
* 인증되지 않은 사용자일 경우 발생하는 예외입니다.
28+
*/
29+
class UnauthenticatedException(
30+
context: String? = null
31+
) : SecurityException(
32+
errorCode = ErrorCode.SECURITY_UNAUTHENTICATED,
33+
message = "User is not authenticated${if (context != null) ": $context" else ""}"
34+
)
35+
}
36+
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package hs.kr.entrydsm.domain.security.interfaces
2+
3+
import java.util.UUID
4+
5+
/**
6+
* 보안 관련 기능을 제공하는 계약(Contract)입니다.
7+
*
8+
* 현재 인증된 사용자의 정보를 조회하는 기능을 제공합니다.
9+
*/
10+
interface SecurityContract {
11+
12+
/**
13+
* 현재 인증된 사용자의 ID를 반환합니다.
14+
*
15+
* @return 현재 사용자 ID
16+
* @throws SecurityException 인증 정보가 없거나 유효하지 않은 경우
17+
*/
18+
fun getCurrentUserId(): UUID
19+
}

casper-application-domain/src/main/kotlin/hs/kr/entrydsm/global/exception/ErrorCode.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,10 @@ enum class ErrorCode(val code: String, val description: String) {
395395
// School 도메인 오류 (SCH)
396396
SCHOOL_INVALID_TYPE("SCH001", "유효하지 않은 학교 유형입니다"),
397397

398+
// Security 도메인 오류 (SEC)
399+
SECURITY_INVALID_TOKEN("SEC001", "유효하지 않은 인증 토큰입니다"),
400+
SECURITY_UNAUTHENTICATED("SEC002", "인증되지 않은 사용자입니다"),
401+
398402
//feign error
399403
FEIGN_SERVER_ERROR("FGN001", "외부 API 서버 오류가 발생했습니다"),
400404

casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/presentation/ApplicationSubmissionController.kt

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import hs.kr.entrydsm.application.domain.application.presentation.dto.request.Ap
44
import hs.kr.entrydsm.application.domain.application.presentation.dto.response.ApplicationSubmissionResponse
55
import hs.kr.entrydsm.application.domain.application.usecase.CompleteApplicationUseCase
66
import hs.kr.entrydsm.application.global.document.application.ApplicationSubmissionApiDocument
7+
import hs.kr.entrydsm.domain.security.interfaces.SecurityContract
78
import org.springframework.http.HttpStatus
89
import org.springframework.http.ResponseEntity
910
import org.springframework.web.bind.annotation.PostMapping
@@ -16,6 +17,7 @@ import java.time.LocalDateTime
1617
@RequestMapping("/api/v1")
1718
class ApplicationSubmissionController(
1819
private val completeApplicationUseCase: CompleteApplicationUseCase,
20+
private val securityContract: SecurityContract,
1921
) : ApplicationSubmissionApiDocument {
2022
@PostMapping("/applications")
2123
override fun submitApplication(
@@ -26,9 +28,8 @@ class ApplicationSubmissionController(
2628
return createErrorResponse("요청 데이터가 없습니다", HttpStatus.BAD_REQUEST)
2729
}
2830

29-
if (request.userId.isBlank()) {
30-
return createErrorResponse("사용자 ID가 필요합니다", HttpStatus.BAD_REQUEST)
31-
}
31+
// SecurityContract를 통해 현재 사용자 ID 추출
32+
val userId = securityContract.getCurrentUserId()
3233

3334
if (request.application.isEmpty()) {
3435
return createErrorResponse("원서 정보가 필요합니다", HttpStatus.BAD_REQUEST)
@@ -38,12 +39,6 @@ class ApplicationSubmissionController(
3839
return createErrorResponse("성적 정보가 필요합니다", HttpStatus.BAD_REQUEST)
3940
}
4041

41-
try {
42-
java.util.UUID.fromString(request.userId)
43-
} catch (e: IllegalArgumentException) {
44-
return createErrorResponse("올바르지 않은 사용자 ID 형식입니다", HttpStatus.BAD_REQUEST)
45-
}
46-
4742
val applicationType = request.application["applicationType"]
4843
val educationalStatus = request.application["educationalStatus"]
4944

@@ -55,7 +50,7 @@ class ApplicationSubmissionController(
5550
return createErrorResponse("학력 상태가 필요합니다", HttpStatus.BAD_REQUEST)
5651
}
5752

58-
val response = completeApplicationUseCase.execute(request)
53+
val response = completeApplicationUseCase.execute(userId, request)
5954
ResponseEntity.status(HttpStatus.CREATED).body(response)
6055
} catch (e: IllegalArgumentException) {
6156
createErrorResponse(e.message ?: "잘못된 요청 파라미터입니다", HttpStatus.BAD_REQUEST)
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package hs.kr.entrydsm.application.domain.application.presentation.dto.request
22

33
data class ApplicationSubmissionRequest(
4-
val userId: String,
54
val application: Map<String, Any>,
65
val scores: Map<String, Any>,
76
)

casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/usecase/CompleteApplicationUseCase.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class CompleteApplicationUseCase(
1818
private val calculator: Calculator,
1919
private val applicationPersistenceService: ApplicationPersistenceService,
2020
) {
21-
fun execute(request: ApplicationSubmissionRequest): ApplicationSubmissionResponse {
21+
fun execute(userId: UUID, request: ApplicationSubmissionRequest): ApplicationSubmissionResponse {
2222
val applicationType = request.application["applicationType"] as String
2323
val educationalStatus = request.application["educationalStatus"] as String
2424
val region = request.application["region"] as? String
@@ -41,7 +41,7 @@ class CompleteApplicationUseCase(
4141

4242
val applicationEntity =
4343
applicationPersistenceService.saveApplication(
44-
userId = UUID.fromString(request.userId),
44+
userId = userId,
4545
applicationData = request.application,
4646
)
4747

casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/config/SecurityConfig.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package hs.kr.entrydsm.application.global.config
22

3+
import hs.kr.entrydsm.application.global.security.FilterConfig
4+
import hs.kr.entrydsm.application.global.security.jwt.JwtProperties
5+
import hs.kr.entrydsm.domain.user.value.UserRole
6+
import org.springframework.boot.context.properties.EnableConfigurationProperties
37
import org.springframework.context.annotation.Bean
48
import org.springframework.context.annotation.Configuration
59
import org.springframework.security.config.annotation.web.builders.HttpSecurity
@@ -11,7 +15,10 @@ import org.springframework.security.web.SecurityFilterChain
1115
* 애플리케이션의 보안 정책과 인증/인가 규칙을 정의합니다.
1216
*/
1317
@Configuration
14-
class SecurityConfig {
18+
@EnableConfigurationProperties(JwtProperties::class)
19+
class SecurityConfig(
20+
private val filterConfig: FilterConfig,
21+
) {
1522
/**
1623
* Spring Security 필터 체인을 구성합니다.
1724
* HTTP 보안 설정 및 경로별 접근 권한을 정의합니다.
@@ -35,8 +42,11 @@ class SecurityConfig {
3542
.requestMatchers("/v3/api-docs/**").permitAll()
3643
.requestMatchers("/swagger-resources/**").permitAll()
3744
.requestMatchers("/webjars/**").permitAll()
45+
.requestMatchers("/admin/**").hasRole(UserRole.ADMIN.name)
46+
.requestMatchers("/api/v1/applications/**").hasRole(UserRole.USER.name)
3847
.anyRequest().authenticated()
3948
}
49+
.apply(filterConfig)
4050

4151
return http.build()
4252
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package hs.kr.entrydsm.application.global.security
2+
3+
import hs.kr.entrydsm.application.global.security.jwt.JwtFilter
4+
import org.springframework.security.config.annotation.SecurityConfigurerAdapter
5+
import org.springframework.security.config.annotation.web.builders.HttpSecurity
6+
import org.springframework.security.web.DefaultSecurityFilterChain
7+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
8+
import org.springframework.stereotype.Component
9+
10+
/**
11+
* 시큐리티 필터 체인 설정을 담당하는 클래스입니다.
12+
* JWT 필터를 Spring Security 필터 체인에 등록합니다.
13+
*/
14+
@Component
15+
class FilterConfig : SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>() {
16+
17+
override fun configure(builder: HttpSecurity) {
18+
builder.addFilterBefore(
19+
JwtFilter(),
20+
UsernamePasswordAuthenticationFilter::class.java,
21+
)
22+
}
23+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package hs.kr.entrydsm.application.global.security
2+
3+
import hs.kr.entrydsm.domain.security.exceptions.SecurityException
4+
import hs.kr.entrydsm.domain.security.interfaces.SecurityContract
5+
import org.springframework.security.core.context.SecurityContextHolder
6+
import org.springframework.stereotype.Component
7+
import java.util.UUID
8+
9+
/**
10+
* Spring Security와 도메인 계층을 연결하는 어댑터입니다.
11+
*
12+
* SecurityContract의 구현체로, Spring Security Context에서
13+
* 현재 인증된 사용자 정보를 추출하여 도메인 계층에 제공합니다.
14+
*/
15+
@Component
16+
class SecurityAdapter : SecurityContract {
17+
18+
override fun getCurrentUserId(): UUID {
19+
val authentication = SecurityContextHolder.getContext().authentication
20+
?: throw SecurityException.UnauthenticatedException("인증 컨텍스트가 존재하지 않습니다")
21+
22+
val userId = authentication.name
23+
?: throw SecurityException.UnauthenticatedException("사용자 정보가 존재하지 않습니다")
24+
25+
try {
26+
return UUID.fromString(userId)
27+
} catch (e: IllegalArgumentException) {
28+
throw SecurityException.InvalidTokenException(userId)
29+
}
30+
}
31+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package hs.kr.entrydsm.application.global.security.jwt
2+
3+
import hs.kr.entrydsm.domain.user.value.UserRole
4+
import jakarta.servlet.FilterChain
5+
import jakarta.servlet.http.HttpServletRequest
6+
import jakarta.servlet.http.HttpServletResponse
7+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
8+
import org.springframework.security.core.Authentication
9+
import org.springframework.security.core.authority.SimpleGrantedAuthority
10+
import org.springframework.security.core.context.SecurityContextHolder
11+
import org.springframework.security.core.userdetails.User
12+
import org.springframework.security.core.userdetails.UserDetails
13+
import org.springframework.web.filter.OncePerRequestFilter
14+
15+
/**
16+
* JWT 인증을 처리하는 필터입니다.
17+
*
18+
* Gateway에서 JWT를 파싱하여 헤더로 전달받은 사용자 정보를
19+
* Spring Security Context에 설정합니다.
20+
*/
21+
class JwtFilter : OncePerRequestFilter() {
22+
23+
override fun doFilterInternal(
24+
request: HttpServletRequest,
25+
response: HttpServletResponse,
26+
filterChain: FilterChain,
27+
) {
28+
val userId: String? = request.getHeader("Request-User-Id")
29+
val role: UserRole? = request.getHeader("Request-User-Role")?.let {
30+
try {
31+
UserRole.valueOf(it)
32+
} catch (e: IllegalArgumentException) {
33+
null
34+
}
35+
}
36+
37+
if (userId == null || role == null) {
38+
filterChain.doFilter(request, response)
39+
return
40+
}
41+
42+
val authorities = mutableListOf(SimpleGrantedAuthority("ROLE_${role.name}"))
43+
val userDetails: UserDetails = User(userId, "", authorities)
44+
val authentication: Authentication =
45+
UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities)
46+
47+
SecurityContextHolder.clearContext()
48+
SecurityContextHolder.getContext().authentication = authentication
49+
50+
filterChain.doFilter(request, response)
51+
}
52+
}

0 commit comments

Comments
 (0)