๐Ÿ’ป ๊ฐœ๋ฐœ ์ผ์ง€/SpringBoot

[SpringBoot/Kotlin] JWT + SpringSecurity๋ฅผ ํ™œ์šฉํ•œ ํšŒ์› API ๊ตฌํ˜„ (2) - JWT ๋กœ๊ทธ์ธ

์ ์ด 2023. 11. 12. 22:16
๋ฐ˜์‘ํ˜•
๐Ÿ’ก ๋ณธ๊ฒฉ์ ์œผ๋กœ JWT๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋กœ๊ทธ์ธ์„ ๊ตฌํ˜„ํ•œ๋‹ค.
ํ•ด๋‹น ์žฅ์—์„œ๋Š” refreshToken ์„ ๊ณ ๋ คํ•˜์ง€ ์•Š๋Š”๋‹ค! (๋‹ค์Œ ์žฅ์—์„œ ๊ตฌํ˜„ ์˜ˆ์ • โœ…)

 

โžก๏ธ 1ํƒ„ ๋ฐ”๋กœ๊ฐ€๊ธฐ: SpringSecurity ์„ค์ • ๋ฐ ํšŒ์›๊ฐ€์ž…

โžก๏ธ 3ํƒ„ ๋ฐ”๋กœ๊ฐ€๊ธฐ: RefreshToken


CustomUser ์ƒ์„ฑ

SpringSecurity์—์„œ ์‚ฌ์šฉํ•˜๋Š” `User` ํด๋ž˜์Šค๋ฅผ ์ƒ์†๋ฐ›์•„, ๋Œ€์ฒดํ•  ์ˆ˜ ์žˆ๋Š” `CustomUser` ํด๋ž˜์Šค ์ƒ์„ฑ
//CustomUser.kt

class CustomUser (
    val id: UUID,
    userName: String,
    password: String,
    authorities: Collection<GrantedAuthority>
): User(userName, password, authorities)

CustomUserDetailsService

SpringSecurity์—์„œ ์‚ฌ์šฉ๋˜๋Š” `UserDetailsService` ๊ฐ€ `CustomUser` ๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ์„œ๋น„์Šค ์ƒ์„ฑ

ํ•ด๋‹น ์„œ๋น„์Šค๋ฅผ Bean์œผ๋กœ ๋“ฑ๋กํ•˜๊ฒŒ ๋˜๋ฉด, ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์—์„œ ์ž๋™์œผ๋กœ `UserDetailsService` ๋กœ ์ธ์‹

โžก๏ธ SpringSecurity์—์„œ ์ œ๊ณตํ•˜๋Š” ์ธ์ฆ/์ธ๊ฐ€๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ, CustomUserDetailService๋ฅผ ์‚ฌ์šฉ (overrideํ•œ ๋ฉ”์†Œ๋“œ์˜ ์—ญํ• !)

โžก๏ธ ์„œ๋น„์Šค์—์„œ ๋งŒ๋“  CustomUser๋กœ SpringSecurity์˜ ์ธ์ฆ/์ธ๊ฐ€ ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ

@Service
class CustomUserDetailsService(
    private val memberRepository: MemberRepository
): UserDetailsService {
    override fun loadUserByUsername(username: String?): UserDetails =
        memberRepository.findByEmail(username)
            ?.let { createUserDetails(it) } ?: throw UsernameNotFoundException("No User Info")

    private fun createUserDetails(member: Member) : UserDetails =
        CustomUser(
            member.id!!,
            member.email,
            member.password,
            listOf(SimpleGrantedAuthority("ROLE_${member.type}"))
        )
}

`loadUserByUsername()`

  • username์„ ๊ฐ€์ง„ User๊ฐ€ ์„œ๋น„์Šค DB์— ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๋ฉ”์†Œ๋“œ
  • ๊ตฌํ˜„ํ•œ `memberRepository`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ User๋ฅผ ์ฐพ๊ณ , ํ•ด๋‹น ์œ ์ €๋ฅผ CustomUser๋กœ ์ปจ๋ฒ„ํŒ…ํ•˜์—ฌ Return

JWT Token Provider ์ƒ์„ฑ

JWT ์†์„ฑ ์ •๋ณด

JWT ๋ฅผ ์‚ฌ์šฉํ•˜๋Š”๋ฐ์— ํ•„์š”ํ•œ ์†์„ฑ ์ •๋ณด๋ฅผ application.yml์— ์ง€์ •
  • ํ•ด๋‹น ํŒŒ์ผ์€ secretKey๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฏ€๋กœ, ์‹ค ์„œ๋น„์Šค์˜ ๊ฒฝ์šฐ Github ๋ฐ ์™ธ๋ถ€์— ๋…ธ์ถœ๋˜์ง€ ์•Š๋„๋ก ์ฃผ์˜!
  • `secret` : JWT ํ† ํฐ์„ ๋งŒ๋“œ๋Š”๋ฐ์— ์‚ฌ์šฉํ•  secret key ์ง€์ •
jwt:
    secret: AMmk3J4jybMhXbDqfvF8hg4e0MkloHUd

Token Provider ์ƒ์„ฑ

์‹ค์ œ ํ† ํฐ์„ ๋‹ค๋ฃจ๋Š” TokenProvider Component ์ƒ์„ฑ: ํ† ํฐ ์ƒ์„ฑ, ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋“ฑ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค.

CreateToken: ํ† ํฐ ์ƒ์„ฑ

@Component
class TokenProvider(
    @Value("\${jwt.secret}") private val secret: String,
    @Value("\${jwt.expiration-minutes}") private val expirationMinutes: Long
) {

    private val key by lazy { Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)) }

    /**
     * ํ† ํฐ ์ƒ์„ฑ
     */
    fun createAccessToken(authentication: Authentication): String {
        val auth = authentication.authorities.joinToString(",", transform = GrantedAuthority::getAuthority)
        val userId = (authentication.principal as CustomUser).id

        return Jwts.builder()
            .setIssuedAt(Timestamp.valueOf(LocalDateTime.now()))
            .setExpiration(Date.from(Instant.now().plus(expirationMinutes, ChronoUnit.MINUTES)))
            .setSubject(authentication.name)
            .claim("auth", auth)
            .claim("userId", userId)
            .signWith(key, SignatureAlgorithm.HS256).compact()
    }
}
  • `auth` : User์˜ ๊ถŒํ•œ๋“ค(`authorities`) ์„ `,` ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์ŠคํŠธ๋ง ์ƒ์„ฑ -> claim์— ์ €์žฅ
  • `userId`: CustomUser Id ์ฆ‰, MemberId๋ฅผ ๊ฐ€์ ธ์˜ด -> claim์— ์ €์žฅ
  • JWT builder
    • `setIssuedAt`: ํ† ํฐ ์ƒ์„ฑ ์‹œ๊ฐ„
    • `setExpiration` : ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ๊ฐ„
      • Refresh ํ† ํฐ์„ ์•„์ง ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์ง€๋งŒ, ๊ตฌํ˜„ํ•œ๋‹ค๋Š” ๊ฐ€์ •ํ•˜์— 30๋ถ„์œผ๋กœ ์ง€์ •
    • `signWith`
      • `key` : yml์— ์ž‘์„ฑํ•œ secretKey๋ฅผ ๋””์ฝ”๋”ฉ ํ•˜์—ฌ `HmacSHA` ํ‚ค๋กœ ์ƒ์„ฑ
      • `signWith` : key์™€ ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ์ง€์ •ํ•˜์—ฌ JWT์— ์„œ๋ช…์„ ์ถ”๊ฐ€

 

GetAuthentication: ํ† ํฐ ์ •๋ณด ์ถ”์ถœ

@Component
class TokenProvider(
    ...
) {
    ...
    /**
     * ํ† ํฐ ์ •๋ณด ์ถ”์ถœ
     */
    fun getAuthentication(token: String): Authentication {
        val claims: Claims = getClaimsWithValidation(token)
        return getAuthentication(claims)
    }
    
    // Claim ์ถ”์ถœ
    private fun getClaimsWithValidation(token: String): Claims =
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).body
    
    // Claim ๋‚ด, Token ์ •๋ณด ์ถ”์ถœ
    private fun getAuthentication(claims: Claims): Authentication {
        val auth = claims["auth"] ?: throw RuntimeException("๊ถŒํ•œ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
        val userId = claims["userId"] ?: throw RuntimeException("๊ถŒํ•œ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")

        val authorities: Collection<GrantedAuthority> = (auth as String).split(",").map { SimpleGrantedAuthority(it) }

        val principal: UserDetails = CustomUser(UUID.fromString(userId.toString()), claims.subject, "", authorities)

        return UsernamePasswordAuthenticationToken(principal, "", authorities)
    }
    
}
  • `getClaimsWithValidation()` : `createToken` ์‹œ ๋„ฃ์–ด์ค€ Claim ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ด
    • `parseClaimsJws()`: Token์˜ Validation๋„ ๊ฐ™์ด ์ง„ํ–‰ -> Validation์— ๋งž์ง€ ์•Š์„ ๊ฒฝ์šฐ ๊ทธ์— ๋งž๋Š” Exception์„ ๋ฑ‰์Œ
      • `ExpiredJwtException`, `UnsupportedJwtException`, `MalformedJwtException`, `SignatureException`, `IllegalArgumentException`
  • `getAuthentication()` : Claim ์† ์ •๋ณด ์ถ”์ถœ
    • `auth`: ์œ ์ €์˜ ๊ถŒํ•œ ๋ฆฌ์ŠคํŠธ
    • `userId`: CustomUser์— ์ €์žฅํ•œ MemberId -> Member ์Šคํ‚ค๋งˆ์˜ Key ๊ฐ’
  • ์ตœ์ข…์ ์œผ๋กœ `UserDetails` ์™€ ๊ถŒํ•œ ์ •๋ณด๋ฅผ ๋‹ด์€ `Authentication` ์„ ๋งŒ๋“ค์–ด ๋ฆฌํ„ด

JwtAuthenticationFilter

๋งค ์š”์ฒญ๋งˆ๋‹ค ํ† ํฐ์„ ๊ฒ€์ฆํ•  Filter ์ƒ์„ฑ

@Order(0)
@Component
class JwtAuthenticationFilter(
    private val tokenProvider: TokenProvider
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain
    ) {
        resolveToken(request, TokenInfo.TokenType.ACCESS_TOKEN.value)?.let { token ->
            handleAuthServlet(request, response) {
                (tokenProvider.getAuthentication(token) to token)
            }
        }

        filterChain.doFilter(request, response)
    }

    private fun resolveToken(request: HttpServletRequest, headerName: String): String? {
        val bearerToken = request.getHeader(headerName)
        return if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TokenInfo.TokenGrantType.BEARER.value)) {
            bearerToken.substring(7)
        } else null
    }

    private fun handleAuthServlet(
        request: HttpServletRequest, response: HttpServletResponse, resolveAuthInfo:() -> Pair<Authentication, String>
    ) = try {
        val (authentication, token) = resolveAuthInfo()
        SecurityContextHolder.getContext().authentication = authentication
        response.setHeader(TokenInfo.TokenType.ACCESS_TOKEN.value, token)
    } catch (e: Exception) {
        request.setAttribute("exception", e)
    }

}
  • `resolveToken()`: `request`์˜ Header์— ๋‹ด๊ธด ํ† ํฐ์„ ๊ฐ€์ ธ์˜จ๋‹ค.
    • ํ•ด๋‹น ํ† ํฐ์˜ GrantType(`Bearer`)์„ ์ œ์™ธํ•˜๊ณ  ํ† ํฐ๋งŒ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
  • ํ† ํฐ์ด ์žˆ๋‹ค๋ฉด, `reolveToken` ์„ ์ง„ํ–‰ํ•˜์—ฌ `authentication` ์ •๋ณด๋ฅผ `SecurityContextHolder` ์— ์ €์žฅํ•œ๋‹ค.
    • ์„ฑ๊ณต ์‹œ, Context ์ €์žฅ ๋ฐ response Header ์ €์žฅ
    • ์‹คํŒจ ์‹œ, request Attribute๋กœ ์˜ˆ์™ธ ์ €์žฅ

 

SecureConfig ํ•„ํ„ฐ ๋“ฑ๋ก

์ด์ „ ํŽธ์— ์ƒ์„ฑํ–ˆ๋˜ `SecureConfig > filterChain` ์— `JwtAuthenticationFilter` ๋ฅผ ๋“ฑ๋ก
http    
    .httpBasic { it.disable() }
    .csrf { it.disable() }
    .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
    .authorizeHttpRequests {
        it.requestMatchers("/auth/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
            .anyRequest().authenticated()
    }
    .addFilterBefore(
        JwtAuthenticationFilter(tokenProvider),
        UsernamePasswordAuthenticationFilter::class.java
    )
    .build()

UsernamePasswordAuthenticationFilter(SpringSecurity ํ•„ํ„ฐ) ๋ฅผ ์‹คํ–‰ํ•˜๊ธฐ ์ „, ๊ตฌํ˜„ํ•œ JwtAuthenticationFilter๋ฅผ ์‹คํ–‰ํ•˜๋„๋ก ๋“ฑ๋ก


Login ์„œ๋น„์Šค ๊ตฌํ˜„

Controller

data class TokenInfo(
    val grantType: TokenGrantType = TokenGrantType.BEARER,
    val accessToken: String
) {
    enum class TokenGrantType(val value: String) {
        BEARER("Bearer")
    }
}
@RequestMapping("/auth")
@RestController
class SignController(
    private val signService: SignService
) {
    ...
    @PostMapping("/sign-in")
    fun login(@RequestBody signInRequest: SignInRequest): BaseResponse<TokenInfo> =
        signService.signIn(signInRequest).let {
            BaseResponse(data = it)
        }
}

accessToken์„ ๋‹ด์€ TokenInfo ๊ฐ์ฒด๋ฅผ Service๋กœ๋ถ€ํ„ฐ ๋ฐ›์•„ BaseResponse๋กœ ๊ฐ์‹ธ return ํ•œ๋‹ค.

Service

@Transactional
@Service
class SignService(
    ...
    private val authenticationManagerBuilder: AuthenticationManagerBuilder,
    private val tokenProvider: TokenProvider
) {
    ...
    @Transactional
    fun signIn(signInRequest: SignInRequest): TokenInfo {
        val authenticationToken = UsernamePasswordAuthenticationToken(signInRequest.email, signInRequest.password)
        val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)

        val accessToken = tokenProvider.createAccessToken(authentication)

        return TokenInfo(accessToken = accessToken)
    }
}
  • Controller๋กœ ๋ฐ›์€ ๋กœ๊ทธ์ธ ์ •๋ณด๋กœ `UsernamePasswordAuthenticationToken` ์ƒ์„ฑ
  • `authenticate` : `CustomUserDetailsService` ์˜ `loadUserByUsername` ์ด ์‹คํ–‰๋˜์–ด, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ Member ์ •๋ณด์™€ ์ผ์น˜ํ•˜๋Š”์ง€ ๋น„๊ต
  • ์œ„ ๋กœ์ง์ด ๋ฌธ์ œ ์—†๋‹ค๋ฉด, ํ•ด๋‹น authentication ์ •๋ณด๋กœ `AccessToken` ์„ ์ƒ์„ฑํ•˜์—ฌ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ „๋‹ฌ

Memberinfo ์„œ๋น„์Šค ๊ตฌํ˜„

MemberInfo ์„œ๋น„์Šค๋Š” Header์— ์žˆ๋Š” Access-Token์„ ๊ฐ€์ง€๊ณ , ํ•ด๋‹น ํšŒ์›์˜ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” API

Controller

@RequestMapping("/member")
@RestController
class MemberController(
    private val memberService: MemberService
) {
    @GetMapping("/info")
    fun getMyInfo(): BaseResponse<MemberInfoResponse> {
        val userId = (SecurityContextHolder.getContext().authentication.principal as CustomUser).id
        val userInfo = memberService.getInfo(userId)
        return BaseResponse(data = userInfo)
    }
}
  • `JwtAuthenticationFilter` ์—์„œ ์ €์žฅํ•œ ์ธ์ฆ ์ •๋ณด๋ฅผ ํ™œ์šฉํ•˜์—ฌ `userId` ์ฆ‰ MemberId๋ฅผ ๊ฐ€์ ธ์˜ด
  • ํ•ด๋‹น MemberId๋ฅผ ๊ฐ€์ง„ Member์˜ ์ •๋ณด๋ฅผ Service๋กœ๋ถ€ํ„ฐ ๊ฐ€์ ธ์˜ด

Service

@Service
class MemberService(
    private val memberRepository: MemberRepository
) {
    fun getInfo(id: UUID): MemberInfoResponse {
        val member = memberRepository.findByIdOrNull(id) ?: throw InvalidPropertiesFormatException("ํšŒ์› ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
        return member.toInfoDto()
    }
}

๊ฒฐ๊ณผ

Login

Member Info

์œ„ ๋กœ๊ทธ์ธ์‹œ์— ๋ฐ›์€ accessToken์„ Header์— ๋„ฃ์–ด ์š”์ฒญ

 


โœ”๏ธ์ฝ”๋“œ๋Š” ์•„๋ž˜์—์„œโœ”๏ธ

https://github.com/jeongum/spring-security-kotlin

 

GitHub - jeongum/spring-security-kotlin

Contribute to jeongum/spring-security-kotlin development by creating an account on GitHub.

github.com

 

๋ฐ˜์‘ํ˜•