WEB/Security

OAuth 2.0 Resource Server - MAC & RSA 토큰 검증

Tony Lim 2022. 12. 23. 14:09
728x90

기본 환경 및 클래스 구성

토큰 검증방법

1. 암호화 알고리즘 방식에 따라 직접 발행한 JWT 토큰을 대상으로 검증을 진행한다.
2. auth server에서 발행한 Access Token 을 대상으로 검증을 진행한다.

MAC검증 방법

1. 자체 토큰 발행 및 검증
2. SecretKey 설정에 의한 검증

RSA검증 방법

1. 자체 토큰 발행 및 검증
2. PublicKey 파일에 의한 검증
3. KeyStore 툴에 의한 검증
4. JwkSetUri 설정에 의한 검증


@Configuration
public class OAuth2ResourceServer{

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.csrf().disable();

        http.authorizeRequests((requests) -> requests.antMatchers("/").permitAll()
                .anyRequest().authenticated());
        http.userDetailsService(userDetailsService());
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter();
        jwtAuthenticationFilter.setAuthenticationManager(authenticationManager(null));
        return jwtAuthenticationFilter;
    }

    @Bean
    public UserDetailsService userDetailsService(){

        UserDetails user = User.withUsername("user").password("1234").authorities("ROLE_USER").build();
        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return NoOpPasswordEncoder.getInstance();
    }
}

AuthenticationManager의 경우 AuthenticationConfiguration 을 주입받게 되는데 내부적으로 필요한 DaoAuthenticationProvider를 만들어준다. 이것을 JwtAuthenticationFilter에 주입해줘서 attempAuthentication 시도시 해당 Provider를 넘겨줘서 authenticate 할수 있게 한다.

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {


        ObjectMapper objectMapper = new ObjectMapper();
        LoginDto loginDto = null;
        try {

            loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);

        } catch (Exception e) {
            e.printStackTrace();
        }
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
        Authentication authentication = getAuthenticationManager().authenticate(authenticationToken);

        return authentication;
    }
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws ServletException, IOException {
        SecurityContextHolder.getContext().setAuthentication(authResult);
        getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
    }
}

Mac 검증기능 구현 - JwtAuthorizationMacFilter

JwtAuthenticationFilter는 서명한 jwtToken을 발행하는 필터이고 JwtAUthorizationMacFilter는 받은 token을 verify하는 filter이다.

MacSigner와 MacVerifier는 쌍으로 존재한다. hashing 과 대칭키로 암호화 하는 것이 Mac

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http.csrf().disable();
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

    http.authorizeRequests((requests) -> requests.antMatchers("/").permitAll().anyRequest().authenticated());
    http.userDetailsService(userDetailsService());
    http.addFilterBefore(jwtAuthenticationFilter(macSecuritySigner, octetSequenceKey), UsernamePasswordAuthenticationFilter.class);
    http.addFilterBefore(new JwtAuthorizationMacFilter(octetSequenceKey), UsernamePasswordAuthenticationFilter.class);

    return http.build();
}
@Configuration
public class SignatureConfig {
    @Bean
    public MacSecuritySigner macSecuritySigner() {
        return new MacSecuritySigner();
    }
    @Bean
    public OctetSequenceKey octetSequenceKey() throws JOSEException {
        OctetSequenceKey octetSequenceKey = new OctetSequenceKeyGenerator(256)
                .keyID("macKey")
                .algorithm(JWSAlgorithm.HS256)
                .generate();
        return octetSequenceKey;
    }
}

OctetSequenceKey는 대칭키다. 

public abstract class SecuritySigner {
    public String getJwtTokenInternal(JWSSigner jwsSigner, UserDetails user, JWK jwk) throws JOSEException {

        JWSHeader header = new JWSHeader.Builder((JWSAlgorithm) jwk.getAlgorithm()).keyID(jwk.getKeyID()).build();
        List<String> authority = user.getAuthorities().stream().map(auth -> auth.getAuthority()).collect(Collectors.toList());
        JWTClaimsSet jwtClaimsSet = new JWTClaimsSet.Builder()
                .subject("user")
                .issuer("http://localhost:8081")
                .claim("username", user.getUsername())
                .claim("authority", authority)
                .expirationTime(new Date(new Date().getTime() + 60 * 1000 * 5))
                .build();

        SignedJWT signedJWT = new SignedJWT(header,jwtClaimsSet);
        signedJWT.sign(jwsSigner);
        String jwtToken = signedJWT.serialize();

        return jwtToken;
    }
    public abstract String getJwtToken(UserDetails user, JWK jwk) throws JOSEException;
}
public class MacSecuritySigner extends SecuritySigner {

    @Override
    public String getJwtToken(UserDetails user, JWK jwk) throws JOSEException {

        MACSigner jwsSigner = new MACSigner(((OctetSequenceKey)jwk).toSecretKey());
        return getJwtTokenInternal(jwsSigner, user, jwk);
    }
}

Mac 방식으로 JWT 를 서명하기위한 클래스들이다.
jwtAuthenticationFilter에서 서명에 필요한 macSigner와 대칭키를 주입하는것을 확인할 수 있다.

 

public class JwtAuthorizationMacFilter extends OncePerRequestFilter {
    private OctetSequenceKey jwk;

    public JwtAuthorizationMacFilter(OctetSequenceKey jwk) {
        this.jwk = jwk;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }
        System.out.println("header : " + header);
        String token = header.replace("Bearer ", "");

      SignedJWT signedJWT;
      try {
         signedJWT = SignedJWT.parse(token);
         MACVerifier macVerifier = new MACVerifier(jwk.toSecretKey());
         boolean verify = signedJWT.verify(macVerifier);

         if(verify){
            JWTClaimsSet jwtClaimsSet = signedJWT.getJWTClaimsSet();
            String username = jwtClaimsSet.getClaim("username").toString();
            List<String> authority = (List)jwtClaimsSet.getClaim("authority");

            if(username != null){
               UserDetails user = User.withUsername(username)
                     .password(UUID.randomUUID().toString())
                     .authorities(authority.get(0))
                     .build();

               Authentication authentication =
                     new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities());
               SecurityContextHolder.getContext().setAuthentication(authentication);
            }
         }

      } catch (Exception e) {
         e.printStackTrace();
      }
        chain.doFilter(request, response);
    }
}

jwtAuthorizationMacFilter 는 verify를 하기위해 동일한 대칭키를 주입받는다. 필요한 MacVerify를 내부적으로 알아서 만들어서 검증하게 된다.

public class MacSecuritySigner extends SecuritySigner {

    @Override
    public String getJwtToken(UserDetails user, JWK jwk) throws JOSEException {

        MACSigner jwsSigner = new MACSigner(((OctetSequenceKey)jwk).toSecretKey());
        return getJwtTokenInternal(jwsSigner, user, jwk);
    }
}

Mac 검증기능구현 - JwtDecoder에 의한 검증

@Configuration
public class JwtDecoderConfig {

    @Bean
    @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "jws-algorithms", havingValue = "HS256", matchIfMissing = false)
    public JwtDecoder jwtDecoderBySecretKeyValue(OctetSequenceKey octetSequenceKey,OAuth2ResourceServerProperties properties) {
        return NimbusJwtDecoder.withSecretKey(octetSequenceKey.toSecretKey())
                .macAlgorithm(MacAlgorithm.from(properties.getJwt().getJwsAlgorithms().get(0)))
                .build();
    }
}

대칭키 방식으로 JwtDecoder 빈을 하나 생성하는 것이다. 

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http.csrf().disable();
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

    http.authorizeRequests((requests) -> requests.antMatchers("/").permitAll()
            .anyRequest().authenticated());
    http.userDetailsService(userDetailsService());
    http.addFilterBefore(jwtAuthenticationFilter(macSecuritySigner, octetSequenceKey), UsernamePasswordAuthenticationFilter.class);
    http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
    return http.build();
}

http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); 이 한줄 추가되었다. 
HttpSecurity가 dobuild할때 추가로 만들 SecurityConfigurer 중 jwt관련된것을 추가하여 JwtDecoder 생성이 가능하게 한것이다.

Authentication에 JwtAuthenticationToken이 들어가고 principle, credential에 JWT 객체가 들어가게 된다.


RSA 검증 기능 구현 - JwtAuthorizationRsaFilter

public class RSASecuritySigner extends SecuritySigner {

    public String getJwtToken(UserDetails user, JWK jwk) throws JOSEException {

        RSASSASigner jwsSigner = new RSASSASigner(((RSAKey)jwk).toRSAPrivateKey());
        return getJwtTokenInternal(jwsSigner, user, jwk);

    }
}

대칭키와 유사하게 SecuritySigner를 상속 받고 sign을 위한 privateKey를 주입하여 RSASSASigner를 생성한다.
SA Signature-Scheme-with-Appendix (RSASSA)

 

signedJWT = SignedJWT.parse(token);
MACVerifier macVerifier = new MACVerifier(jwk.toSecretKey());
boolean verify = signedJWT.verify(macVerifier);
         
----------

signedJWT = SignedJWT.parse(token);

boolean verify = signedJWT.verify(jwsVerifier);

기존 Filter에서 다 동일한데 Verifier를 생성하는 부분만 Mac 과 RSA에 차이가 있으므로 Verifier를 주입받는 형식으로 변경하였다.

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.authorizeRequests((requests) -> requests.antMatchers("/").permitAll().anyRequest().authenticated());
        http.userDetailsService(userDetailsService());
        http.addFilterBefore(jwtAuthenticationFilter(null, null), UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(jwtAuthorizationRsaFilter(null), UsernamePasswordAuthenticationFilter.class);
//        http.addFilterBefore(new JwtAuthorizationMacFilter(new MACVerifier(octetSequenceKey.toSecretKey())), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public JwtAuthorizationRsaFilter jwtAuthorizationRsaFilter(RSAKey rsaKey) throws JOSEException {
        return new JwtAuthorizationRsaFilter(new RSASSAVerifier(rsaKey.toRSAPublicKey()));
    }
@Configuration
public class SignatureConfig {
    @Bean
    public MacSecuritySigner macSecuritySigner() {
        return new MacSecuritySigner();
    }
    @Bean
    public OctetSequenceKey octetSequenceKey() throws JOSEException {
        OctetSequenceKey octetSequenceKey = new OctetSequenceKeyGenerator(256)
                .keyID("macKey")
                .algorithm(JWSAlgorithm.HS256)
                .generate();
        return octetSequenceKey;
    }

    @Bean
    public RSASecuritySigner rsaSecuritySigner() {
        return new RSASecuritySigner();
    }
    @Bean
    public RSAKey rsaKey() throws JOSEException {
        RSAKey rsaKey = new RSAKeyGenerator(2048)
                .keyID("rsaKey")
                .algorithm(JWSAlgorithm.RS512)
                .generate();
        return rsaKey;
    }
}

filter를 만드는데 RSAKey 인자에 null을 넣는것을 확인할 수 있다. 이는 이미 우리가 RSAKey를 Bean으로 하나만들어서 등록했기 때문에 NullPointerException이 뜨지않고 만들어진 bean을 주입 받는것이다.


RSA 검증 기능 구현 - JwtDecoder에 의한 검증

@Bean
@ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "jws-algorithms", havingValue = "RS512", matchIfMissing = false)
public JwtDecoder jwtDecoderByPublicKeyValue(RSAKey rsaKey, OAuth2ResourceServerProperties properties) throws JOSEException {
    return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey())
            .signatureAlgorithm(SignatureAlgorithm.from(properties.getJwt().getJwsAlgorithms().get(0)))
            .build();
}

Mac과 설정만 다르게 주면 된다. RSAKey 도 Bean으로 이미 만들어놓았다.


RSA 검증기능 구현 - PublicKey.txt에 의한 검증

 

KeyStore클래스

Java는 KeyStore라는 인터페이스를 통해 암호화/복호화 및 전자서명에 사용되는 Private ,Public Key와 Certificate를 추상화하여 제공하고 있다.

Keystore는 keytool을 사용해서 생성할 수 있으며 기본타입은 jks이다.

 

keytool

keytool은 자바에서 제공하는 유틸리티로 KeyStore 기반으로 인증서와 키를 관리할 수 있으며 JDK에 포함되어있다.

public class RsaPublicKeySecuritySigner extends SecuritySigner {

    private PrivateKey privateKey;

    public String getJwtToken(UserDetails user, JWK jwk) throws JOSEException{

        RSASSASigner jwsSigner = new RSASSASigner(privateKey);
        return getJwtTokenInternal(jwsSigner, user, jwk);

    }
    public void setPrivateKey(PrivateKey privateKey) {
        this.privateKey = privateKey;
    }
}
@Bean
public RsaPublicKeySecuritySigner rsaPublicKeySecuritySigner(){
    return new RsaPublicKeySecuritySigner();
}

keytool -genkeypair -alias apiKey -keyalg RSA -keypass "pass1234" -keystore apiKey.jks -storepass "pass1234"

명령어로 privateKey를 생성하였다.

@Component
public class RsaKeyExtractor implements ApplicationRunner {

    @Autowired
    private RsaPublicKeySecuritySigner rsaPublicKeySecuritySigner;

    @Override
    public void run(ApplicationArguments args) throws Exception {

        String path = "E:\\project\\spring-security-oauth2\\src\\main\\resources\\certs\\";
        File file = new File(path + "publicKey.txt");

        FileInputStream is = new FileInputStream(path + "apiKey.jks");
        KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
        keystore.load(is, "pass1234".toCharArray());
        String alias = "apiKey";
        Key key = keystore.getKey(alias, "pass1234".toCharArray());

        if (key instanceof PrivateKey) {

            Certificate certificate = keystore.getCertificate(alias);
            PublicKey publicKey = certificate.getPublicKey();
            KeyPair keyPair = new KeyPair(publicKey, (PrivateKey) key);
            rsaPublicKeySecuritySigner.setPrivateKey(keyPair.getPrivate());

            if (!file.exists()) {
                String publicStr = java.util.Base64.getMimeEncoder().encodeToString(publicKey.getEncoded());
                publicStr = "-----BEGIN PUBLIC KEY-----\r\n" + publicStr + "\r\n-----END PUBLIC KEY-----";

                OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file), Charset.defaultCharset());
                writer.write(publicStr);
                writer.close();
            }
        }
        is.close();
    }
}

위에서 명령어로만든 privatekey를 keystore.getKey를 통해 apiKey.jks로부터 privateKey를 가지고 온다.
certificate를 만들고 여기서 publickey를 가져온다. 그리고 keyPair를 만들어준다.
그리고 publicKey.txt 에 publicKey를 쓴다.

private byte[] getKeySpec(String keyValue) {
   keyValue = keyValue.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "");
   return Base64.getMimeDecoder().decode(keyValue);
}

OAuth2ResourceServerJwtConfiguration의 getKeyspec 메소드에서 저렇게 문자열을 기준으로 public Keyvalue를 추출하니 쓸때도 알맞게 적어줘한다.

server:
  port: 8081

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jws-algorithms: RS256
#          public-key-location: classpath:certs/publicKey.txt

publicKey.txt가 존재해야 jws-algorithms으로 JWTDecoder 빈생성할때 문제가 되지 않기 때문에 주석처리한후에 기동해서 publicKey.txt를 생성하고 주석처리르 해제해서 제대로 동작하는지 확인해야한다.

OAuth2ResourceServerProperties 위에 @ConfigurationProperties(prefix = "spring.security.oauth2.resourceserver") 덕분에 application.yml 정보를 읽어와 property bean하나를 생성할 수 있다. 

@Bean
@ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "jws-algorithms", havingValue = "RS512", matchIfMissing = false)
public JwtDecoder jwtDecoderByPublicKeyValue(RSAKey rsaKey, OAuth2ResourceServerProperties properties) throws JOSEException {
    return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey())
            .signatureAlgorithm(SignatureAlgorithm.from(properties.getJwt().getJwsAlgorithms().get(0)))
            .build();
}

이때 위에서 JwtDecoder 비대칭키로 만드는 방법 할때 사용한 JwtDecoder빈을 만드는 방식인데 @ConditionalProerty의 havingValue 로 인해 application.yml에 적힌 jws-algorithms이 같아야 JwtDecoder 빈을 만들 수 있다.


RSA 검증 기능 구현 - JwkSetUri에 의한 검증

auth server로부터 token을 받아왔다면 application.yml에 jwk-set-uri를 명시해주는 방식으로 발급받은 token에 대한 검증을 auth sever에게 위임할 수 있다.

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.authorizeRequests((requests) -> requests.antMatchers("/").permitAll()
                .anyRequest().authenticated());
        http.userDetailsService(userDetailsService());
        http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
//        http.addFilterBefore(jwtAuthenticationFilter(null, null), UsernamePasswordAuthenticationFilter.class);
//        http.addFilterBefore(jwtAuthorizationRsaPublicKeyFilter(null), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

직접 필터를 만들거나 추가할 필요없이         http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); 해당 설정만으로도 spring security가 알아서 필터와 JwtDecoder를 생성해준다.

@Bean
@ConditionalOnProperty(name = "spring.security.oauth2.resourceserver.jwt.jwk-set-uri")
JwtDecoder jwtDecoderByJwkKeySetUri() {
   NimbusJwtDecoder nimbusJwtDecoder = NimbusJwtDecoder.withJwkSetUri(this.properties.getJwkSetUri())
         .jwsAlgorithms(this::jwsAlgorithms).build();
   String issuerUri = this.properties.getIssuerUri();
   Supplier<OAuth2TokenValidator<Jwt>> defaultValidator = (issuerUri != null)
         ? () -> JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators::createDefault;
   nimbusJwtDecoder.setJwtValidator(getValidators(defaultValidator));
   return nimbusJwtDecoder;
}

JwtDecoderConfiguration에서 JwtDecoder를 생성해준다.


Authentication / @AuthenticationPrincipal

Authentication

리소스 서버에서 토큰 검증이 이루어지면 토큰으로 부터 정보를 추출해서 인증 객체를 구성하게 된다.
스프링 시큐리티의 자원에 대한 접근은 인증객체의 인증 유무와 권한 정보에 따라 결정되기 떄문에 인증객체를 생성해야한다.
인증 객체는 JwtAuthenticationToken 타입으로 생성되고 SecurityContext에 저장한다.

Jwt = JwtDecoder에서 검증이 성공하면 토큰의 Claims 정보를 추출해서 Jwt객체를 반환하고 JwtAuthenticationToken의 Principal에 저장이된다.

@GetMapping("/api/user")
public Authentication user(Authentication authentication, @AuthenticationPrincipal Jwt principal) throws URISyntaxException {


    JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication;
    String sub = jwtAuthenticationToken.getTokenAttributes().get("sub") + " is the subject";
    String sub1 = principal.getClaim("sub") + " is the subject";

    String token = principal.getTokenValue();

    RestTemplate restTemplate = new RestTemplate();
    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization","Bearer " + token);
    RequestEntity<String> request = new RequestEntity<>(headers, HttpMethod.GET, new URI("http://localhost:9090/user"));
    ResponseEntity<String> exchange = restTemplate.exchange(request, String.class);

    String body = exchange.getBody();

    return authentication;
}

검증된 토큰을 다른 곳으로 보내는 예시이다.


검증 아키텍처 이해 - BearerTokenAuthenticationFilter

 

728x90