Spring Security 6 - 토큰 기반 인증 유지 | OAuth2 resource server | JWT (작성중)

개요

2024.08.30 - [Spring/Spring Security] - Spring Security 6 - 일반 로그인 | 폼 로그인

2024.08.31 - [Spring/Spring Security] - Spring Security 6 - OAuth 2 Client 간편 로그인 | 카카오 로그인

2024.09.01 - [Spring/Spring Security] - Spring Security 6 - 세션 기반 인증 유지 | Authentication Persistence

지난 글 들에서 로그인 기능과 세션 기반 인증 유지 방법에 대해서 알아보았다.
이번 글에선 JWT 관련 컴포넌트를 제공해주는 spring-security-oauth2-resource-server 패키지와 구성방법에 대해서 알아볼 것이다.
이후에는 커스텀하여 JWT를 세션의 attribute에서 resolve 하는 방법도 알아볼 것이다.

추가된 코드

build.gradle

    implementation 'org.springframework.security:spring-security-oauth2-resource-server'

SecurityConfig

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.oauth2ResourceServer(conf -> conf.jwt(
            jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverterGenerator())));
    }


    private Converter<Jwt, UsernamePasswordAuthenticationToken> jwtAuthenticationConverterGenerator() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
        return (Jwt jwtSrc) -> {
            String id = jwtSrc.getSubject();
            String username = jwtSrc.getClaimAsString("username");
            Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(
                jwtSrc);
            Set<UserRole> roles = authorities.stream()
                                             .map(GrantedAuthority::getAuthority)
                                             .map(UserRole::valueOf)
                                             .collect(Collectors.toSet());
            Provider provider = new Provider(Long.parseLong(id), username, roles);
            return new UsernamePasswordAuthenticationToken(provider, null, authorities);
        };
    }

    private void successHandler(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) throws IOException {
        Object principal = authentication.getPrincipal();
        if (principal instanceof Me me) {
            sendResponse(response, new JwtResponse(me.toProvider()));
            return;
        }
        sendResponse(response, "로그인에 성공했습니다.");
    }

    @Getter
    private final class JwtResponse {

        private final OAuth2AccessToken accessToken;

        private JwtResponse(Provider provider) {
            Instant issuedAt = Instant.now();
            accessToken = toAccessToken(provider, issuedAt);
        }

        private OAuth2AccessToken toAccessToken(Provider provider, Instant issuedAt) {
            Instant expiredAt = issuedAt.plus(Duration.ofSeconds(ACCESS_TOKEN_EXPIRES_IN));
            JwtClaimsSet claims = JwtClaimsSet.builder()
                                              .claims((claim) -> claim.putAll(provider.toClaims()))
                                              .expiresAt(expiredAt)
                                              .issuedAt(issuedAt)
                                              .build();
            Jwt encode = jwtEncoder.encode(JwtEncoderParameters.from(claims));
            return new OAuth2AccessToken(TokenType.BEARER, encode.getTokenValue(), issuedAt,

    }

Provider

public record Provider(long userId, String username, Set<UserRole> roles) {
    public Map<String, Object> toClaims() {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        claims.put("sub", String.valueOf(userId));
        claims.put("authorities", Strings.join(roles, ' '));
        return claims;
    }
    // provider 속성 변경과 security config 과의 의존성을 줄이기 위해 분리했다.
    //            JwtClaimsSet claims = JwtClaimsSet.builder()
    //                                              .claims((claim) -> claim.putAll(provider.toClaims())
}

JwtBeanGenerator

 @Bean
    public JwtDecoder jwtDecoder(RSAKey rsaKey) throws JOSEException {
        return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey())
                               .build();
    }

    @Bean
    public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
        return new NimbusJwtEncoder(jwkSource);
    }


    @Bean
    public JWKSource<SecurityContext> jwkSource(RSAKey rsaKey) {
        return new ImmutableJWKSet<>(new JWKSet(rsaKey));
    }

RSAKey 빈은

2024.09.06 - [Spring/Spring Security] - 부록: JWT용 RSA 키 페어 자동 생성하기

[부록: JWT용 RSA 키 페어 자동 생성하기

spring-security-tistory/src/main/java/example/springsecuritytistory/security/JwtBeanGenerator.java at master · jsween5723/sprin티스토리 블로깅을 위한 예제. Contribute to jsween5723/spring-security-tistory development by creating an account on

sween.tistory.com](https://sween.tistory.com/23)

글에서 참조할 수 있다.

요청 시 필수로 거치는 필터의 추가

BearerTokenAuthenticationFilter가 추가되었다.
다른 AuthenticationFilter들과 다르게 AbstractAuthenticationProcessingFilter를 상속받지 않으며
OncePerRequestFilter를 상속받아 모든 요청에서 해당 필터를 거친다.
이외 AbstractAuthenticationProcessingFilter 경로일 땐 해당되는 필터가 먼저 적용되고, 아닐 땐 이 필터가 동작한다고 보면 된다.
마찬가지로 JwtAuthenticationProvider와 협력하여 인증을 시도한다.

필터 분석

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token;
        try {
            token = this.bearerTokenResolver.resolve(request);
            // 헤더 혹은 쿼리스트링에서 토큰을 취득한다.
            // DefaultBearerTokenResolver를 사용하며 Authorization 헤더의 Bearer 기준 파싱 혹은
            // 쿼리스트링의 access_token을 취득한다.
            // 추후 커스텀 설명 예정
        }
        catch (OAuth2AuthenticationException invalid) {
            this.logger.trace("Sending to authentication entry point since failed to resolve bearer token", invalid);
            this.authenticationEntryPoint.commence(request, response, invalid);
            // 토큰 파싱 실패 예외 처리 BearerTokenAuthenticationEntryPoint가 기본적으로 동작하며
            // WWW_AUTHENTICATE 헤더를 자동으로 처리해주므로 추후 커스텀할 때 유의하며 처리할 것이다.
            return;
        }
        if (token == null) {
            this.logger.trace("Did not process request since did not find bearer token");
            // 비어 있을 경우 ANONYMOUS로 처리하기 위해 다음 필터로 넘김
            filterChain.doFilter(request, response);
            return;
        }

        BearerTokenAuthenticationToken authenticationRequest = new BearerTokenAuthenticationToken(token);
        authenticationRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
        //AuthenticationProvider에서 처리 가능하도록 Authentication 인스턴스로 변환
        try {
            AuthenticationManager authenticationManager = this.authenticationManagerResolver.resolve(request);
            //JwtAuthenticationProvider가 동작
            Authentication authenticationResult = authenticationManager.authenticate(authenticationRequest);

        //AbstractAuthenticationProcessingFilter.successfulAuthentication
            SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
            context.setAuthentication(authenticationResult);
            this.securityContextHolderStrategy.setContext(context);
            this.securityContextRepository.saveContext(context, request, response);
            if (this.logger.isDebugEnabled()) {
                this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authenticationResult));
            }
            filterChain.doFilter(request, response);
        }
        catch (AuthenticationException failed) {
        //AbstractAuthenticationProcessingFilter.unsuccessfulAuthentication
            this.securityContextHolderStrategy.clearContext();
            this.logger.trace("Failed to process authentication request", failed);
            this.authenticationFailureHandler.onAuthenticationFailure(request, response, failed);
        }
    }

보면 AbstractAuthenticationProcessingFilter를 상속받지 않기 때문에 onSuccess, onFailure등이 템플릿 메소드로 나뉘어 있지 않아 길어진 것을 볼 수 있다.

JwtAuthenticationProvider

그 다음으로 중요한 로직인 인증을 담당하는 클래스를 분석해보자

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        BearerTokenAuthenticationToken bearer = (BearerTokenAuthenticationToken) authentication;
        Jwt jwt = getJwt(bearer);
        // 토큰을 decode한다.
        AbstractAuthenticationToken token = this.jwtAuthenticationConverter.convert(jwt);
        // decode한 내용 (claim 해쉬맵)을 Authentication으로 만든다.
        if (token.getDetails() == null) {
            token.setDetails(bearer.getDetails());
        }
        this.logger.debug("Authenticated token");
        return token; //반환한다.
    }
    private Jwt getJwt(BearerTokenAuthenticationToken bearer) {
        try {
            return this.jwtDecoder.decode(bearer.getToken());
        }
        catch (BadJwtException failed) {
            this.logger.debug("Failed to authenticate since the JWT was invalid");
            throw new InvalidBearerTokenException(failed.getMessage(), failed);
        }
        catch (JwtException failed) {
            throw new AuthenticationServiceException(failed.getMessage(), failed);
        }
    }

JWT는 사용처 별로 구조가 제각각이기 때문에 인터페이스가 주어진다

Decoder의 경우 NimbusJwtDecoder 구현체를 Security가 제공해 생성할 수 있으며 변경된 코드 영역에서 참고하면된다.
또한 Decoder도 SpringBoot auto configuration을 지원하는데 다음과 같다.
하지만 JwtEncoder 구현체는 자동 구성을 지원하지 않는다.

2024.09.06 - [Spring/Spring Security] - 부록: JWT용 RSA 키 페어 자동 생성하기

[부록: JWT용 RSA 키 페어 자동 생성하기

spring-security-tistory/src/main/java/example/springsecuritytistory/security/JwtBeanGenerator.java at master · jsween5723/sprin티스토리 블로깅을 위한 예제. Contribute to jsween5723/spring-security-tistory development by creating an account on

sween.tistory.com](https://sween.tistory.com/23)

해당 글을 참조해 구성하는 것을 추천한다.

JwtDecoder SpringBoot 사용자화

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jws-algorithms: RS256
          public-key-location: key.pub

JwtAuthenticationConverter SpringBoot 사용자화

JwtAuthenticationConverter는 기본 구현체의 경우 일부지만 Spring Boot에서 auto configuration을 지원한다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          authorities-claim-name: authorities
          authorities-claim-delimiter: ,
          authority-prefix: ROLE_
          principal-claim-name: id

이렇게 4가지이다.

하지만 JWT에 속성이 너무 적으며 분산환경에서 사용자 데이터베이스 부하를 줄이는 데 목적이 있는 JWT의 목적성이 흐려진다고 생각하여
직접 구현하겠다.

취향으로 인해 람다형식으로 구현하였지만 Bean이나 Component로 선언하여도 상관없다.


    private Converter<Jwt, UsernamePasswordAuthenticationToken> jwtAuthenticationConverterGenerator() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
        return (Jwt jwtSrc) -> {
            String id = jwtSrc.getSubject();
            String username = jwtSrc.getClaimAsString("username");
            Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(
                jwtSrc);
            Set<UserRole> roles = authorities.stream()
                                             .map(GrantedAuthority::getAuthority)
                                             .map(UserRole::valueOf)
                                             .collect(Collectors.toSet());
            Provider provider = new Provider(Long.parseLong(id), username, roles);
            return new UsernamePasswordAuthenticationToken(provider, null, authorities);
        };
    }
반응형
LIST