๋ค์ด๊ฐ๊ธฐ ์์
ํ์ฌ ํ์ฌ์์๋ ์์ฒด์ ์ธ ํ ํฐ ์์ฑ ๋ฉ์๋๋ฅผ ์ด์ฉํด ์ ์ ํ ํฐ์ ์์ฑํ๊ณ ์ด๋ฅผ 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 |