๐ก ๋ณธ๊ฒฉ์ ์ผ๋ก JWT๋ฅผ ํ์ฉํ์ฌ ๋ก๊ทธ์ธ์ ๊ตฌํํ๋ค.
ํด๋น ์ฅ์์๋ refreshToken ์ ๊ณ ๋ คํ์ง ์๋๋ค! (๋ค์ ์ฅ์์ ๊ตฌํ ์์ โ )
โก๏ธ 1ํ ๋ฐ๋ก๊ฐ๊ธฐ: SpringSecurity ์ค์ ๋ฐ ํ์๊ฐ์
โก๏ธ 3ํ ๋ฐ๋ก๊ฐ๊ธฐ: RefreshToken
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` ๋ก ์ธ์
โก๏ธ ์๋น์ค์์ ๋ง๋ 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 ์์ฑ ์ ๋ณด
-
ํด๋น ํ์ผ์ secretKey๋ฅผ ๊ฐ์ง๊ณ ์์ผ๋ฏ๋ก, ์ค ์๋น์ค์ ๊ฒฝ์ฐ Github ๋ฐ ์ธ๋ถ์ ๋ ธ์ถ๋์ง ์๋๋ก ์ฃผ์!
-
`secret` : JWT ํ ํฐ์ ๋ง๋๋๋ฐ์ ์ฌ์ฉํ secret key ์ง์
- ์๋ฌธ ๋ฐ ์ซ์ ์กฐํฉ์ผ๋ก 32์ ์ด์
- https://acte.ltd/utils/randomkeygen
jwt:
secret: AMmk3J4jybMhXbDqfvF8hg4e0MkloHUd
Token Provider ์์ฑ
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`
- `parseClaimsJws()`: Token์ Validation๋ ๊ฐ์ด ์งํ -> Validation์ ๋ง์ง ์์ ๊ฒฝ์ฐ ๊ทธ์ ๋ง๋ Exception์ ๋ฑ์
- `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 ํํฐ ๋ฑ๋ก
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 ์๋น์ค ๊ตฌํ
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
โ๏ธ์ฝ๋๋ ์๋์์โ๏ธ
https://github.com/jeongum/spring-security-kotlin