DEV Community

ReLive27
ReLive27

Posted on

Spring Cloud Gateway Combined with the Security Practice of OAuth2.0 Protocol

Overview

Spring Cloud Gateway is an API Gateway built on top of the Spring ecosystem. It is built on top of Spring Boot, Spring WebFlux, and Project Reactor.

In this section, you will use Spring Cloud Gateway to route requests to a Servlet API service.

What you will learn in this article:

  • OpenID Connect authentication - used for user authentication.
  • Token relay - Spring Cloud Gateway API gateway acts as a client and forwards tokens to resource requests.

Prerequisites:

  • Java 8+
  • MySQL
  • Redis

OpenID Connect authentication

OpenID Connect defines a user authentication mechanism based on the OAuth2 authorization code flow. The following diagram shows the complete process of authentication between Spring Cloud Gateway and the authorization service. For clarity, some parameters have been omitted.

Image description

Build authorization service

In this section, we will use Spring Authorization Server to build an authorization service that supports the OAuth2 and OpenID Connect protocols. At the same time, we will use the RBAC0 basic permission model to control access rights. This authorization service also supports Github third-party login as an OAuth2 client.

Relevant database table structure

We have created a basic RBAC0 permission model for this article and provided the table structures required for persistence storage of OAuth2 authorization services and OAuth2 clients. The oauth2_client_role table defines the external system role and the mapping relationship with the local platform role. The SQL statements related to the creation of related tables and initialization data can be obtained here.

Image description

Role description

By default, the authorization service in this section provides two roles, with the following role attributes and access permissions:

read write
ROLE_ADMIN
ROLE_OPERATION

Maven dependencies

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-authorization-server</artifactId>
  <version>0.3.1</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-jdbc</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>8.0.21</version>
</dependency>
<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>druid-spring-boot-starter</artifactId>
  <version>1.2.3</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Configuration

First, let's start with the application.yml configuration, where we specify the port number and MySQL connection configuration:

server:
  port: 8080

spring:
  datasource:
    druid:
      db-type: mysql
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/oauth2server?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
      username: <<username>> # Modify username
      password: <<password>> # Modify password
Enter fullscreen mode Exit fullscreen mode

Next, we will create AuthorizationServerConfig to configure the required beans for OAuth2 and OIDC. First, we will add OAuth2 client information and persist it to the database:

    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        RegisteredClient registeredClient = RegisteredClient.withId("relive-messaging-oidc")
                .clientId("relive-client")
                .clientSecret("{noop}relive-client")
                .clientAuthenticationMethods(s -> {
                    s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
                    s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
                })
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-gateway-oidc")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .scope(OidcScopes.EMAIL)
                .scope("read")
                .clientSettings(ClientSettings.builder()
                        .requireAuthorizationConsent(false)
                        .requireProofKey(false)
                        .build())
                .tokenSettings(TokenSettings.builder()
                        .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
                        .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                        .accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
                        .refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
                        .reuseRefreshTokens(true)
                        .build())
                .build();

        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
        registeredClientRepository.save(registeredClient);
        return registeredClientRepository;
    }

Enter fullscreen mode Exit fullscreen mode

Second, we will create the persistence container class required during the authorization process:

    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }


    @Bean
    public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }
Enter fullscreen mode Exit fullscreen mode

The authorization server needs a signing key for its tokens, so let's generate a 2048-byte RSA key:

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
      RSAKey rsaKey = Jwks.generateRsa();
      JWKSet jwkSet = new JWKSet(rsaKey);
      return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    static class Jwks {

      private Jwks() {
      }

      public static RSAKey generateRsa() {
        KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
          .privateKey(privateKey)
          .keyID(UUID.randomUUID().toString())
          .build();
      }
    }

    static class KeyGeneratorUtils {

      private KeyGeneratorUtils() {
      }

      static KeyPair generateRsaKey() {
        KeyPair keyPair;
        try {
          KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
          keyPairGenerator.initialize(2048);
          keyPair = keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
          throw new IllegalStateException(ex);
        }
        return keyPair;
      }
    }
Enter fullscreen mode Exit fullscreen mode

Next, we will create the SecurityFilterChain used for OAuth2 authorization. SecurityFilterChain is a filter chain provided by Spring Security.

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
                new OAuth2AuthorizationServerConfigurer<>();
        //配置OIDC
        authorizationServerConfigurer.oidc(Customizer.withDefaults());

        RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();

        return http.requestMatcher(endpointsMatcher)
                .authorizeRequests((authorizeRequests) -> {
                    ((ExpressionUrlAuthorizationConfigurer.AuthorizedUrl) authorizeRequests.anyRequest()).authenticated();
                }).csrf((csrf) -> {
                    csrf.ignoringRequestMatchers(new RequestMatcher[]{endpointsMatcher});
                }).apply(authorizationServerConfigurer)
                .and()
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
                .exceptionHandling(exceptions -> exceptions.
                        authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
                .apply(authorizationServerConfigurer)
                .and()
                .build();
    }
Enter fullscreen mode Exit fullscreen mode

Above, we configured the default configuration for OAuth2 and OpenID Connect, and redirected authentication requests to the login page. At the same time, we also enable the OAuth2 resource service configuration provided by Spring Security. This configuration is used to protect the OpenID Connect /userinfo user information endpoint.

When enabling the Spring Security OAuth2 resource service configuration, we specify JWT verification, so we need to specify the jwk-set-uri in application.yml or add JwtDecoder declaratively. Here, we use declarative configuration:

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }
Enter fullscreen mode Exit fullscreen mode

Next, we will customize the access token. In this example, we use the RBAC0 permission model, so we add the authorities for the permission code of the current user's role to the access_token:

@Configuration(proxyBeanMethods = false)
public class AccessTokenCustomizerConfig {

    @Autowired
    RoleRepository roleRepository;

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
        return (context) -> {
            if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
                context.getClaims().claims(claim -> {
                    claim.put("authorities", roleRepository.findByRoleCode(context.getPrincipal().getAuthorities().stream()
                            .map(GrantedAuthority::getAuthority).findFirst().orElse("ROLE_OPERATION"))
                            .getPermissions().stream().map(Permission::getPermissionCode).collect(Collectors.toSet()));
                });
            }
        };
    }
}

Enter fullscreen mode Exit fullscreen mode

RoleRepository is the persistence layer object of the role table. In this example, we use the JPA framework and the relevant code will not be shown in this article. If you are not familiar with JPA, you can use Mybatis instead.

Below we will configure the authorization service Form authentication method.

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests.anyRequest().authenticated()
                )
                .formLogin(withDefaults())

          ...

        return http.build();
    }
Enter fullscreen mode Exit fullscreen mode

Next, we will create a JdbcUserDetailsService that implements UserDetailsService, which is used to look up the password and permission information of the logged-in user during the authentication process. If you are interested in why UserDetailsService needs to be implemented, you can check the source code of UsernamePasswordAuthenticationFilter -> ProviderManager -> DaoAuthenticationProvider. In DaoAuthenticationProvider, the user information is obtained by calling UserDetailsService#loadUserByUsername(String username).

@RequiredArgsConstructor
public class JdbcUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        com.relive.entity.User user = userRepository.findUserByUsername(username);
        if (ObjectUtils.isEmpty(user)) {
            throw new UsernameNotFoundException("user is not found");
        }
        if (CollectionUtils.isEmpty(user.getRoleList())) {
            throw new UsernameNotFoundException("role is not found");
        }
        Set<SimpleGrantedAuthority> authorities = user.getRoleList().stream().map(Role::getRoleCode)
                .map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
        return new User(user.getUsername(), user.getPassword(), authorities);
    }
}

Enter fullscreen mode Exit fullscreen mode

And we will inject it into Spring:

    @Bean
    UserDetailsService userDetailsService(UserRepository userRepository) {
        return new JdbcUserDetailsService(userRepository);
    }
Enter fullscreen mode Exit fullscreen mode

When attempting to access an unauthenticated interface, the user will be directed to the login page and prompted to enter their username and password, as shown below:

Image description

Users usually need to use multiple platforms provided and hosted by different organizations. These users may need to use specific (and different) credentials for each platform. When users have many different credentials, they often forget their login credentials.

Federated authentication is used to authenticate users using external systems. This can be used with Google, Github or any other identity provider. Here, I will use Github for user authentication and data synchronization management.

Github Identity Authentication

First, we will configure the Github client information, and you only need to change the clientId and clientSecret. Secondly, we will use the JdbcClientRegistrationRepository persistence layer container class introduced in Spring Security Persistence OAuth2 Client to store the GitHub client information in the database.

    @Bean
    ClientRegistrationRepository clientRegistrationRepository(JdbcTemplate jdbcTemplate) {
        JdbcClientRegistrationRepository jdbcClientRegistrationRepository = new JdbcClientRegistrationRepository(jdbcTemplate);
        ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("github")
                .clientId("123456")
                .clientSecret("123456")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUri("{baseUrl}/{action}/oauth2/code/{registrationId}")
                .scope(new String[]{"read:user"})
                .authorizationUri("https://github.com/login/oauth/authorize")
                .tokenUri("https://github.com/login/oauth/access_token")
                .userInfoUri("https://api.github.com/user")
                .userNameAttributeName("login")
                .clientName("GitHub").build();

        jdbcClientRegistrationRepository.save(clientRegistration);
        return jdbcClientRegistrationRepository;
    }
Enter fullscreen mode Exit fullscreen mode

Next, we will instantiate OAuth2AuthorizedClientService and OAuth2AuthorizedClientRepository:

  • OAuth2AuthorizedClientService: responsible for persisting OAuth2AuthorizedClient between web requests.
  • OAuth2AuthorizedClientRepository: used to save and persist authorization clients between requests.

    @Bean
    OAuth2AuthorizedClientService authorizedClientService(
            JdbcTemplate jdbcTemplate,
            ClientRegistrationRepository clientRegistrationRepository) {
        return new JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository);
    }

    @Bean
    OAuth2AuthorizedClientRepository authorizedClientRepository(
            OAuth2AuthorizedClientService authorizedClientService) {
        return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
    }
Enter fullscreen mode Exit fullscreen mode

For each user logging in with Github, we need to assign platform roles to control which resources they can access. Here, we will create the AuthorityMappingOAuth2UserService class to grant user roles:

@RequiredArgsConstructor
public class AuthorityMappingOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
    private final OAuth2ClientRoleRepository oAuth2ClientRoleRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        DefaultOAuth2User oAuth2User = (DefaultOAuth2User) delegate.loadUser(userRequest);

        Map<String, Object> additionalParameters = userRequest.getAdditionalParameters();
        Set<String> role = new HashSet<>();
        if (additionalParameters.containsKey("authority")) {
            role.addAll((Collection<? extends String>) additionalParameters.get("authority"));
        }
        if (additionalParameters.containsKey("role")) {
            role.addAll((Collection<? extends String>) additionalParameters.get("role"));
        }
        Set<SimpleGrantedAuthority> mappedAuthorities = role.stream()
                .map(r -> oAuth2ClientRoleRepository.findByClientRegistrationIdAndRoleCode(userRequest.getClientRegistration().getRegistrationId(), r))
                .map(OAuth2ClientRole::getRole).map(Role::getRoleCode).map(SimpleGrantedAuthority::new)
                .collect(Collectors.toSet());
        //When no client role is specified, the least privilege ROLE_OPERATION is given by default
        if (CollectionUtils.isEmpty(mappedAuthorities)) {
            mappedAuthorities = new HashSet<>(
                    Collections.singletonList(new SimpleGrantedAuthority("ROLE_OPERATION")));
        }
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        return new DefaultOAuth2User(mappedAuthorities, oAuth2User.getAttributes(), userNameAttributeName);
    }
}
Enter fullscreen mode Exit fullscreen mode

We can see that the permission information is obtained from the authority and role attributes, and the role attributes mapped to this platform are searched through OAuth2ClientRoleRepository.

Note: authority and role are custom attributes of the platform, unrelated to the OAuth2 protocol and Open ID Connect protocol. In a production environment, you can negotiate with an external system to agree on an attribute to transmit permission information.

OAuth2ClientRoleRepository is the persistence layer container class for the oauth2_client_role table, implemented by JPA.

For the mapped role information that has not been obtained in advance, we will assign the default ROLE_OPERATION minimum permission role. In this example, users who log in with Github will also be assigned the ROLE_OPERATION role.

For users who successfully authenticate with Github and log in for the first time, we will obtain their user information and persist it to the user table. Here, we will implement AuthenticationSuccessHandler and add user persistence logic.

public final class SavedUserAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final AuthenticationSuccessHandler delegate = new SavedRequestAwareAuthenticationSuccessHandler();


    private Consumer<OAuth2User> oauth2UserHandler = (user) -> {
    };

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        if (authentication instanceof OAuth2AuthenticationToken) {
            if (authentication.getPrincipal() instanceof OAuth2User) {
                this.oauth2UserHandler.accept((OAuth2User) authentication.getPrincipal());
            }
        }

        this.delegate.onAuthenticationSuccess(request, response, authentication);
    }

    public void setOauth2UserHandler(Consumer<OAuth2User> oauth2UserHandler) {
        this.oauth2UserHandler = oauth2UserHandler;
    }
}
Enter fullscreen mode Exit fullscreen mode

We will inject UserRepositoryOAuth2UserHandler into SavedUserAuthenticationSuccessHandler through the setOauth2UserHandler(Consumer<OAuth2User> oauth2UserHandler) method. UserRepositoryOAuth2UserHandler defines specific persistence layer operations:

@Component
@RequiredArgsConstructor
public final class UserRepositoryOAuth2UserHandler implements Consumer<OAuth2User> {

    private final UserRepository userRepository;

    private final RoleRepository roleRepository;

    @Override
    public void accept(OAuth2User oAuth2User) {
        DefaultOAuth2User defaultOAuth2User = (DefaultOAuth2User) oAuth2User;
        if (this.userRepository.findUserByUsername(oAuth2User.getName()) == null) {
            User user = new User();
            user.setUsername(defaultOAuth2User.getName());
            Role role = roleRepository.findByRoleCode(defaultOAuth2User.getAuthorities()
                    .stream().map(GrantedAuthority::getAuthority).findFirst().orElse("ROLE_OPERATION"));
            user.setRoleList(Arrays.asList(role));
            userRepository.save(user);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We obtain the role information mapped by defaultOAuth2User.getAuthorities() and store it in the database with the user information.

UserRepository and RoleRepository are persistence container classes.

Finally, we add OAuth2 Login configuration to SecurityFilterChain:

    @Autowired
    UserRepositoryOAuth2UserHandler userHandler;

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests.anyRequest().authenticated()
                )
                .oauth2Login(oauth2login -> {
                    SavedUserAuthenticationSuccessHandler successHandler = new SavedUserAuthenticationSuccessHandler();
                    successHandler.setOauth2UserHandler(userHandler);
                    oauth2login.successHandler(successHandler);
                });

        ...

        return http.build();
    }
Enter fullscreen mode Exit fullscreen mode

Create a Spring Cloud Gateway application

In this section, we will use Spring Security OAuth2 Login in Spring Cloud Gateway to enable OpenID Connect authentication and relay Access Token to downstream services.

Maven dependencies

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-gateway</artifactId>
  <version>3.1.2</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
  <version>2.6.3</version>
</dependency>

<dependency>
  <groupId>io.netty</groupId>
  <artifactId>netty-all</artifactId>
  <version>4.1.76.Final</version>
</dependency>

Enter fullscreen mode Exit fullscreen mode

Configuration

First, we add the following properties to application.yml:

server:
  port: 8070
  servlet:
    session:
      cookie:
        name: GATEWAY-CLIENT
Enter fullscreen mode Exit fullscreen mode

Here, the cookie name is specified as GATEWAY-CLIENT to avoid conflicts with the authorization service JSESSIONID.

Route to the resource server through Spring Cloud Gateway:

spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
      routes:
        - id: resource-server
          uri: http://127.0.0.1:8090
          predicates:
            Path=/resource/**
          filters:
            - TokenRelay

Enter fullscreen mode Exit fullscreen mode

The TokenRelay filter extracts the access token stored in the user session and adds it as an Authorization header to the outgoing request. This allows downstream services to authenticate requests.

We will add OAuth2 client information in application.yml:

spring:
  security:
    oauth2:
      client:
        registration:
          messaging-gateway-oidc:
            provider: gateway-client-provider
            client-id: relive-client
            client-secret: relive-client
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope:
              - openid
              - profile
            client-name: messaging-gateway-oidc
        provider:
          gateway-client-provider:
            authorization-uri: http://127.0.0.1:8080/oauth2/authorize
            token-uri: http://127.0.0.1:8080/oauth2/token
            jwk-set-uri: http://127.0.0.1:8080/oauth2/jwks
            user-info-uri: http://127.0.0.1:8080/userinfo
            user-name-attribute: sub
Enter fullscreen mode Exit fullscreen mode

OpenID Connect uses a special scope value openid to control access to the UserInfo endpoint, and other information is consistent with the client registration information parameters of the authorization service in the previous section.

Spring Security intercepts unauthenticated requests and performs authentication on the authorization server. For simplicity, CSRF is disabled.

@Configuration(proxyBeanMethods = false)
@EnableWebFluxSecurity
public class OAuth2LoginSecurityConfig {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http
                .authorizeExchange(authorize -> authorize
                        .anyExchange().authenticated()
                )
                .oauth2Login(withDefaults())
                .cors().disable();
        return http.build();
    }
}
Enter fullscreen mode Exit fullscreen mode

After completing OpenID Connect authentication in Spring Cloud Gateway, the user information and token are stored in the session, so we add spring-session-data-redis to provide Redis-supported distributed session functionality and add the following configuration in application.yml:

spring:
  session:
    store-type: redis 
    redis:
      flush-mode: on_save 
      namespace: gateway:session
  redis:
    host: localhost
    port: 6379
    password: 123456
Enter fullscreen mode Exit fullscreen mode

Based on the above example, we use Spring Cloud Gateway to drive authentication and know how to authenticate users, obtain tokens (after user consent), but do not authenticate/authorize requests through Gateway (Spring Gateway Cloud is not the audience target of Access Token). The reason behind this approach is that some services are protected while others are public. Even in a single service, sometimes only a few endpoints need to be protected, not every endpoint. That's why I leave the request authentication/authorization to specific services.

Of course, from the implementation perspective, it does not prevent us from performing authentication/authorization in Spring Cloud Gateway, it is just a matter of choice.

Resource server

In this section, we use Spring Boot to set up a simple resource server. The example provides two API interfaces for the resource server and protects them through Spring Security OAuth2 resource server configuration.

Maven dependencies

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
  <version>2.6.7</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Configuration

Add the jwk-set-uri property in application.yml:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://127.0.0.1:8080
          jwk-set-uri: http://127.0.0.1:8080/oauth2/jwks 

server:
  port: 8090
Enter fullscreen mode Exit fullscreen mode

Create the ResourceServerConfig class to configure the Spring Security security module, and use the @EnableMethodSecurity annotation to enable annotation-based security.

@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((authorize) -> authorize
                .anyRequest().authenticated()
                 )
                .oauth2ResourceServer()
                .jwt();
        return http.build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Spring Security resource server uses the scope and scp attributes in the claim by default to verify the token and extract permission information.

Spring Security JwtAuthenticationProvider extracts permission information and other information through the JwtAuthenticationConverter auxiliary converter.

However, in this example, the internal permissions use the authorities attribute, so we use JwtAuthenticationConverter to manually extract permissions.

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
        grantedAuthoritiesConverter.setAuthorityPrefix("");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }
Enter fullscreen mode Exit fullscreen mode

Here, we specify the permission attribute as authorities and completely remove the permission prefix.

Finally, we will create API interfaces for testing in the example and use @PreAuthorize to protect the interface, which can only be accessed by corresponding permissions:

@RestController
public class ArticleController {

    List<String> article = new ArrayList<String>() {{
        add("article1");
        add("article2");
    }};

    @PreAuthorize("hasAuthority('read')")
    @GetMapping("/resource/article/read")
    public Map<String, Object> read(@AuthenticationPrincipal Jwt jwt) {
        Map<String, Object> result = new HashMap<>(2);
        result.put("principal", jwt.getClaims());
        result.put("article", article);
        return result;
    }

    @PreAuthorize("hasAuthority('write')")
    @GetMapping("/resource/article/write")
    public String write(@RequestParam String name) {
        article.add(name);
        return "success";
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing our application

After we have started the service, we access http://127.0.0.1:8070/resource/article/read in the browser and we will be redirected to the authorization service login page as shown below:

Image description

After we enter the username and password (admin/password), we will get the request response information:

Image description

The role of the admin user is ROLE_ADMIN, so we try to request http://127.0.0.1:8070/resource/article/write?name=article3

Image description

After logging out, we also access http://127.0.0.1:8070/resource/article/read, but this time we use Github login, and the response information is as shown below:

Image description

We can see that the response information shows that the user has switched to your Github username.

Users who log in with Github are assigned the ROLE_OPERATION role by default, and ROLE_OPERATION does not have access to http://127.0.0.1:8070/resource/article/write?name=article3. Let's try to test it:

Image description

The result shows that our request is rejected, with a 403 status code indicating that we do not have access permissions.

Conclusion

In this article, you learned how to use Spring Cloud Gateway to protect microservices with OAuth2. In the example, the browser cookie only stores the session ID, and the JWT access token is not exposed to the browser but flows internally in the service. This way, we experience the advantages of JWT and also use the cookie-session to compensate for the shortcomings of JWT, such as when we need to implement a forced user logout function.

As always, the source code used in this article is available on GitHub.

Thanks for reading! 😊

Top comments (1)

Collapse
 
vdelitz profile image
vdelitz

Hey great article on adding OAuth2 authentication to Spring - learned a lot. Recently, I was digging deeper into Spring + WebAuthn / passkey authentication. Have you ever implemented that, too?