天天看點

如何将Spring Cloud Gateway 與OAuth2模式一起使用

作者:小滿隻想睡覺

概述

Spring Cloud Gateway是一個建構在 Spring 生态之上的 API Gateway。 建立在Spring Boot 2.x、Spring WebFlux和Project Reactor之上。

本節中您将使用Spring Cloud Gateway将請求路由到Servlet API服務。

本文您将學到:

  • OpenID Connect 身份驗證 - 用于使用者身份驗證
  • 令牌中繼-Spring Cloud Gateway API網關充當用戶端将令牌轉發到資源請求上

先決條件:

  • Java 8+
  • MySQL
  • Redis

OpenID Connect身份驗證

OpenID Connect 定義了一種基于 OAuth2 授權代碼流的最終使用者身份驗證機制。下圖是Spring Cloud Gateway與授權服務進行身份驗證完整流程,為了清楚起見,其中一些參數已被省略。

如何将Spring Cloud Gateway 與OAuth2模式一起使用

建立授權服務

本節中我們将使用Spring Authorization Server 建構授權服務,支援OAuth2協定與OpenID Connect協定。同時我們還将使用RBAC0基本權限模型控制通路權限。并且該授權服務同時作為OAuth2用戶端支援Github第三方登入。

相關資料庫表結構

我們建立了基本RBAC0權限模型用于本文示例講解,并提供了OAuth2授權服務持久化存儲所需表結構和OAuth2用戶端持久化存儲所需表結構。通過oauth2_client_role定義外部系統角色與本平台角色映射關系。涉及相關建立表及初始化資料的SQL語句可以從這裡擷取。

如何将Spring Cloud Gateway 與OAuth2模式一起使用

角色說明

本節中授權服務預設提供兩個角色,以下是角色屬性及通路權限:

read write
ROLE_ADMIN
ROLE_OPERATION

Maven依賴

<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>
複制代碼           

配置

首先我們從application.yml配置開始,這裡我們指定了端口号與MySQL連接配接配置:

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>> # 修改使用者名
      password: <<password>> # 修改密碼
複制代碼           

接下來我們将建立AuthorizationServerConfig,用于配置OAuth2及OIDC所需Bean,首先我們将新增OAuth2用戶端資訊,并持久化到資料庫:

@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) // 生成JWT令牌
                        .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                        .accessTokenTimeToLive(Duration.ofSeconds(30 * 60))//accessTokenTimeToLive:access_token有效期
                        .refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))//refreshTokenTimeToLive:refresh_token有效期
                        .reuseRefreshTokens(true)
                        .build())
                .build();

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

複制代碼           

其次我們将建立授權過程中所需持久化容器類:

@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);
    }
複制代碼           

授權伺服器需要其用于令牌的簽名密鑰,讓我們生成一個 2048 位元組的 RSA 密鑰:

@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;
  }
}
複制代碼           

接下來我們将建立用于OAuth2授權的SecurityFilterChain,SecurityFilterChain是Spring Security提供的過濾器鍊,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();
    }
複制代碼           

上述我們配置了OAuth2和OpenID Connect預設配置,并将為認證請求重定向到登入頁,同時我們還啟用了Spring Security提供的OAuth2資源服務配置,該配置用于保護OpenID Connect中/userinfo使用者資訊端點。

在啟用Spring Security的OAuth2資源服務配置時我們指定了JWT驗證,是以我們需要在application.yml中指定jwk-set-uri或聲明式添加JwtDecoder,下面我們使用聲明式配置:

@Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }
複制代碼           

接下來我們将自定義Access Token,在本示例中我們使用RBAC0權限模型,是以我們在Access Token中添加authorities為目前使用者所屬角色的權限(permissionCode):

@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()));
                });
            }
        };
    }
}

複制代碼           
RoleRepository屬于role表持久層對象,在本示例中選用JPA架構,相關代碼将不在文中展示,如果您并不了解JPA使用,可以使用Mybatis替代。

下面我們将配置授權服務Form表單認證方式:

@Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests.anyRequest().authenticated()
                )
                .formLogin(withDefaults())
                
          ...
          
        return http.build();
    }
複制代碼           

接下來我們将建立JdbcUserDetailsService 實作 UserDetailsService,用于在認證過程中查找登入使用者的密碼及權限資訊,至于為什麼需要實作UserDetailsService,感興趣可以檢視UsernamePasswordAuthenticationFilter -> ProviderManager -> DaoAuthenticationProvider 源碼,在DaoAuthenticationProvider中通過調用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);
    }
}

複制代碼           
@Bean
    UserDetailsService userDetailsService(UserRepository userRepository) {
        return new JdbcUserDetailsService(userRepository);
    }
複制代碼           

在嘗試請求未認證接口将會引導使用者到登入頁面并提示輸入使用者名密碼,結果如下:

如何将Spring Cloud Gateway 與OAuth2模式一起使用

使用者通常需要使用多個平台,這些平台由不同組織提供和托管。 這些使用者可能需要使用每個平台的特定(和不同)的憑據。當使用者擁有許多不同的憑據時,他們常常會忘記登入憑據。

聯合身份驗證是使用外部系統對使用者進行身份驗證。這可以與Google,Github或任何其他身份提供商一起使用。在這裡,我将使用Github進行使用者身份驗證和資料同步管理。

Github身份認證

首先我們将配置Github用戶端資訊,你隻需要更改其中clientId和clientSecret。其次我們将使用Spring Security 持久化OAuth2用戶端 文中介紹的JdbcClientRegistrationRepository持久層容器類将GitHub用戶端資訊存儲在資料庫中:

@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;
    }
複制代碼           

接下來我們将執行個體化OAuth2AuthorizedClientService和OAuth2AuthorizedClientRepository:

  • OAuth2AuthorizedClientService:負責OAuth2AuthorizedClient在 Web 請求之間進行持久化。
  • OAuth2AuthorizedClientRepository:用于在請求之間儲存和持久化授權用戶端。
@Bean
    OAuth2AuthorizedClientService authorizedClientService(
            JdbcTemplate jdbcTemplate,
            ClientRegistrationRepository clientRegistrationRepository) {
        return new JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository);
    }

    @Bean
    OAuth2AuthorizedClientRepository authorizedClientRepository(
            OAuth2AuthorizedClientService authorizedClientService) {
        return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
    }
複制代碼           

對于每個使用Github登入的使用者,我們都要配置設定平台的角色以控制他們可以通路哪些資源,在此我們将建立AuthorityMappingOAuth2UserService類授予使用者角色:

@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());
        //當沒有指定用戶端角色,則預設賦予最小權限ROLE_OPERATION
        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);
    }
}
複制代碼           

我們可以看到從authority和role屬性中擷取權限資訊,在通過OAuth2ClientRoleRepository查找映射到本平台的角色屬性。

注意:authority和role是由平台自定義屬性,與OAuth2協定與Open ID Connect 協定無關,在生産環境中你可以與外部系統協商約定一個屬性來傳遞權限資訊。

OAuth2ClientRoleRepository為oauth2_client_role表持久層容器類,由JPA實作。

對于未擷取到預先定義的映射角色資訊,我們将賦予預設ROLE_OPERATION最小權限角色。而在本示例中GitHub登入的使用者來說,也将被賦予ROLE_OPERATION角色。

針對GitHub認證成功并且首次登入的使用者我們将擷取使用者資訊并持久化到user表中,這裡我們實作AuthenticationSuccessHandler并增加持久化使用者邏輯:

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;
    }
}
複制代碼           

我們将通過setOauth2UserHandler(Consumer oauth2UserHandler)方法将UserRepositoryOAuth2UserHandler注入到SavedUserAuthenticationSuccessHandler中,UserRepositoryOAuth2UserHandler定義了具體持久層操作:

@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);
        }
    }
}
複制代碼           

我們通過defaultOAuth2User.getAuthorities()擷取到映射後的角色資訊,并将其與使用者資訊存儲到資料庫中。

UserRepository和RoleRepository為持久化容器類。

最後我們向SecurityFilterChain加入OAuth2 Login配置:

@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();
    }
複制代碼           

建立Spring Cloud Gateway應用程式

本節中我們将在Spring Cloud Gateway中通過Spring Security OAuth2 Login 啟用OpenID Connect身份驗證,并将Access Token中繼到下遊服務。

Maven依賴

<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>

複制代碼           

配置

首先我們在application.yml添加以下屬性:

server:
  port: 8070
  servlet:
    session:
      cookie:
        name: GATEWAY-CLIENT
複制代碼           

這裡指定了cookie name為GATEWAY-CLIENT,避免與授權服務JSESSIONID沖突。

通過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

複制代碼           

TokenRelay 過濾器将提取存儲在使用者會話中的通路令牌,并将其作為Authorization标頭添加到傳出請求中。這允許下遊服務對請求進行身份驗證。

我們将在application.yml中添加OAuth2用戶端資訊:

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
複制代碼           

OpenID Connect 使用一個特殊的權限範圍值 openid 來控制對 UserInfo 端點的通路,其他資訊與上節中授權服務注冊用戶端資訊參數保持一緻。

我們通過Spring Security攔截未認證請求到授權伺服器進行認證。為了簡單起見,CSRF被禁用。

@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();
    }
}
複制代碼           

Spring Cloud Gateway在完成OpenID Connect身份驗證後,将使用者資訊和令牌存儲在session會話中,是以添加spring-session-data-redis提供由 Redis 支援的分布式會話功能,在application.yml中添加以下配置:

spring:
  session:
    store-type: redis # 會話存儲類型
    redis:
      flush-mode: on_save # 會話重新整理模式
      namespace: gateway:session # 用于存儲會話的鍵的命名空間
  redis:
    host: localhost
    port: 6379
    password: 123456
複制代碼           

基于上述示例我們使用 Spring Cloud Gateway驅動身份驗證,知道如何對使用者進行身份驗證,可以為使用者擷取令牌(在使用者同意後),但不對通過Gateway的請求進行身份驗證/授權(Spring Gateway Cloud并不是Access Token的閱聽人目标)。這種方法背後的原因是一些服務是受保護的,而一些是公共的。即使在單個服務中,有時也隻能保護幾個端點而不是每個端點。這就是我将請求的身份驗證/授權留給特定服務的原因。

當然從實作角度并不妨礙我們在Spring Cloud Gateway進行身份驗證/授權,這隻是一個選擇問題。

搭建資源服務

本節中我們使用Spring Boot搭建一個簡單的資源服務,示例中資源服務提供兩個API接口,并通過Spring Security OAuth2資源服務配置保護。

Maven依賴

<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>
複制代碼           

配置

在application.yml中添加jwk-set-uri屬性:

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
複制代碼           

建立ResourceServerConfig類來配置Spring Security安全子產品,@EnableMethodSecurity注解來啟用基于注解的安全性:

@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();
    }
}
複制代碼           

Spring Security資源服務在驗證token提取權限預設使用claim中scope和scp屬性。

Spring Security JwtAuthenticationProvider通過JwtAuthenticationConverter輔助轉換器提取權限等資訊。

但是在本示例中内部化權限使用authorities屬性,是以我們使用JwtAuthenticationConverter 手動提取權限:

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

    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    return jwtAuthenticationConverter;
}   
    
複制代碼           

在這裡我們将權限屬性指定為authorities,并完全删除權限字首。

最後我們将建立用于示例中測試的API接口,使用@PreAuthorize保護接口必須由相應權限才能通路:

@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";
    }
}
複制代碼           

測試我們的應用程式

在我們啟動完成服務後,我們在浏覽器中通路http://127.0.0.1:8070/resource/article/read ,我們将重定向到授權服務登入頁,如圖所示:

如何将Spring Cloud Gateway 與OAuth2模式一起使用

在我們輸入使用者名密碼(admin/password)後,将擷取到請求響應資訊:

如何将Spring Cloud Gateway 與OAuth2模式一起使用

admin使用者所屬角色是ROLE_ADMIN,是以我們嘗試請求http://127.0.0.1:8070/resource/article/write?name=article3

如何将Spring Cloud Gateway 與OAuth2模式一起使用

登出登入後,我們同樣通路http://127.0.0.1:8070/resource/article/read ,不過這次使用Github登入,響應資訊如圖所示:

如何将Spring Cloud Gateway 與OAuth2模式一起使用

可以看到響應資訊中使用者已經切換為你的Github使用者名。

Github登入的使用者預設賦予角色為ROLE_OPERATION,而ROLE_OPERATION是沒有http://127.0.0.1:8070/resource/article/write?name=article3 通路權限,我們來嘗試測試下:

如何将Spring Cloud Gateway 與OAuth2模式一起使用

結果我們請求被拒絕,403狀态碼提示我們沒有通路權限。

結論

本文中您了解到如何使用Spring Cloud Gateway結合OAuth2保護微服務。在示例中浏覽器cookie僅存儲sessionId,JWT通路令牌并沒有暴露給浏覽器,而是在内部服務中流轉。這樣我們體驗到了JWT帶來的優勢,也同樣利用cookie-session彌補了JWT的不足,例如當我們需要實作強制使用者登出功能。

與往常一樣,本文中使用的源代碼可在 GitHub 上獲得。