๐Ÿ”ฅ TIL - Day 74 Kotlin & Springboot 05 SpringSecurity ์—†์ด ํ† ํฐ๊ธฐ๋ฐ˜ ์ธ์ฆ ๊ตฌํ˜„ ๋ฐ Junit ํ…Œ์ŠคํŠธ

Kim Dae Hyunยท2021๋…„ 12์›” 19์ผ
2

TIL

๋ชฉ๋ก ๋ณด๊ธฐ
85/93

์ „์ฒด ์†Œ์Šค์ฝ”๋“œ Github

Spring Security ์—†์ด ๊ตฌํ˜„ํ•˜๋Š” ์ด์œ 

  • Spring Security๋Š” ๊ต‰์žฅํžˆ ํฐ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ด๋‹ค. ๋”ฐ๋ผ์„œ ์™„๋ฒฝํ•˜๊ฒŒ ์ดํ•ดํ•˜๋Š” ๊ฒƒ์ด ์‰ฝ์ง€ ์•Š๋‹ค.
  • ํ† ํฐ๊ธฐ๋ฐ˜ ์ธ์ฆ์˜ ๊ฒฝ์šฐ ๊ตณ์ด SpringSecurity์˜ ํ•„ํ„ฐ์ฒด์ธ์„ ์ด์šฉํ•˜์ง€ ์•Š์•„๋„ ๊ตฌํ˜„ ๊ฐ€๋Šฅํ•˜๋‹ค.
  • ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์˜ ๊ธฐ๋Šฅ์„ ์ง์ ‘ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค๋ฉด ๋‹ค๋ฅธ ์–ธ์–ด์˜ ํ”„๋ ˆ์ž„์›Œํฌ์— ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๋Š”๋ฐ ์šฉ์ดํ•  ๊ฒƒ์ด๋‹ค.

ํฐ ํ๋ฆ„์„ ์žก์•„๋ณด์ž.
ํด๋ผ์ด์–ธํŠธ๋Š” ํšŒ์›๊ฐ€์ž…, ๋กœ๊ทธ์ธ, ๋งˆ์ดํŽ˜์ด์ง€ ์ด 3๊ฐ€์ง€ API์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋‹ค.

  • ํšŒ์›๊ฐ€์ž…, ๋กœ๊ทธ์ธ์€ ์ธ์ฆ์„ ํ•„์š”๋กœ ํ•˜์ง€ ์•Š๋Š”๋‹ค.
  • ๋งˆ์ดํŽ˜์ด์ง€์˜ ๊ฒฝ์šฐ ์ธ์ฆ์„ ํ•„์š”๋กœ ํ•˜๊ณ  ์ธ์ฆ์€ ๋ฐœ๊ธ‰๋œ ํ† ํฐ์„ ๊ฒ€์ฆํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์ˆ˜ํ–‰ํ•œ๋‹ค.

๋กœ๊ทธ์ธ ์ „,ํ›„๋กœ ๋งˆ์ดํŽ˜์ด์ง€ ์ ‘๊ทผ ๊ฐ€๋Šฅ์—ฌ๋ถ€๋กœ ํ…Œ์ŠคํŠธ๋ฅผ ์ง„ํ–‰ํ•œ๋‹ค.


๐Ÿ“Œ Jwt token ์ƒ์„ฑ ๋ฐ ๊ฒ€์ฆ์„ ์œ„ํ•œ ์œ ํ‹ธํด๋ž˜์Šค

@Component
class JwtUtils {

    val SECRET = "secret"
    val EXP_TIME = 1000 * 60 * 10

    // ํ† ํฐ์ƒ์„ฑ
    fun generateToken(userId: Long, username: String): String {
        return Jwts.builder()
            .setSubject(username)
            .claim("userId", userId)
            .setExpiration(Date(System.currentTimeMillis() + EXP_TIME))
            .signWith(SignatureAlgorithm.HS256, SECRET)
            .compact()
    }

    // ํ† ํฐ๊ฒ€์ฆ 
    fun verifyToken(token: String): Boolean {
        return try {
            val claims = getAllClaims(token)
            val expiration = claims.expiration
            expiration.after(Date())
        } catch (e: JwtException) {
            false
        } catch (e: IllegalArgumentException) {
            false
        }
    }

    // Claim ์กฐํšŒ
    fun getClaim(token: String, key: String ): Any? {
        val claims: Claims = getAllClaims(token)
        return claims[key]
    }

    private fun getAllClaims(token: String): Claims {
        return Jwts.parser()
            .setSigningKey(SECRET)
            .parseClaimsJwt(token).body
    }
}

๐Ÿ“Œ Http ์š”์ฒญ์—์„œ JWT ํ† ํฐ์„ ๋ฝ‘์•„๋‚ด๋Š” ์œ ํ‹ธ ํด๋ž˜์Šค

class JwtTokenExtractor {

    companion object{
        const val AUTHORIZATION_HEADER_PREFIX = "Authorization"
        const val BEARER_TYPE_PREFIX = "Bearer "

        fun extract(request: HttpServletRequest): String? {
            val authorization: String? = request.getHeader(AUTHORIZATION_HEADER_PREFIX)
            authorization?.let {
                if(authorization.startsWith(BEARER_TYPE_PREFIX)) {
                    return authorization.substring(BEARER_TYPE_PREFIX.length)
                }
            }
            return null
        }
    }
}

๐Ÿ“Œ SpringSecurity์˜ @AuthenticationPrincipal์„ ๋Œ€์‹ ํ•  Custom Annotation ์ž‘์„ฑ

Spring Security๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ ํ˜„์žฌ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์˜ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๊ธฐ ์œ„ํ•ด SecurityContext์˜ Authentication์„ ๊ฐ€์ ธ์˜ค๋Š” @AuthenticationPrincipal ์–ด๋…ธํ…Œ์ด์…˜์„ ๋งค์šฐ ์ž์ฃผ ์‚ฌ์šฉํ•œ๋‹ค. ๋™์ผํ•œ ๊ธฐ๋Šฅ์„ ๊ฐ€์ง€๋Š” ์–ด๋…ธํ…Œ์ด์…˜์„ ์ž‘์„ฑํ•ด๋ณธ๋‹ค.

์–ด๋…ธํ…Œ์ด์…˜ ํด๋ž˜์Šค ์ƒ์„ฑ

@Target(AnnotationTarget.VALUE_PARAMETER) // ์ƒ์„ฑ์ž ๋งค๊ฐœ๋ณ€์ˆ˜์— ์ ์šฉ
@Retention(AnnotationRetention.RUNTIME)
annotation class LoginUser()

์–ด๋…ธํ…Œ์ด์…˜ ๊ตฌํ˜„ ํด๋ž˜์Šค ์ƒ์„ฑ (HandlerMethodArgumentResolver)

@Component
class LoginUserArgumentResolver(
    private val jwtUtils: JwtUtils,
    private val userRepository: UserRepository
): HandlerMethodArgumentResolver {

    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.hasParameterAnnotation(LoginUser::class.java)
    }

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?
    ): User? {
        val httpServletRequest: HttpServletRequest = webRequest.getNativeRequest(HttpServletRequest::class.java) as HttpServletRequest
        // Http ์š”์ฒญ์—์„œ ํ† ํฐ์ถ”์ถœ
        val token = JwtTokenExtractor.extract(httpServletRequest)
        // ํ† ํฐ์ด ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ
        token?.let {
            if(jwtUtils.verifyToken(token)) { // ํ† ํฐ๊ฒ€์ฆ
                val userId = jwtUtils.getClaim(token, "id") // ๊ฒ€์ฆ๋œ ํ† ํฐ์—์„œ id๊ฐ’์„ ๋ฝ‘์•„์„œ User๋ฅผ ์กฐํšŒ
                return userRepository.findByIdOrNull(userId as Long)
            }
        }

        return null
    }

    // ํ† ํฐ์—์„œ user pk ์ถ”์ถœ
    private fun getUserIdFromToken(token: String): Long? {
        return jwtUtils.getClaim(token, "id") as Long?
    }
}

์ด์ œ ์ƒ์„ฑ์ž์—์„œ User๋ฅผ ๋ฐ›๋Š” ๊ฒฝ์šฐ @LoginUser๋ฅผ ๋ถ™์—ฌ์ฃผ๋ฉด ํ˜„์žฌ ํ† ํฐ์„ ์†Œ์œ ํ•œ User๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ๋‹ค.

๐Ÿ“Œ API ์•ž ๋‹จ์—์„œ ํ† ํฐ๊ฒ€์ฆ์„ ์ˆ˜ํ–‰ํ•  ์ธํ„ฐ์…‰ํ„ฐ ๊ตฌํ˜„

JwtUtils์˜ ๊ฒ€์ฆ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด์„œ ์š”์ฒญ์˜ ํ—ค๋”๊ฐ€ ํ† ํฐ์„ ํฌํ•จํ•˜๋Š” ๊ฒฝ์šฐ ๊ฒ€์ฆ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค.

@Component
class TokenVerifyInterceptor(
    private val jwtUtils: JwtUtils
) : HandlerInterceptor {

    override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
        val method = request.method
        // ์‚ฌ์ „์š”์ฒญ์˜ ๊ฒฝ์šฐ pass
        if (method.equals(HttpMethod.OPTIONS)) return true
	// Http ์š”์ฒญ์—์„œ ํ† ํฐ ์ถ”์ถœ
        val token: String? = JwtTokenExtractor.extract(request)
        // ํ† ํฐ์ด ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ
        token?.let {
            jwtUtils.verifyToken(it) // ํ† ํฐ๊ฒ€์ฆ
        } ?: throw AuthenticationException("์ธ์ฆ์‹คํŒจ")

        return true
    }
}

๐Ÿ“Œ WebMvcConfig

  • ๊ฒ€์ฆ์„ ์ˆ˜ํ–‰ํ•  ์ธํ„ฐ์…‰ํ„ฐ๋ฅผ ์ ์šฉํ•˜์ง€ ์•Š์„ API ์—”๋“œํฌ์ธํŠธ ์ง€์ • excludePathPatterns
    ์ด์™ธ API๋กœ์˜ ์š”์ฒญ์€ ๋ชจ๋‘ ๊ฒ€์ฆ ์ธํ„ฐ์…‰ํ„ฐ๊ฐ€ ๋™์ž‘ํ•˜๋„๋ก ํ•œ๋‹ค.
  • LoginUser ๊ตฌํ˜„๋ถ€ (ArgumentResolver) ๋“ฑ๋ก
@Configuration
class WebMvcConfig(
    private val tokenVerifyInterceptor: TokenVerifyInterceptor,
    private val loginUserArgumentResolver: LoginUserArgumentResolver
) : WebMvcConfigurer {

    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(tokenVerifyInterceptor)
            .addPathPatterns("/**")
            .excludePathPatterns("/user/signup", "/user/signin")
    }

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

๐Ÿ“Œ Controller ์ž‘์„ฑ

ํšŒ์›๊ฐ€์ž…(signup), ๋กœ๊ทธ์ธ(signin), ๋งˆ์ดํŽ˜์ด์ง€(me)

  • /users/me๋กœ ์ ‘๊ทผํ•˜๋Š” ๊ฒฝ์šฐ ํ† ํฐ๊ฒ€์ฆ ์ธํ„ฐ์…‰ํ„ฐ๋ฅผ ๊ฑฐ์น˜๊ณ  @LoginUser๋ฅผ ํ†ตํ•ด ํ˜„์žฌ ํ† ํฐ์„ ์†Œ์œ ํ•œ User์˜ ์ •๋ณด๋ฅผ ๋ฐ›์•„์™€์„œ ๊ทธ๋Œ€๋กœ ๋ฆฌํ„ดํ•œ๋‹ค.
@RequestMapping("/users")
@RestController
class UserController(private val userService: UserService) {

    @PostMapping("/signup")
    fun signup(@RequestBody signupRequest: SignupRequest): ResponseEntity<String>
        = ResponseEntity.status(HttpStatus.CREATED).body(userService.saveUser(signupRequest))

    @PostMapping("/signin")
    fun signin(@RequestBody signinRequest: SigninRequest): ResponseEntity<String>
        = ResponseEntity.ok().body(userService.signin(signinRequest))

    @GetMapping("/me")
    fun me(@LoginUser user: User?): ResponseEntity<User> {
        return ResponseEntity.ok().body(user)
    }
}

๐Ÿ“Œ Junit ํ…Œ์ŠคํŠธ

@Transactional
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@AutoConfigureMockMvc
@SpringBootTest
class UserTest {

    @Autowired lateinit var mockMvc: MockMvc
    @Autowired lateinit var objectMapper: ObjectMapper
    @Autowired lateinit var userRepository: UserRepository
    @Autowired lateinit var passwordEncoder: PasswordEncoder
    @Autowired lateinit var userService: UserService
    lateinit var user: User

    @BeforeAll
    fun beforeAll() {
        user = User(null, "test", passwordEncoder.encode("test"))
        userRepository.save(user)
    }


    @DisplayName("ํšŒ์›๊ฐ€์ž…")
    @Test
    fun `ํšŒ์›๊ฐ€์ž… ํ…Œ์ŠคํŠธ` () {
        val signupRequest = SignupRequest("username", "password")

        mockMvc.post("/users/signup")
        {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(signupRequest)
        }
            .andExpect {
                status { isCreated() }
            }
            .andDo {
                print()
            }
    }

    @DisplayName("์ธ์ฆ (ํ† ํฐ๋ฐœ๊ธ‰)")
    @Test
    fun `์ธ์ฆ ํ…Œ์ŠคํŠธ ํ† ํฐ๋ฐœ๊ธ‰์ด ์ œ๋Œ€๋กœ ๋˜๋Š”์ง€` () {
        val signinRequest = SigninRequest("test", "test")

        mockMvc.post("/users/signin")
        {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(signinRequest)
        }
            .andExpect {
                status { isOk() }
            }
            .andDo {
                print()
            }
    }

    @DisplayName("์ ‘๊ทผ๋ถˆ๊ฐ€ ํ…Œ์ŠคํŠธ")
    @Test
    fun `ํ† ํฐ์—†์ด ์ธ์ฆ์„ ํ•„์š”๋กœํ•˜๋Š” API์— ์ ‘๊ทผ ํ…Œ์ŠคํŠธ`() {
        try {
            mockMvc.get("/users/me")
                .andDo {
                    print()
                }
                .andExpect {
                    status {
                        is5xxServerError()
                    }
                }
        } catch (e: Exception) {
            println(e)
        }
    }

    @DisplayName("ํ† ํฐ์ธ์ฆ ํ…Œ์ŠคํŠธ")
    @Test
    fun `๋ฐœ๊ธ‰๋œ ํ† ํฐ์„ ์ด์šฉํ•œ ์ธ์ฆ ํ…Œ์ŠคํŠธ` () {
        val signinRequest = SigninRequest("test", "test")
        val token = userService.signin(signinRequest)

        mockMvc.get("/users/me")
        {
            header("Authorization", "Bearer " + token)
        }
            .andDo {
                print()
            }
            .andExpect {
                status { isOk() }
            }
    }
}

profile
์ข€ ๋” ์ฒœ์ฒœํžˆ ๊นŒ๋จน๊ธฐ ์œ„ํ•ด ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. ๐Ÿง

1๊ฐœ์˜ ๋Œ“๊ธ€

comment-user-thumbnail
2023๋…„ 3์›” 3์ผ

์ž˜ ๋ณด๊ณ  ๊ฐ‘๋‹ˆ๋‹ค!! ๐Ÿ‘๐Ÿผ

๋‹ต๊ธ€ ๋‹ฌ๊ธฐ