refactor: \@AdminOnly에 관리자 타입(STORE, HQ) 추가

This commit is contained in:
이상진 2025-09-14 22:12:06 +09:00
parent 5aa6a6cc2c
commit e3b0693a3c
6 changed files with 83 additions and 58 deletions

View File

@ -6,14 +6,10 @@ import io.jsonwebtoken.Claims
import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
import org.slf4j.MDC
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import roomescape.auth.business.CLAIM_TYPE_KEY
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
import roomescape.common.dto.PrincipalType
import java.util.* import java.util.*
import javax.crypto.SecretKey import javax.crypto.SecretKey
@ -47,21 +43,6 @@ class JwtUtils(
} }
} }
fun extractIdAndType(token: String?): Pair<Long, PrincipalType> {
val id: Long = extractSubject(token)
.also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) }
.toLong()
val type: PrincipalType = extractClaim(token, CLAIM_TYPE_KEY)
?.let { PrincipalType.valueOf(it) }
?: run {
log.info { "[JwtUtils.extractIdAndType] 회원 타입 조회 실패. id=$id" }
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
}
return id to type
}
fun extractSubject(token: String?): String { fun extractSubject(token: String?): String {
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {
throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND)

View File

@ -1,10 +1,12 @@
package roomescape.auth.web.support package roomescape.auth.web.support
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege import roomescape.admin.infrastructure.persistence.Privilege
@Target(AnnotationTarget.FUNCTION) @Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
annotation class AdminOnly( annotation class AdminOnly(
val type: AdminType,
val privilege: Privilege val privilege: Privilege
) )

View File

@ -4,17 +4,21 @@ import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import org.slf4j.MDC
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor import org.springframework.web.servlet.HandlerInterceptor
import roomescape.admin.infrastructure.persistence.AdminPermissionLevel import roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege
import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import roomescape.auth.business.CLAIM_PERMISSION_KEY import roomescape.auth.business.CLAIM_PERMISSION_KEY
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.accessToken import roomescape.auth.web.support.accessToken
import roomescape.common.dto.PrincipalType import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -30,32 +34,67 @@ class AdminInterceptor(
if (handler !is HandlerMethod) { if (handler !is HandlerMethod) {
return true return true
} }
val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true
val token: String? = request.accessToken() val token: String? = request.accessToken()
val (id, type) = jwtUtils.extractIdAndType(token)
val permission: AdminPermissionLevel = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY) try {
?.let { run {
AdminPermissionLevel.valueOf(it) val id: String = jwtUtils.extractSubject(token).also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) }
} val type: AdminType = validateTypeAndGet(token, annotation.type)
?: run { val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege)
if (type != PrincipalType.ADMIN) {
log.warn { "[AdminInterceptor] 회원의 관리자 API 접근: id=${id}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
log.warn { "[AdminInterceptor] 토큰에서 이용자 권한이 조회되지 않음: id=${id}" }
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
}
if (!permission.hasPrivilege(annotation.privilege)) { log.info { "[AdminInterceptor] 인증 완료. adminId=$id, type=${type}, permission=${permission}" }
log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${annotation.privilege} / current=${permission}" } }
return true
} catch (e: Exception) {
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
}
}
private fun validateTypeAndGet(token: String?, requiredType: AdminType): AdminType {
val typeClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_ADMIN_TYPE_KEY)
if (typeClaim == null) {
log.warn { "[AdminInterceptor] 관리자 타입 조회 실패: token=${token}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
val type = try {
AdminType.valueOf(typeClaim)
} catch (_: IllegalArgumentException) {
log.warn { "[AdminInterceptor] 관리자 타입 변환 실패: token=${token}, typeClaim=${typeClaim}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
if (type != AdminType.HQ && type != requiredType) {
log.warn { "[AdminInterceptor] 관리자 권한 부족: requiredType=${requiredType} / current=${type}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED) throw AuthException(AuthErrorCode.ACCESS_DENIED)
} }
log.info { "[AdminInterceptor] 인증 완료. adminId=$id, permission=${permission}" } return type
}
return true private fun validatePermissionAndGet(token: String?, requiredPrivilege: Privilege): AdminPermissionLevel {
val permissionClaim: String? = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY)
if (permissionClaim == null) {
log.warn { "[AdminInterceptor] 관리자 권한 조회 실패: token=${token}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
val permission = try {
AdminPermissionLevel.valueOf(permissionClaim)
} catch (_: IllegalArgumentException) {
log.warn { "[AdminInterceptor] 관리자 권한 변환 실패: token=${token}, permissionClaim=${permissionClaim}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
if (!permission.hasPrivilege(requiredPrivilege)) {
log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${requiredPrivilege} / current=${permission.privileges}" }
throw AuthException(AuthErrorCode.ACCESS_DENIED)
}
return permission
} }
} }

View File

@ -4,6 +4,7 @@ import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import org.slf4j.MDC
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor import org.springframework.web.servlet.HandlerInterceptor
@ -12,7 +13,7 @@ import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.UserOnly import roomescape.auth.web.support.UserOnly
import roomescape.auth.web.support.accessToken import roomescape.auth.web.support.accessToken
import roomescape.common.dto.PrincipalType import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -29,16 +30,17 @@ class UserInterceptor(
if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(UserOnly::class.java) == null)) { if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(UserOnly::class.java) == null)) {
return true return true
} }
val token: String? = request.accessToken() val token: String? = request.accessToken()
val (id, type) = jwtUtils.extractIdAndType(token)
if (type != PrincipalType.USER) { try {
log.warn { "[UserInterceptor] 관리자의 회원 API 접근: id=${id}" } jwtUtils.extractSubject(token).also {
throw AuthException(AuthErrorCode.ACCESS_DENIED) MDC.put(MDC_PRINCIPAL_ID_KEY, it)
log.info { "[UserInterceptor] 인증 완료. userId=$it" }
}
return true
} catch (e: Exception) {
log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
} }
log.info { "[UserInterceptor] 인증 완료. userId=$id" }
return true
} }
} }

View File

@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege import roomescape.admin.infrastructure.persistence.Privilege
import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.Public import roomescape.auth.web.support.Public
@ -53,21 +54,21 @@ interface ScheduleAPI {
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(privilege = Privilege.READ_DETAIL) @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_DETAIL)
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true))
fun findScheduleDetail( fun findScheduleDetail(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ScheduleDetailResponse>> ): ResponseEntity<CommonApiResponse<ScheduleDetailResponse>>
@AdminOnly(privilege = Privilege.CREATE) @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE)
@Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun createSchedule( fun createSchedule(
@Valid @RequestBody request: ScheduleCreateRequest @Valid @RequestBody request: ScheduleCreateRequest
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> ): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>>
@AdminOnly(privilege = Privilege.UPDATE) @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE)
@Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun updateSchedule( fun updateSchedule(
@ -75,7 +76,7 @@ interface ScheduleAPI {
@Valid @RequestBody request: ScheduleUpdateRequest @Valid @RequestBody request: ScheduleUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(privilege = Privilege.DELETE) @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE)
@Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true))
fun deleteSchedule( fun deleteSchedule(

View File

@ -8,6 +8,7 @@ import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege import roomescape.admin.infrastructure.persistence.Privilege
import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.Public import roomescape.auth.web.support.Public
@ -16,28 +17,27 @@ import roomescape.theme.web.*
@Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") @Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.")
interface ThemeAPIV2 { interface ThemeAPIV2 {
@AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_SUMMARY)
@AdminOnly(privilege = Privilege.READ_SUMMARY)
@Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findAdminThemes(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>> fun findAdminThemes(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>>
@AdminOnly(privilege = Privilege.READ_DETAIL) @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_DETAIL)
@Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>> fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>>
@AdminOnly(privilege = Privilege.CREATE) @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE)
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>> fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>>
@AdminOnly(privilege = Privilege.DELETE) @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE)
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true))
fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>>
@AdminOnly(privilege = Privilege.UPDATE) @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE)
@Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun updateTheme( fun updateTheme(