From e3b0693a3c6e453bec096ffb6d698154d8f37f1b Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 14 Sep 2025 22:12:06 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20\@AdminOnly=EC=97=90=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=ED=83=80=EC=9E=85(STORE,=20HQ)=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 --- .../auth/infrastructure/jwt/JwtUtils.kt | 19 ----- .../auth/web/support/AuthAnnotations.kt | 2 + .../support/interceptors/AdminInterceptor.kt | 79 ++++++++++++++----- .../support/interceptors/UserInterceptor.kt | 20 ++--- .../roomescape/schedule/docs/ScheduleAPI.kt | 9 ++- .../kotlin/roomescape/theme/docs/ThemeApi.kt | 12 +-- 6 files changed, 83 insertions(+), 58 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt index 8e74a755..b68d612b 100644 --- a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt +++ b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt @@ -6,14 +6,10 @@ import io.jsonwebtoken.Claims import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys -import org.slf4j.MDC import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component -import roomescape.auth.business.CLAIM_TYPE_KEY import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException -import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY -import roomescape.common.dto.PrincipalType import java.util.* import javax.crypto.SecretKey @@ -47,21 +43,6 @@ class JwtUtils( } } - fun extractIdAndType(token: String?): Pair { - 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 { if (token.isNullOrBlank()) { throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index 003e4261..d97d2166 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -1,10 +1,12 @@ package roomescape.auth.web.support +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class AdminOnly( + val type: AdminType, val privilege: Privilege ) diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt index b9f0cc63..8c1d610c 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt @@ -4,17 +4,21 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor 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.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.AdminOnly 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 {} @@ -30,32 +34,67 @@ class AdminInterceptor( if (handler !is HandlerMethod) { return true } - val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true - val token: String? = request.accessToken() - val (id, type) = jwtUtils.extractIdAndType(token) - val permission: AdminPermissionLevel = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY) - ?.let { - AdminPermissionLevel.valueOf(it) - } - ?: run { - 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) - } + try { + run { + val id: String = jwtUtils.extractSubject(token).also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) } + val type: AdminType = validateTypeAndGet(token, annotation.type) + val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege) - if (!permission.hasPrivilege(annotation.privilege)) { - log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${annotation.privilege} / current=${permission}" } + log.info { "[AdminInterceptor] 인증 완료. adminId=$id, type=${type}, permission=${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) } - 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 } } diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt index ac42cd0f..8c9ebc81 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt @@ -4,6 +4,7 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor @@ -12,7 +13,7 @@ import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.UserOnly 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 {} @@ -29,16 +30,17 @@ class UserInterceptor( if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(UserOnly::class.java) == null)) { return true } - val token: String? = request.accessToken() - val (id, type) = jwtUtils.extractIdAndType(token) - if (type != PrincipalType.USER) { - log.warn { "[UserInterceptor] 관리자의 회원 API 접근: id=${id}" } - throw AuthException(AuthErrorCode.ACCESS_DENIED) + try { + jwtUtils.extractSubject(token).also { + 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 } } diff --git a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt index e0009e9f..de5d416e 100644 --- a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt +++ b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt @@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.Public @@ -53,21 +54,21 @@ interface ScheduleAPI { @PathVariable("id") id: Long ): ResponseEntity> - @AdminOnly(privilege = Privilege.READ_DETAIL) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_DETAIL) @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) fun findScheduleDetail( @PathVariable("id") id: Long ): ResponseEntity> - @AdminOnly(privilege = Privilege.CREATE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE) @Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createSchedule( @Valid @RequestBody request: ScheduleCreateRequest ): ResponseEntity> - @AdminOnly(privilege = Privilege.UPDATE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE) @Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateSchedule( @@ -75,7 +76,7 @@ interface ScheduleAPI { @Valid @RequestBody request: ScheduleUpdateRequest ): ResponseEntity> - @AdminOnly(privilege = Privilege.DELETE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE) @Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) fun deleteSchedule( diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index 5f465d64..3406dd98 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -8,6 +8,7 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody +import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.Public @@ -16,28 +17,27 @@ import roomescape.theme.web.* @Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") interface ThemeAPIV2 { - - @AdminOnly(privilege = Privilege.READ_SUMMARY) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_SUMMARY) @Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemes(): ResponseEntity> - @AdminOnly(privilege = Privilege.READ_DETAIL) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.READ_DETAIL) @Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity> - @AdminOnly(privilege = Privilege.CREATE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.CREATE) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity> - @AdminOnly(privilege = Privilege.DELETE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.DELETE) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) fun deleteTheme(@PathVariable id: Long): ResponseEntity> - @AdminOnly(privilege = Privilege.UPDATE) + @AdminOnly(type = AdminType.STORE, privilege = Privilege.UPDATE) @Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateTheme(