Skip to content
This repository has been archived by the owner on May 19, 2024. It is now read-only.

[WEAV-137] 인증 필터, 인가 인터셉터 구현 #46

Merged
merged 7 commits into from
Feb 2, 2024
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,18 @@
package com.studentcenter.weave.application.common.security.context

import com.studentcenter.weave.application.common.security.vo.UserAuthentication
import com.studentcenter.weave.support.security.context.SecurityContext

class UserSecurityContext(
private var authentication: UserAuthentication,
) : SecurityContext<UserAuthentication> {

override fun getAuthentication(): UserAuthentication {
return authentication
}

override fun setAuthentication(authentication: UserAuthentication) {
this.authentication = authentication
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.studentcenter.weave.application.common.security.vo

import com.studentcenter.weave.application.vo.UserTokenClaims
import com.studentcenter.weave.domain.vo.Nickname
import com.studentcenter.weave.support.common.vo.Email
import com.studentcenter.weave.support.common.vo.Url
import com.studentcenter.weave.support.security.authority.Authentication
import java.util.*

data class UserAuthentication(
val userId: UUID,
val nickname: Nickname,
val email: Email,
val avatar: Url?,
) : Authentication {

companion object {

fun from(claims: UserTokenClaims.AccessToken): UserAuthentication {
return UserAuthentication(
userId = claims.userId,
nickname = claims.nickname,
email = claims.email,
avatar = claims.avatar
)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.studentcenter.weave.application.vo

import com.studentcenter.weave.domain.entity.User
import com.studentcenter.weave.domain.entity.UserFixtureFactory

object UserTokenClaimsFixtureFactory {

fun createAccessTokenClaim(
user: User = UserFixtureFactory.create()
): UserTokenClaims.AccessToken {
return UserTokenClaims.AccessToken(
userId = user.id,
email = user.email,
nickname = user.nickname,
avatar = user.avatar
)
}

}
4 changes: 4 additions & 0 deletions bootstrap/http/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
dependencies {
implementation(project(":support:common"))
implementation(project(":support:security"))

implementation(project(":domain"))
implementation(project(":application"))
implementation(project(":infrastructure:client"))
Expand All @@ -11,4 +13,6 @@ dependencies {

developmentOnly("org.springframework.boot:spring-boot-devtools:${Version.SPRING_BOOT}")
developmentOnly("org.springframework.boot:spring-boot-docker-compose:${Version.SPRING_BOOT}")

testImplementation(testFixtures(project(":application")))
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
package com.studentcenter.weave.bootstrap.common.config

import com.studentcenter.weave.bootstrap.common.security.interceptor.AuthorizationInterceptor
import com.studentcenter.weave.bootstrap.common.security.resolver.RegisterTokenArgumentResolver
import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebMvcConfig(
private val registerTokenArgumentResolver: RegisterTokenArgumentResolver
private val registerTokenArgumentResolver: RegisterTokenArgumentResolver,
private val authorizationInterceptor: AuthorizationInterceptor,
) : WebMvcConfigurer {

override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(registerTokenArgumentResolver)
super.addArgumentResolvers(resolvers)
}

override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(authorizationInterceptor)
super.addInterceptors(registry)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ import com.studentcenter.weave.support.common.exception.CustomExceptionType
enum class ApiExceptionType(override val code: String) : CustomExceptionType {
INVALID_DATE_EXCEPTION("API-001"),
INVALID_PARAMETER("API-002"),
UNAUTHORIZED_REQUEST("API-003"),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.studentcenter.weave.bootstrap.common.security.annotation

/**
* 인증된 사용자만 접근 가능한 API에 사용하는 어노테이션
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class Secured
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.studentcenter.weave.bootstrap.common.security.filter

import com.studentcenter.weave.application.common.security.context.UserSecurityContext
import com.studentcenter.weave.application.common.security.vo.UserAuthentication
import com.studentcenter.weave.application.service.util.UserTokenService
import com.studentcenter.weave.support.security.context.SecurityContextHolder
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter

@Component
class JwtAuthenticationFilter(
private val userTokenService: UserTokenService,
) : OncePerRequestFilter() {

override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
extractToken(request)?.let { processToken(it) }
filterChain.doFilter(request, response)
SecurityContextHolder.clearContext()
}

private fun extractToken(request: HttpServletRequest): String? {
val bearerToken: String? = request.getHeader(TOKEN_HEADER)
return if (bearerToken != null && bearerToken.startsWith(TOKEN_PREFIX)) {
bearerToken.substring(TOKEN_PREFIX.length)
} else null
}

private fun processToken(token: String) {
with(userTokenService.resolveAccessToken(token)) {
UserAuthentication.from(this)
}.let { userAuthentication ->
UserSecurityContext(userAuthentication)
}.also { securityContext ->
SecurityContextHolder.setContext(securityContext)
}
}

companion object {
const val TOKEN_HEADER = "Authorization"
const val TOKEN_PREFIX = "Bearer "
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.studentcenter.weave.bootstrap.common.security.interceptor

import com.studentcenter.weave.application.common.security.vo.UserAuthentication
import com.studentcenter.weave.bootstrap.common.exception.ApiExceptionType
import com.studentcenter.weave.support.common.exception.CustomException
import com.studentcenter.weave.bootstrap.common.security.annotation.Secured
import com.studentcenter.weave.support.security.context.SecurityContextHolder
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor

@Component
class AuthorizationInterceptor : HandlerInterceptor {

override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
if (hasSecuredAnnotation(handler) && isNotAuthenticated()) {
throw CustomException(ApiExceptionType.UNAUTHORIZED_REQUEST, "요청 권한이 없습니다.")
}

return super.preHandle(request, response, handler)
}

private fun hasSecuredAnnotation(handler: Any): Boolean {
(handler as HandlerMethod).method.declaredAnnotations.forEach {
if (it is Secured) {
return true
}
}
return false
}

private fun isNotAuthenticated(): Boolean {
SecurityContextHolder
.getContext<UserAuthentication>()
.let { userAuthentication ->
return userAuthentication == null
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.studentcenter.weave.bootstrap.common.security.filter

import com.studentcenter.weave.application.service.util.UserTokenService
import com.studentcenter.weave.application.vo.UserTokenClaimsFixtureFactory
import com.studentcenter.weave.bootstrap.controller.JwtAuthenticationFilterTestController
import io.kotest.core.spec.DisplayName
import io.kotest.core.spec.style.DescribeSpec
import io.mockk.every
import io.mockk.mockk
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder


@DisplayName("JwtAuthenticationFilterTest")
@WebMvcTest(JwtAuthenticationFilterTestController::class)
class JwtAuthenticationFilterTest : DescribeSpec({

val userTokenService: UserTokenService = mockk<UserTokenService>()
val sut = JwtAuthenticationFilter(userTokenService)
val mockMvc: MockMvc = MockMvcBuilders
.standaloneSetup(JwtAuthenticationFilterTestController())
.addFilter<StandaloneMockMvcBuilder?>(sut)
.build()

describe("JwtAuthenticationFilter") {
context("Authorization Header에 access toeken을 전달하면") {
it("UserAuthentication이 SecurityContextHolder에 저장된다") {
// arrange
every { userTokenService.resolveAccessToken(any()) } returns UserTokenClaimsFixtureFactory.createAccessTokenClaim()

// act, assert
mockMvc.get("/jwt-authentication-filter-test") {
header("Authorization", "Bearer access-token")
}.andExpect {
status { isOk() }
}
}
}

context("Authorization Header에 access toeken을 전달하지 않으면") {
it("UserAuthentication이 SecurityContextHolder에 저장되지 않는다") {
// act, assert
mockMvc.get("/jwt-authentication-filter-test").andExpect {
status { isUnauthorized() }
}
}
}
}

})
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.studentcenter.weave.bootstrap.common.security.interceptor

import com.studentcenter.weave.application.service.util.UserTokenService
import com.studentcenter.weave.application.vo.UserTokenClaimsFixtureFactory
import com.studentcenter.weave.bootstrap.common.exception.CustomExceptionHandler
import com.studentcenter.weave.bootstrap.common.security.filter.JwtAuthenticationFilter
import com.studentcenter.weave.bootstrap.controller.AuthorizationInterceptorTestController
import io.kotest.core.annotation.DisplayName
import io.kotest.core.spec.style.DescribeSpec
import io.mockk.every
import io.mockk.mockk
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder

@DisplayName("AuthorizationInterceptorTest")
@WebMvcTest(
AuthorizationInterceptorTestController::class,
CustomExceptionHandler::class
)
class AuthorizationInterceptorTest : DescribeSpec({

val userTokenService: UserTokenService = mockk<UserTokenService>()
val authenticationFilter = JwtAuthenticationFilter(userTokenService)
val sut = AuthorizationInterceptor()
val mockMvc: MockMvc = MockMvcBuilders
.standaloneSetup(AuthorizationInterceptorTestController())
.setControllerAdvice(CustomExceptionHandler())
.addFilter<StandaloneMockMvcBuilder?>(authenticationFilter)
.addInterceptors(sut)
.build()

describe("AuthorizationInterceptor") {
context("유효한 인증 토큰과 함께, @Secured 어노테이션이 붙은 메소드에 접근하면") {
it("정상적으로 요청이 처리 된다") {
// arrange
every { userTokenService.resolveAccessToken(any()) } returns UserTokenClaimsFixtureFactory.createAccessTokenClaim()

// act, assert
mockMvc.get("/secured-method") {
header("Authorization", "Bearer access-token")
}.andExpect {
status { isOk() }
}
}
}

context("인증 토큰 없이, @Secured 어노테이션이 붙은 메소드에 접근하면") {
it("API-003(Unauthorized)을 응답한다") {
// act, assert
mockMvc.get("/secured-method").andExpect {
status { isBadRequest() }
content {
jsonPath("$.exceptionCode") { value("API-003") }
}
}
}
}

context("유효한 인증 토큰과 함께, @Secured 어노테이션이 없는 메소드에 접근하면") {
it("정상적으로 요청이 처리 된다") {
// arrange
every { userTokenService.resolveAccessToken(any()) } returns UserTokenClaimsFixtureFactory.createAccessTokenClaim()

// act, assert
mockMvc.get("/unsecured-method") {
header("Authorization", "Bearer access-token")
}.andExpect {
status { isOk() }
}
}
}

context("인증 토큰 없이, @Secured 어노테이션이 없는 메소드에 접근하면") {
it("정상적으로 요청이 처리 된다") {
// act, assert
mockMvc.get("/unsecured-method").andExpect {
status { isOk() }
}
}
}
}

})
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.studentcenter.weave.bootstrap.controller

import com.studentcenter.weave.bootstrap.common.security.annotation.Secured
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController

@RestController
class AuthorizationInterceptorTestController {

@Secured
@GetMapping("/secured-method")
@ResponseStatus(HttpStatus.OK)
fun securedMethod() {
/* secured method */
}

@GetMapping("/unsecured-method")
@ResponseStatus(HttpStatus.OK)
fun unsecuredMethod() {
/* unsecured method */
}

}
Loading
Loading