Spring Security OAuth2로 JWT 검증하기

게으른 사람·2022년 12월 18일
3
post-thumbnail

1. 개요

이 글에선 spring-security라이브러리로 쉽게 JWT를 검증하는 법을 소개합니다.
org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.0.0에서 작성된 글입니다.


2. 라이브러리 살펴보기

Spring Security는 OAuth2의 각 역할을 구현하기 위한 라이브러리를 제공합니다. 그 중 엑세스 토큰을 검증하고 자원을 제공하는 Resource Server를 구현하기 위해 다음과 같은 라이브러리를 제공합니다.


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

spring-security-oauth2-jose는 엑세스 토큰 구현체 중 JWT 관련 클래스를 제공합니다. nimbusds-jose-jwt라이브러리를 사용하며 JWT 인코딩/디코딩을 지원합니다.
주요 구성 클래스는 다음과 같습니다.

  • JwtDecoder: 토큰을 JWT 객체로 변환합니다.
  • NimbusJwtDecoder: JwtDecoder의 구현체이며 Spring에서 Nimbus를 사용한 로우레벨 클래스입니다.

그리고 Spring Boot는 위 라이브러리들을 묶어 spring-boot-starter-oauth2-resource-server를 제공합니다.


3. 구성하기

실제 구현코드를 작성해보며 알아보겠습니다.


build.gradle

// 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'
}

SecurityConfig.java

@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();
    }
}
  1. Spring에서 권장하는 메소드 빈 방식의 Security 설정 구성입니다. HttpSecurity 객체를 주입 받아 SecurityFilterChain 객체를 작성합니다.
  2. JwtAuthenticationConverter는 Spring에서 기본으로 제공하는 클래스입니다. 기본으로 제공하는 기능으로 충분하지만 조금의 수정을 위해 SimpleJwtAuthenticationConverter을 작성해주었습니다. 조금 뒤에 설명 더 붙이겠습니다.
  3. 경로 패턴에 인증을 부여합니다. 여기선 /auth/** 경로 접근은 SCOPE_acc 권한이 있어야만 접근 가능하도록 설정했습니다. 토큰 Claim 중 scp의 값이 acc인 토큰만 사용가능합니다.
  4. oauth2-resource-server 설정을 시작합니다.
  5. AccessDeniedHandler를 설정합니다. 여기선 CustomAccessDeniedHandler를 작성하였습니다.
  6. AuthenticationEntryPoint를 설정합니다. 여기선 CustomEntryPoint를 작성하였습니다.
  7. oauth2-jose 설정을 시작합니다.
  8. JwtAuthenticationConverter를 설정합니다. 여기선 SimpleJwtAuthenticationConverter빈을 주입했습니다.

SimpleJwtAuthenticationConverter.java

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);
}
  1. JwtAuthenticationConverter의 기본 형식인 JwtAbstractAuthenticationToken로 변환하는 Converter를 상속합니다.
  2. 기본 동작은 JwtAuthenticationConverter로 구현하기 때문에 필드에 생성합니다.
  3. convert()를 구현합니다. final로 선언해 재정의하지 못하도록합니다.
  4. JwtAuthenticationConverter를 사용하여 AbstractAuthenticationToken로 변환합니다.
  5. 이 클래스의 목적으로 JwtGrantedAuthority목록을 넘겨 하위 클래스에서 구현하도록합니다. 이 클래스의 구현체는 JwtGrantedAuthority목록을 사용하여 AbstractAuthenticationToken를 생성할 수 있습니다.

클래스 사용법은 다음과 같습니다.

@Component // 1
public class MemberJwtAuthenticationConverter extends SimpleJwtAuthenticationConverter {

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt, Collection<GrantedAuthority> authorities) {
        return new MemberJwtToken(jwt, authorities); // 2
    }
}
  1. 빈으로 등록하여 SecurityConfig에서 주입될 수 있도록합니다.
  2. 프로젝트에서 사용할 Jwt토큰 객체를 생성하여 사용합니다.

CustomAccessDeniedHandler.java

권한 불충분 시 에러를 핸들링합니다.

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();
    }
}
  1. 응답 상태를 정의합니다. 여기선 content_typeapplication/json 상태코드를 403으로 지정하였습니다.
  2. 실무에선 공통된 에러 객체로 리턴합니다.

CustomEntryPoint.java

토큰에러를 핸들링합니다.

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();
    }
}
  1. 에러 유형을 판단합니다. 대부분 invalidToken을 리턴합니다.

JwtDecoder

마지막으로 토큰 문자열을 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();
    }

4. JwtAuthenticationToken 객체 활용

JwtAuthenticationConverter로 인해 변환되는 JwtAuthenticationToken의 관계도입니다.

JwtAuthenticationTokenAuthentication을 상속하고 있습니다.
이 말인 즉 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를 통해 명확하게 토큰의 정보를 파악할 수 있습니다.


5. 결론

문자열 토큰이 Spring Security의 Authentication로 변환되는 과정을 Spring Security OAuth2는 많이 간소화 시켜줍니다.
예시의 전체 소스코드는 깃헙에서 확인할 수 있습니다.

예시에 토큰 생성 코드도 포함했습니다. Bearer 토큰을 생성하여 Authorization 헤더에 담아 검증을 테스트해보세요. 😄

profile
웹/앱 백앤드 개발자

1개의 댓글

comment-user-thumbnail
2022년 12월 26일

깔끔하네요~

답글 달기