Programming/Spring

[Spring Security] @AuthenticationPrincipal Test Trouble Shooting

osean 2023. 4. 8. 23:25

๋“ค์–ด๊ฐ€๊ธฐ ์•ž์„œ

ํ˜„์žฌ ํšŒ์‚ฌ์—์„œ๋Š” ์ž์ฒด์ ์ธ ํ† ํฐ ์ƒ์„ฑ ๋ฉ”์†Œ๋“œ๋ฅผ ์ด์šฉํ•ด ์œ ์ € ํ† ํฐ์„ ์ƒ์„ฑํ•˜๊ณ  ์ด๋ฅผ Memcached ์— ์ €์žฅํ•œ ๋’ค ์›น์ธ ๊ฒฝ์šฐ Cookie ๋ฅผ, ์•ฑ์ธ ๊ฒฝ์šฐ Http Header ์— ๋‹ด๊ธด ๊ฐ’์„ ํ†ตํ•ด ์œ ์ € ์ธ์ฆ ์ •๋ณด๋ฅผ ํ™•์ธํ•˜๋Š” ๊ตฌ์กฐ๋กœ ์ฒ˜๋ฆฌํ•˜๊ณ  ์žˆ๋Š”๋ฐ, ์Šค์ผ€์ผ ์•„์›ƒ์œผ๋กœ ์„œ๋ฒ„๋ฅผ ์šด์˜ํ•˜๊ณ  ์žˆ์–ด ์œ ์ € ์ธ์ฆ ์ •๋ณด๋ฅผ ์—ฌ๋Ÿฌ ๋Œ€์˜ ์„œ๋ฒ„์—์„œ ํ•จ๊ป˜ ๊ณต์œ ํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.

๋‹ค๋งŒ, ์œ ์ € ์ธ์ฆ/์ธ๊ฐ€ ์ฒ˜๋ฆฌ๋ฅผ Filter ๊ฐ€ ์•„๋‹Œ ๋ณ„๋„์˜ ์ธ์ฆ์šฉ ํด๋ž˜์Šค๋ฅผ ๋งŒ๋“ค๊ณ  ์ธ์ฆ ์ฒ˜๋ฆฌ ๋ฉ”์†Œ๋“œ์— @ModelAttribute ๋ฅผ ๋ถ™์—ฌ ๋งค ์š”์ฒญ ๋งˆ๋‹ค ์ธ์ฆ ์ •๋ณด๋ฅผ ํ™•์ธํ•˜๊ณ  ์žˆ์–ด ๋ถˆํ•„์š”ํ•œ ์ž์›์ด ๋‚ญ๋น„๋˜๊ณ  ์žˆ๋Š” ์ƒํ™ฉ์ด๋‹ค.

๋•Œ๋ฌธ์— Spring Security ์— ์ต์ˆ™์น˜๋„ ์•Š๊ณ , ์–ธ์  ๊ฐ€ ํšŒ์‚ฌ์˜ ๋ ˆ๊ฑฐ์‹œ ์ฝ”๋“œ๋ฅผ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ ๊ฐ™์•„ ๋น„์‚ฌ์ด๋“œ ์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ์—์„œ ์œ ์ € ์ธ์ฆ์— Spring Security + JWT ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ํ–ˆ๋‹ค.

ํ˜„์žฌ ์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” JWT ๋ฅผ ์ด์šฉํ•œ ์ธ์ฆ/์ธ๊ฐ€ ๋กœ์ง์€ ๊ตฌํ˜„์ด ์™„๋ฃŒ๋œ ์ƒํƒœ์ด๋ฉฐ, ์„œ๋น„์Šค ์šด์˜ ์‹œ ์„œ๋ฒ„ ํ•œ ๋Œ€๋กœ๋„ ์ถฉ๋ถ„ํžˆ ๊ฐ๋‹นํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ ๊ฐ™์•„ ํ† ํฐ ์ •๋ณด๋ฅผ ์บ์‹ฑ ๋ฉ”๋ชจ๋ฆฌ์— ์ €์žฅํ•˜์ง€ ์•Š๊ธฐ๋กœ ํ–ˆ๋‹ค.

๋•Œ๋ฌธ์— ์ธ์ฆ ์„ฑ๊ณต ์‹œ ์œ ์ € ๊ธฐ๋ณธ ์ •๋ณด๋ฅผ SecurityContext ์— ๋‹ด์•„ ์š”์ฒญ ์‹œ @AuthenticationPrincipal ์„ ์ด์šฉํ•ด ์œ ์ € ๊ธฐ๋ณธ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋ ค๊ณ  ํ•˜๋Š”๋ฐ, ํ•ด๋‹น ์• ๋…ธํ…Œ์ด์…˜์„ ์‚ฌ์šฉํ•˜๋ฉด ์›น ๊ณ„์ธต ํ…Œ์ŠคํŠธ์—์„œ ์œ ์ € ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†๋‹ค๋Š” ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•ด ์ด๋ฅผ ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ…ํ•œ ๊ฒฝํ—˜์„ ์ •๋ฆฌํ•˜๊ณ ์ž ํ•œ๋‹ค.

์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์†Œ๋“œ์—์„œ ์œ ์ € ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ

์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์†Œ๋“œ์˜ ์ธ์ž๋กœ ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ›์•„์˜ค๋Š” ๊ฒฝ์šฐ ์ธ์ฆ๋œ ์œ ์ €์— ํ•œํ•ด์„œ @AuthenticationPrincipal ๋ฅผ ์‚ฌ์šฉํ•ด SecurityContext ์— ์„ค์ •๋œ ์œ ์ € ์ •๋ณด๋ฅผ ๋งตํ•‘ ํ•  ์ˆ˜ ์žˆ๋‹ค. ์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” UserDetail ์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ๊ด€๋ จ๋œ ํด๋ž˜์Šค๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค.
๋จผ์ € ์ธ์ฆ ํ•„ํ„ฐ ํด๋ž˜์Šค์™€ Authentication ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋ฉ”์†Œ๋“œ๋ฅผ ํ™•์ธํ•ด๋ณด์ž.

JwtAuthenticationFilter.kt

์š”์ฒญ ์‹œ Http Header ์— ๋‹ด๊ธด Bearer ํ† ํฐ ์ •๋ณด๋ฅผ ํ™•์ธํ•˜๊ณ  ์œ ํšจํ•œ ์ธ์ฆ์ธ ๊ฒฝ์šฐ ์œ ์ € ์ •๋ณด๋ฅผ SecurityContext ์— ์„ค์ •ํ•œ๋‹ค.

class JwtAuthenticationFilter : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val authHeader = request.getHeader("Authorization")
        if (authHeader.isNullOrBlank() || !authHeader.startsWith("Bearer ")) {
            return filterChain.doFilter(request, response)
        }
        validateJwt(authHeader.substring("Bearer ".length), filterChain, request, response)
    }

    private fun validateJwt(
        jwt: String,
        filterChain: FilterChain,
        request: HttpServletRequest,
        response: HttpServletResponse
    ) {
        if (JwtProvider.isValidToken(jwt)) {
            SecurityContextHolder.getContext().authentication = JwtProvider.getAuthentication(jwt)
        }
        filterChain.doFilter(request, response)
    }
}

JwtProvider.kt

์š”์ฒญ์— ๋‹ด๊ธด JWT ํ† ํฐ์„ ํ™•์ธํ•˜๊ณ  ์œ ํšจํ•œ ๊ฒฝ์šฐ ํ•ด๋‹น ํ† ํฐ์— ๋‹ด๊ธด ์œ ์ € ์ •๋ณด๋ฅผ ์ด์šฉํ•ด Authentication ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•œ ๋’ค ๋ฆฌํ„ดํ•œ๋‹ค.

class JwtProvider {
  companion object {

    ...

    fun getAuthentication(token: String?): Authentication {
            val claims = getAllClaims(token)
            val member = Member(
                id = (claims[MEMBER_ID] as Int).toLong(),
                email = claims[EMAIL] as String
            )
            val authorities =
                listOf(claims[ROLE]).map { role -> SimpleGrantedAuthority(role as String?) }
            return UsernamePasswordAuthenticationToken(member, token, authorities)
        }
  }
}

CreateBingoApi.kt

@AuthenticationPrincipal ๋ฅผ ์ด์šฉํ•ด SecurityContext ์— ๋‹ด๊ธด ์œ ์ € ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค.

@RestController
@RequestMapping("/api/bingos")
class CreateBingoApi(
    private val createBingoService: CreateBingoService
) {
  @PostMapping
    fun create(
        @AuthenticationPrincipal
        member: Member,
        @RequestBody
        @Validated
        request: CreateBingoRequest,
        bindingResult: BindingResult
    ): ApiResponse<BingoResponse> {
        if (bindingResult.hasErrors()) throw BindException(bindingResult)
        val command = request.command(member.id)
        val response = createBingoService.create(command)
        return ApiResponse.OK(response)
    }
}

๋น™๊ณ  ์ƒ์„ฑ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ

์•ž์„œ ์ž‘์„ฑํ•œ ๊ฒƒ ์ฒ˜๋Ÿผ ์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ๋ณ„๋„๋กœ UserDetails ๋ฅผ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์— ๊ด€๋ จ๋œ ํด๋ž˜์Šค๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค.
๊ทธ๋ ‡๊ธฐ์— @WithMockUser ์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ธฐ๋ณธ ์œ ์ € ์ •๋ณด๋Š” ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์†Œ๋“œ์—์„œ ์ฒ˜๋ฆฌํ•˜์ง€ ๋ชปํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๊ฒŒ ๋œ๋‹ค.

Exception Message

Request processing failed: java.lang.NullPointerException: Parameter specified as non-null is null: method com.beside.groubing.groubingserver.domain.bingo.api.CreateBingoApi.create, parameter member

CreateBingoApiTest.kt

@WithMockUser
@WebMvcTest(controllers = [CreateBingoApi::class])
@AutoConfigureMockMvc(addFilters = false)
@AutoConfigureRestDocs
class CreateBingoApiTest(
    private val mockMvc: MockMvc,
    private val mapper: ObjectMapper,
    @MockkBean private val createBingoService: CreateBingoService
) : BehaviorSpec({
    Given("์‹ ๊ทœ ๋น™๊ณ  ์ƒ์„ฑ ์š”์ฒญ ์‹œ") {
        val now = LocalDate.now()
        val tomorrow = now.plusDays(1)
        val memberId = Arb.long(1L..100L).single()
        val pattern = "^[a-zA-Zใ„ฑ-ใ…Žใ…-ใ…ฃ๊ฐ€-ํžฃ -@\\[-_~]{1,40}"
        val request = CreateBingoRequest(
            title = Arb.stringPattern(pattern).single(),
            type = Arb.enum<BingoType>().single(),
            size = Arb.enum<BingoSize>().single(),
            color = Arb.enum<BingoColor>().single(),
            goal = Arb.int(1..3).single(),
            open = Arb.boolean().single(),
            since = Arb.localDate(minDate = now, maxDate = tomorrow).single(),
            until = Arb.localDate(minDate = tomorrow.plusDays(1)).single()
        )

        When("๋ฐ์ดํ„ฐ๊ฐ€ ์œ ํšจํ•˜๋‹ค๋ฉด") {
            val board = BingoBoard(
                title = request.title,
                type = request.type,
                size = request.size,
                color = request.color,
                goal = request.goal,
                open = request.open,
                since = request.since,
                until = request.until,
                member = Member(id = memberId)
            )
            val items = board.createNewItems()
            val response = ApiResponse.OK(BingoResponse(board, items))

            Then("์ƒ์„ฑ๋œ ๋น™๊ณ ๋ฅผ ๋ฆฌํ„ดํ•œ๋‹ค.") {
                every { createBingoService.create(any()) } returns response.data

                mockMvc.post("/api/bingos") {
                    content = mapper.writeValueAsString(request)
                    contentType = MediaType.APPLICATION_JSON
                }.andDo {
                    print()
                }.andExpect {
                    status { isOk() }
                    content { json(mapper.writeValueAsString(response)) }
                }
            }
        }
    }
})

ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ…

์ปค์Šคํ…€ @WithMockUser

@WithMockUser ๋‚˜ @WithUserDetails ๋Š” ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์†Œ๋“œ์—์„œ ์š”๊ตฌํ•˜๋Š” ์œ ์ € ํด๋ž˜์Šค๋ฅผ ๋ฆฌํ„ดํ•  ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์— ์š”๊ตฌ์‚ฌํ•ญ์— ๋งž๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ๋ฆฌํ„ดํ•  ์ˆ˜ ์žˆ๋Š” ๊ฐ™์€ ์—ญํ• ์˜ ์• ๋…ธํ…Œ์ด์…˜์ด ํ•„์š”ํ•˜๋‹ค.

WithAuthMember.kt

@Target(AnnotationTarget.CLASS)
@Retention
@WithSecurityContext(factory = WithAuthMemberSecurityContextFactory::class)
annotation class WithAuthMember(
    val id: Long = 0L,
    val email: String = "test@groubing.com",
    val role: MemberRole = MemberRole.MEMBER
)

์ปค์Šคํ…€ WithXXXSecurityContextFactory

@WithMockUser ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ WithMockUserSecurityContextFactory ๋ฅผ ์‚ฌ์šฉํ•˜๊ณ , @WithUserDetails ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ WithUserDetailsSecurityContextFactory ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. ์šฐ๋ฆฌ๋Š” ์œ„์˜ ๋‘ ์• ๋…ธํ…Œ์ด์…˜๊ณผ ๊ฐ™์€ ์—ญํ• ์„ ํ•˜๋Š” ์• ๋…ธํ…Œ์ด์…˜์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ, ์ปค์Šคํ…€ ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์†Œ๋“œ๋กœ ์ „๋‹ฌํ•  ์ปค์Šคํ…€ WithXXXSecurityContextFactory ๋ฅผ ๊ตฌํ˜„ํ•ด์•ผ ํ•œ๋‹ค.

WithAuthMemberSecurityContextFactory.kt

์œ ์ € ์ธ์ฆ/์ธ๊ฐ€ ํ•„ํ„ฐ์—์„œ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒƒ๊ณผ ๋น„์Šทํ•˜๊ฒŒ ์ธ์ฆ ๊ณผ์ •์€ ์ƒ๋žตํ•˜๊ณ  ์ž„์˜์˜ ์œ ์ € ์ •๋ณด๋ฅผ ์ด์šฉํ•ด Authentication ๊ฐ์ฒด ์ƒ์„ฑ ํ›„ SecurityContext ์— ์„ค์ •ํ•ด์ค€๋‹ค.

class WithAuthMemberSecurityContextFactory : WithSecurityContextFactory<WithAuthMember> {
    override fun createSecurityContext(annotation: WithAuthMember): SecurityContext {
        val context = SecurityContextHolder.getContext()
        val jwt = JwtProvider.createToken(annotation.id, annotation.email, annotation.role)
        context.authentication = JwtProvider.getAuthentication(jwt)
        return context
    }
}

ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ

์ปค์Šคํ…€ ์• ๋…ธํ…Œ์ด์…˜๊ณผ ํŒฉํ† ๋ฆฌ ํด๋ž˜์Šค๋ฅผ ๊ตฌํ˜„ ํ–ˆ๋‹ค๋ฉด ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์— ์ ์šฉํ•ด๋ณด์ž.
์šฐ๋ฆฌ์˜ ํ”„๋กœ์ ํŠธ๋Š” Koteset + MockK ์„ ์ด์šฉํ•˜๊ณ , ์›น ๊ณ„์ธต ํ…Œ์ŠคํŠธ์˜ ๊ฒฝ์šฐ BehaviorSpec ์„ ์ด์šฉํ•˜๊ธฐ์— ํด๋ž˜์Šค ์ƒ๋‹จ์— ์• ๋…ธํ…Œ์ด์…˜์„ ์ ์šฉํ•˜๊ณ ์ž ํ•œ๋‹ค.

CreateBingoApiTest.kt

@AutoConfigurationMockMvc(addFilters = false) ์˜ ๊ฒฝ์šฐ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋‹ค ๋ณด๋‹ˆ ๋ณ„๋„์˜ ์ธ์ฆ ๊ณผ์ •์„ ์ƒ๋žตํ•˜๊ธฐ ์œ„ํ•ด์„œ ์ถ”๊ฐ€ํ–ˆ๋‹ค.

@WithAuthMember
@WebMvcTest(controllers = [CreateBingoApi::class])
@AutoConfigureMockMvc(addFilters = false)
@AutoConfigureRestDocs
class CreateBingoApiTest(...): BehaviorSpec({...})

๋””๋ฒ„๊น… ๋ฐ ๊ฒฐ๊ณผ

๋””๋ฒ„๊น…์„ ํ•ด๋ณด๋ฉด @WithAuthMember ์—์„œ ์„ค์ •ํ•œ ๊ฐ’์œผ๋กœ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์†Œ๋“œ์— ์ž˜ ๋“ค์–ด์˜ค๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ๋„ ์„ฑ๊ณต์ ์ด๋‹ค.

'Programming > Spring' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€

[QueryDSL] NoSuchMethodError Trouble Shooting  (0) 2023.02.22
Criteria API  (0) 2023.01.28
Spring Bean / IoC Container / DI  (1) 2023.01.21
JPA / Persistence Context / Transactional  (2) 2023.01.14
Spring | Spring Framework ?  (0) 2020.08.02