이 글은 Spring Boot를 공부하며 정리한 글입니다.
인증과 인가는 개념적으로 다른내용인데, 많이들 헷갈리시는 내용이죠. 흔히 로그인을 구현할 때, 인증이란 접근을 시도하는 사용자가 시스템에 접근할 수 있는 사용자인지 확인하는 것이구요. 인가는 허가된 사용자에게 정해진 권한을 부여하는 행위를 의미합니다. 마찬가지로 서버를 이용하여 로그인을 구현하기 위해서는 인증과 인가는 필수적이죠. Spring boot에서 Spring Security와 JWT를 이용해서 해당 과정을 구현할 수 있습니다.
Spring Security는 어플리케이션의 보안을 담당하는 Spring 하위의 프레임워크입니다. 엄청나게 많은 기능이 있으나, 여기서는 인증과 인가를 위해 사용합니다.
JWT는 Json Web Token의 약자입니다. 로그인 기능을 구현할 경우 JWT는 굉장히 유명하죠. Header, Payload, Signature로 구성되어 있습니다.
Header는 토근의 유형과 서명 알고리즘을 명시하고, Payload는 claim이라고 불리는 사용자 인증, 인가 정보이며, Header와 Payload가 비밀키로 서명하는 것을 Signature라고 합니다.
JWT와 Spring Security를 사용하기 위해서는 아래와 같이 의존성을 추가해주어야 합니다.
dependencies {
...
// spring security
implementation("org.springframework.boot:spring-boot-starter-security")
// jwt 관련
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
...
}
JWT를 위해서 영문숫자 조합의 32글자 이상의 키가 필요합니다. 이 키는 application.yml파일안에 작성해두겠습니다.
...
jwt:
secret: ThisIsTestKeyThisIsTestKeyThisIsTestKeyThisIsTestKeyThisIsTestKeyThisIsTestKey
...
우리는 JWT를 이용해서 토큰을 발급받을 것입니다. 해당 토큰의 정보는 TokenInfo 라는 data class를 만들어서 담도록 하겠습니다.
data class TokenInfo(
val grantType : String,
val accessToken : String,
)
원래는 refresh도 사용하는 것이 일반적이나 해당 사항을 다루지 않겠습니다.
이제 토큰을 생성하고 토큰 정보를 추출하는 JwtTokenProvider를 만들겠습니다.
const val EXPIRATION_MILLISECONDS : Long = 1000 * 60 * 30
@Component
class JwtTokenProvider {
@Value("\${jwt.secret}")
lateinit var secretKey: String
private val key by lazy { Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)) }
}
상단에 선언한 EXPIRATION_MILLISECONDS은 토큰의 유효시간 설정을 위해 선언하였습니다. secretKey는 우리가 application.yml에 생성한 값을 이용하기 위해 @Value를 이용하였습니다. 그리고 마지막으로 key를 해당 secretKey를 이용하여 선언해 줍니다. 이제 토큰을 생성하기 위한 createToken 메소드를 만들겠습니다.
@Component
class JwtTokenProvider {
@Value("\${jwt.secret}")
lateinit var secretKey: String
private val key by lazy { Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)) }
// 토큰 생성
fun createToken(authentication: Authentication) : TokenInfo {
val authorities : String
= authentication
.authorities
.joinToString(",", transform = GrantedAuthority::getAuthority)
val now = Date()
val accessExpiration = Date(now.time+ EXPIRATION_MILLISECONDS)
val accessToken = Jwts
.builder()
.setSubject(authentication.name)
.claim("auth", authorities)
.setIssuedAt(now)
.setExpiration(accessExpiration)
.signWith(key, SignatureAlgorithm.HS256)
.compact()
return TokenInfo("Bearer", accessToken)
}
}
createToken은 Authentication 타입의 authentication을 넘겨받아 이전에 생성한 TokenInfo 로 반환해줄것입니다. 토큰을 만들기위한 authorities는 전달받은 authentication의 권한들을 ','를 기준으로 뽑아내어 선언합니다. 이제 본격적으로 생성할 토큰은 authentication의 name을 통하여 "auth"라는곳에 해당 권한들을 담고, 유효시간을 설정하면 토큰 생성이 완료됩니다. Bearer는 저희가 사용할 인증타입입니다. 이제 토큰 정보를 추출하는 getAuthentication 메소드를 만들어보겠습니다.
...
fun getAuthentication(token : String) : Authentication {
val claims : Claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.body
val auth = claims["auth"] ?: throw RuntimeException("잘못된 토큰입니다.")
val authorities : Collection<GrantedAuthority> = (auth as String)
.split(",")
.map { SimpleGrantedAuthority(it) }
val principal : UserDetails = User(claims.subject, "", authorities)
return UsernamePasswordAuthenticationToken(principal, "", authorities)
}
}
가장 먼저 claims값을 가져와야 됩니다. 이후 토큰이 정상적으로 생성되었다면 "auth"라는 곳에 authorities 정보가 담겨있겠죠? 해당값을 이용해 authorities값도 가져옵니다. 만약 존재하지 않는다면 그대로 에러를 throw합니다. 이후에 authorities를 이용하여 최종적으로 UsernamePasswordAuthenticationToken을 반환해줍니다. 이제, 생성한 토큰을 검증하는 validateToken 메소드를 생성하겠습니다. 이 메소드는 후에 토큰을 검사하는 과정에서 필요하게 됩니다.
fun validateToken(token : String) : Boolean {
try {
getClaims(token)
return true
} catch (e : Exception) {
println(e.message)
}
return false
}
private fun getClaims(token: String) : Claims {
return Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.body
}
토큰을 이용해서 claim 정보를 가져올 수 있다면 정상적으로 생성된 토큰이겠죠. 하지만 해당 과정에서 에러가 발생하게 된다면 문제가 있습니다. 따라서, claim을 가져올 수 있다면 true를 반환해주도록 하겠습니다. 그리고 claim을 가져오는 코드가 중복되므로 getClaims 함수를 만들어서 코드의 중복줄일 수 있습니다.
fun getAuthentication(token : String) : Authentication {
val claims : Claims = getClaims(token)
val auth = claims["auth"] ?: throw RuntimeException("잘못된 토큰입니다.")
val authorities : Collection<GrantedAuthority> = (auth as String)
.split(",")
.map { SimpleGrantedAuthority(it) }
val principal : UserDetails = User(claims.subject, "", authorities)
return UsernamePasswordAuthenticationToken(principal, "", authorities)
}
getAuthentication 함수는 위처럼 바꿔줄 수 있겠죠?
JwtAuthenticationFilter를 생성하겠습니다. 이 class는 JwtTokenProvider를 기본 생성자로 가지고, GenericFilterBean()을 상속받습니다.
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider
) : GenericFilterBean() {
override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
TODO("Not yet implemented")
}
}
상속을 시키면 doFilter 함수를 오버라이딩 해야합니다. 이제 요청으로부터 키값을 가져와야됩니다.
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider
) : GenericFilterBean() {
override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
TODO("Not yet implemented")
}
private fun resolveToken(request : HttpServletRequest) : String? {
val bearerToken = request.getHeader("Authorization")
return if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
bearerToken.substring(7)
} else {
null
}
}
}
요청을 이용해서 "Authorization"으로 시작되는 것을 찾아 "Bearer"라면 substring을 이용하여 7번째 인덱스를 가져오면 토큰정보입니다. 그 값을 반환해주면 됩니다.
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider
) : GenericFilterBean() {
override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
val token = resolveToken(request as HttpServletRequest)
if (token != null && jwtTokenProvider.validateToken(token)) {
val authentication = jwtTokenProvider.getAuthentication(token)
SecurityContextHolder.getContext().authentication = authentication
}
chain?.doFilter(request, response)
}
...
이제 요청을 받아서 resolveToken을 이용해 토큰정보를 받아 해당 값이 null 이 아니고 validation을 통과할 경우 authentication 을 가져올 수 있습니다. 그리고 SecurityContextHolder에 기록하여 이후에 사용할 수 있습니다.
기본적으로 Security Config 의존성을 주입하는 순간부터 모든 요청은 거부하게 됩니다. 그렇기에 config 파일을 작성하여 JWT에서 추출한 회원의 권한이 등록된 경우에만 요청을 허용하도록 하겠습니다. 추가적으로 사용자가 로그인, 회원가입하는 경우에는 권한이 없어도 되니 해당 api는 바로 통과할 수 있어야 합니다.
import ...
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val jwtTokenProvider: JwtTokenProvider
) {
@Bean
fun filterChain(http : HttpSecurity) : SecurityFilterChain {
http
.csrf { it.disable() }
.cors { it.disable() }
.httpBasic { it.disable() }
.sessionManagement {
it.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
}
.authorizeHttpRequests {
it.requestMatchers("/api/member/join", "/api/member/login", "/api/member/refresh").anonymous()
.requestMatchers("/api/**").hasRole("MEMBER")
.anyRequest().permitAll()
}
.addFilterBefore(
JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter::class.java
)
return http.build()
}
@Bean
fun passwordEncoder() : PasswordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder()
}
config 파일은 @Configuration 어노테이션을 붙여야 합니다. 게다가 Security Config 는 자체적으로 서비스의 보안을 담당하기에 @EnableWebSecurity 어노테이션을 붙여야 합니다. 한마디로 Security Config 파일은 무조건 저 어노테이션들을 붙이는게 일반적입니다.
config 파일에서는 해당 설정을 Bean으로 주입하게 됩니다. 그렇게 되면 서비스가 시작되면서 Bean에 등록된 설정이 주입되는 것이지요. @Bean 어노테이션을 붙여주어야 합니다.
JWT를 사용하면 일반적으로 사용하는 세션 방식은 사용하지 않습니다. 따라서 위 코드를 보면 세션과 관련된 설정을 STATELESS로 지정한 것을 볼 수 있습니다.