이 글에선 spring-security
라이브러리로 쉽게 JWT를 검증하는 법을 소개합니다.
org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.0.0
에서 작성된 글입니다.
Spring Security는 OAuth2의 각 역할을 구현하기 위한 라이브러리를 제공합니다. 그 중 엑세스 토큰을 검증하고 자원을 제공하는 Resource Server를 구현하기 위해 다음과 같은 라이브러리를 제공합니다.
spring-security-oauth2-resource-server
는 액세스 토큰 검증 기능을 제공합니다. JWT와 Opaque토큰을 지원하며 토큰 검증 오류와 권한 오류에 대한 핸들링도 지원합니다.
주요 구성 클래스는 다음과 같습니다.
JwtAuthenticationConverter
: JWT 토큰을 Spring Security의 Authentication
객체로 변환시킵니다.JwtGrantedAuthoritiesConverter
: JWT 토큰 Claims 중 scope나 scp를 포함하면 그 Claim을 Spring Security의 Authentication
에 권한으로 부여합니다.AccessDeniedHandler
: 토큰의 권한이 올바르지 않을 시 에러를 핸들링합니다. AuthenticationEntryPoint
: 엑세스 토큰이 잘못된 토큰일 시 에러를 핸들링합니다.spring-security-oauth2-jose
는 엑세스 토큰 구현체 중 JWT 관련 클래스를 제공합니다. nimbusds-jose-jwt
라이브러리를 사용하며 JWT 인코딩/디코딩을 지원합니다.
주요 구성 클래스는 다음과 같습니다.
JwtDecoder
: 토큰을 JWT 객체로 변환합니다.NimbusJwtDecoder
: JwtDecoder
의 구현체이며 Spring에서 Nimbus를 사용한 로우레벨 클래스입니다.그리고 Spring Boot는 위 라이브러리들을 묶어 spring-boot-starter-oauth2-resource-server
를 제공합니다.
실제 구현코드를 작성해보며 알아보겠습니다.
// build.gradle
...
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain jwtChain(
HttpSecurity http, // 1
SimpleJwtAuthenticationConverter jwtAuthenticationConverter // 2
) throws Exception {
http.authorizeHttpRequests()
.requestMatchers("/auth/**").hasAuthority("SCOPE_acc") // 3
.anyRequest().permitAll()
.and()
.oauth2ResourceServer() // 4
.accessDeniedHandler(new CustomAccessDeniedHandler()) // 5
.authenticationEntryPoint(new CustomEntryPoint()) // 6
.jwt() // 7
.jwtAuthenticationConverter(jwtAuthenticationConverter); // 8
return http.build();
}
}
HttpSecurity
객체를 주입 받아 SecurityFilterChain
객체를 작성합니다.JwtAuthenticationConverter
는 Spring에서 기본으로 제공하는 클래스입니다. 기본으로 제공하는 기능으로 충분하지만 조금의 수정을 위해 SimpleJwtAuthenticationConverter
을 작성해주었습니다. 조금 뒤에 설명 더 붙이겠습니다./auth/**
경로 접근은 SCOPE_acc
권한이 있어야만 접근 가능하도록 설정했습니다. 토큰 Claim 중 scp
의 값이 acc
인 토큰만 사용가능합니다.oauth2-resource-server
설정을 시작합니다.AccessDeniedHandler
를 설정합니다. 여기선 CustomAccessDeniedHandler
를 작성하였습니다.AuthenticationEntryPoint
를 설정합니다. 여기선 CustomEntryPoint
를 작성하였습니다.oauth2-jose
설정을 시작합니다.JwtAuthenticationConverter
를 설정합니다. 여기선 SimpleJwtAuthenticationConverter
빈을 주입했습니다.JwtAuthenticationConverter
는 Jwt를 JwtAuthenticationToken
객체로 변환합니다. 이 객체는 Jwt
, GrantedAuthority
(권한 객체), name
(주 claim 값, 기본값은 sub)로 구성되어있어 주 claim 값을 하나만 사용하는 Jwt라면 사용하는데 문제없지만 실무에서는 주 claim 값 이외에도 다양한 값을 담아 사용하기 때문에 커스텀을 해주겠습니다.
public abstract class SimpleJwtAuthenticationConverter
implements Converter<Jwt, AbstractAuthenticationToken> { // 1
private final JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); // 2
@Override
public final AbstractAuthenticationToken convert(Jwt jwt) { // 3
AbstractAuthenticationToken token = jwtAuthenticationConverter.convert(jwt); // 4
Collection<GrantedAuthority> authorities = token.getAuthorities();
return convert(jwt, authorities); // 5
}
public abstract AbstractAuthenticationToken convert(Jwt jwt, Collection<GrantedAuthority> authorities);
}
JwtAuthenticationConverter
의 기본 형식인 Jwt
를 AbstractAuthenticationToken
로 변환하는 Converter
를 상속합니다.JwtAuthenticationConverter
로 구현하기 때문에 필드에 생성합니다.convert()
를 구현합니다. final로 선언해 재정의하지 못하도록합니다.JwtAuthenticationConverter
를 사용하여 AbstractAuthenticationToken
로 변환합니다.Jwt
와 GrantedAuthority
목록을 넘겨 하위 클래스에서 구현하도록합니다. 이 클래스의 구현체는 Jwt
와 GrantedAuthority
목록을 사용하여 AbstractAuthenticationToken
를 생성할 수 있습니다.클래스 사용법은 다음과 같습니다.
@Component // 1
public class MemberJwtAuthenticationConverter extends SimpleJwtAuthenticationConverter {
@Override
public AbstractAuthenticationToken convert(Jwt jwt, Collection<GrantedAuthority> authorities) {
return new MemberJwtToken(jwt, authorities); // 2
}
}
SecurityConfig
에서 주입될 수 있도록합니다.권한 불충분 시 에러를 핸들링합니다.
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE); // 1
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write("권한 불충분 %s".formatted(accessDeniedException.getMessage())); // 2
response.getWriter().flush();
}
}
content_type
을 application/json
상태코드를 403으로 지정하였습니다.토큰에러를 핸들링합니다.
public class CustomEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) throws IOException {
OAuth2Error error = ex instanceof OAuth2AuthenticationException ? // 1
((OAuth2AuthenticationException) ex).getError() :
BearerTokenErrors.invalidToken(ex.getMessage());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding(Charset.defaultCharset().name());
response.getWriter().write(error.getErrorCode());
response.getWriter().flush();
}
}
마지막으로 토큰 문자열을 Jwt 객체로 변환하는 JwtDecoder
를 등록해주어야 합니다.
기본적으로 public키가 있다면 jwk-set-url이나 public키 파일을 등록해주면 JwtDecoder
는 자동으로 주입됩니다.
spring.security.oauth2.resourceserver.jwt.jwt-set-uri
spring.security.oauth2.resourceserver.jwt.public-key-location
하지만 public키가 없는 단순 secret키를 사용하는 알고리즘을 사용한다면 JwtDecoder
를 등록해주어야 합니다.
예시로 HS256 알고리즘의 JwtDecoder
를 생성 코드입니다.
@Bean
public JwtDecoder jwtDecoder() {
MacAlgorithm algorithm = MacAlgorithm.HS256;
return NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secretKey.getBytes(), algorithm.getName()))
.macAlgorithm(algorithm)
.build();
}
JwtAuthenticationConverter
로 인해 변환되는 JwtAuthenticationToken
의 관계도입니다.
JwtAuthenticationToken
은 Authentication
을 상속하고 있습니다.
이 말인 즉 Spring Security에서 Authentication
를 활용하던 방식을 그대로 활용할 수 있습니다.
자주 사용하는 기능은 SecurityContextHolder
에서 Authentication
를 가져오는 겁니다.
Jwt 검증이 통과되었다면 다음과 같이 JwtAuthenticationToken
객체를 가져올 수 있습니다.
public class MemberJwtToken extends JwtAuthenticationToken {
private final Long memberId;
public MemberJwtToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities) {
super(jwt, authorities);
this.memberId = Long.valueOf(jwt.getClaimAsString("member_id"));
}
public Long getMemberId() {
return memberId;
}
}
public class MemberJwtContextHolder {
public static MemberJwtToken getMemberJwtToken() {
return (MemberJwtToken) SecurityContextHolder.getContext().getAuthentication();
}
}
임의의 JwtAuthenticationToken
객체를 정의하고 SecurityContextHolder
에서 해당 객체를 꺼내옵니다. 개발자는 MemberJwtToken
를 통해 명확하게 토큰의 정보를 파악할 수 있습니다.
문자열 토큰이 Spring Security의 Authentication
로 변환되는 과정을 Spring Security OAuth2는 많이 간소화 시켜줍니다.
예시의 전체 소스코드는 깃헙에서 확인할 수 있습니다.
예시에 토큰 생성 코드도 포함했습니다. Bearer 토큰을 생성하여 Authorization 헤더에 담아 검증을 테스트해보세요. 😄
깔끔하네요~