Spring Security和OpenID Connect

Spring Security和OpenID Connect

概述

OpenID Connect 是一個開放標準,由 OpenID 基金會于 2014 年 2 月發布。它定義了一種使用 OAuth 2.0 執行用戶身份認證的互通方式。OpenID Connect 直接基于 OAuth 2.0 構建,并保持與它兼容。

當授權服務器支持 OIDC 時,它有時被稱為身份提供者(Idp),因為它向客戶端提供有關資源所有者的信息。而客戶端映射為OpenID Connect 流程中登錄依賴方(RP)。在本文中我們將授權服務稱為身份提供者,客戶端稱為登錄依賴方進行陳述。

OpenID Connect 流程看起來與 OAuth 相同。主要區別是,在授權請求中,使用了一個特定的范圍openid,而在獲取token中,登錄依賴方(RP)同時接收到一個訪問令牌和一個ID 令牌(經過簽名的 JWT)。ID令牌與訪問令牌不同的是,ID 令牌是發送給 RP 的,并且要被它解析。

本文您將學到

  • 配置授權服務支持OpenID Connect
  • 自定義ID令牌
  • 登錄依賴方通過OAuth2UserService實現權限映射

先決條件:

  • java 8+
  • mysql

使用Spring Authorization Server搭建身份提供服務(IdP)

本節中我們將使用Spring Authorization Server搭建身份提供服務,并通過OAuth2TokenCustomizer實現自定義ID Token。

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-web</artifactId>
  <version>2.6.7</version>
</dependency>

配置

首先我們配置身份提供服務端口8080:

server:
  port: 8080

接下來我們創建AuthorizationServerConfig配置類,在此類中我們配置OAuth2及OICD相關Bean。我們首先注冊一個客戶端:

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .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-client-oidc")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .scope(OidcScopes.EMAIL)
                .clientSettings(ClientSettings.builder()
                        .requireAuthorizationConsent(true)
                        .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();
        return new InMemoryRegisteredClientRepository(registeredClient);
    }

我們正在配置的屬性是:

  • clientId – Spring Security將使用它來識別哪個客戶端正在嘗試訪問資源
  • clientSecret——客戶端和服務器都知道的一個秘密,它提供了兩者之間的信任
  • 客戶端驗證方式——在我們的例子中,我們將支持basic和post身份驗證方式
  • 授權類型——允許客戶端生成授權碼和刷新令牌
  • 重定向 URI – 客戶端將在基于重定向的流程中使用它
  • scope——此參數定義客戶端可能擁有的權限。在我們的例子中,我們將擁有所需的OidcScopes.OPENID和用來獲取額外的身份信息OidcScopes.PROFILEOidcScopes.EMAIL


OpenID Connect 使用一個特殊的權限范圍值 openid 來控制對 UserInfo 端點的訪問。 OpenID Connect 定義了一組標準化的 OAuth 權限范圍,對應于用戶屬性的子集profile、email、 phone、address,參見表格:

權限范圍 聲明
openid sub
profile Name、family_name、given_name、middle_name、nickname、preferred_username、profile、 picture、website、gender、birthdate、zoneinfo、locale、updated_at
email email、email_verified
address address,是一個 JSON 對象、包含 formatted、street_address、locality、region、postal_code、country
phone phone_number、phone_number_verified

讓我們根據上述規范定義OidcUserInfoService,用于擴展/userinfo用戶信息端點響應:

public class OidcUserInfoService {

    public OidcUserInfo loadUser(String name, Set<String> scopes) {
        OidcUserInfo.Builder builder = OidcUserInfo.builder().subject(name);
        if (!CollectionUtils.isEmpty(scopes)) {
            if (scopes.contains(OidcScopes.PROFILE)) {
                builder.name("First Last")
                        .givenName("First")
                        .familyName("Last")
                        .middleName("Middle")
                        .nickname("User")
                        .preferredUsername(name)
                        .profile("http://127.0.0.1:8080/" + name)
                        .picture("http://127.0.0.1:8080/" + name + ".jpg")
                        .website("http://127.0.0.1:8080/")
                        .gender("female")
                        .birthdate("2022-05-24")
                        .zoneinfo("China/Beijing")
                        .locale("zh-cn")
                        .updatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
            }
            if (scopes.contains(OidcScopes.EMAIL)) {
                builder.email(name + "@163.com").emailVerified(true);
            }
            if (scopes.contains(OidcScopes.ADDRESS)) {
                JSONObject address = new JSONObject();
                address.put("address", Collections.singletonMap("formatted", "Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance"));
                builder.address(address.toJSONString());
            }
            if (scopes.contains(OidcScopes.PHONE)) {
                builder.phoneNumber("13728903134").phoneNumberVerified("false");
            }
        }
        return builder.build();
    }
}


接下來,我們將配置一個 bean 以應用默認 OAuth 安全性。使用上述OidcUserInfoService配置OIDC中UserInfoMapper;oauth2ResourceServer()配置資源服務器使用JWT驗證,用來保護Spring Security 提供的/userinfo端點;對于未認證請求我們會將它重定向到/login 登錄頁:

注意:有時“授權服務器”和“資源服務器”是同一臺服務器。

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
                new OAuth2AuthorizationServerConfigurer<>();

        //自定義用戶映射器
        Function<OidcUserInfoAuthenticationContext, OidcUserInfo> userInfoMapper = (context) -> {
            OidcUserInfoAuthenticationToken authentication = context.getAuthentication();
            JwtAuthenticationToken principal = (JwtAuthenticationToken) authentication.getPrincipal();
            return userInfoService.loadUser(principal.getName(), context.getAccessToken().getScopes());
        };
        authorizationServerConfigurer.oidc((oidc) -> {
            oidc.userInfoEndpoint((userInfo) -> userInfo.userInfoMapper(userInfoMapper));
        });

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


每個授權服務器都需要其用于令牌的簽名密鑰,讓我們生成一個 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;
  }
}


然后我們將使用帶有@EnableWebSecurity注釋的配置類啟用 Spring Web 安全模塊:

@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class DefaultSecurityConfig {

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

    //...
}

這里我們使用Form表單認證方式,所以我們還需要為登錄認證提供用戶名和密碼:

    @Bean
    UserDetailsService users() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("admin")
                .password("password")
                .roles("ADMIN")
                .build();
        return new InMemoryUserDetailsManager(user);
    }


至此,我們服務配置完成,但是用于給客戶端傳遞權限信息,我們將更改ID Token claim,添加用戶角色屬性:

@Configuration(proxyBeanMethods = false)
public class IdTokenCustomizerConfig {

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
        return (context) -> {
            if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
                context.getClaims().claims(claims ->
                        claims.put("role", context.getPrincipal().getAuthorities()
                                .stream().map(GrantedAuthority::getAuthority)
                                .collect(Collectors.toSet())));
            }
        };
    }
}

登錄依賴方服務(RP)實現

本節中我們將使用Spring Security搭建登錄依賴方服務,并設計相關數據庫表結構表達身份提供方服務與登錄依賴方服務權限關系,通過OAuth2UserService實現權限映射。

本節中部分代碼涉及JPA相關知識,如果您并不了解也沒有關系,您可以通過Mybatis進行替換。

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-oauth2-client</artifactId>
  <version>2.6.7</version>  
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-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>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
  <version>2.6.7</version>  
</dependency>

<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>8.0.21</version>  
</dependency>

相關數據庫表結構

這是我們本文中RP服務使用的相關數據庫表,涉及相關創建表及初始化數據的SQL語句可以從這里獲取。

oauth2_sql_model.png

配置

首先我們通過application.yml文件中配置服務端口和數據庫連接信息:

server:
  port: 8070
  servlet:
    session:
      cookie:
        name: CLIENT-SESSION

spring:
  datasource:
    druid:
      db-type: mysql
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/oidc_login?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
      username: <<root>> # 修改用戶名
      password: <<password>> # 修改密碼


接下來我們將啟用Spring Security安全配置。使用Form認證方式;并使用oauth2Login()定義OAuth2登錄默認配置:

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
                .anyRequest()
                .authenticated()
                .and()
                .formLogin(from -> {
                    from.defaultSuccessUrl("/home");
                })
                .oauth2Login(Customizer.withDefaults())
                .csrf().disable();
        return http.build();
    }


下面我們將配置OAuth2客戶端基于MySql數據庫的存儲方式,你也可以從Spring Security 持久化OAuth2客戶端了解詳細信息。

    /**
     * 定義JDBC 客戶端注冊存儲庫
     *
     * @param jdbcTemplate
     * @return
     */
    @Bean
    public ClientRegistrationRepository clientRegistrationRepository(JdbcTemplate jdbcTemplate) {
        return new JdbcClientRegistrationRepository(jdbcTemplate);
    }

    /**
     * 負責{@link org.springframework.security.oauth2.client.OAuth2AuthorizedClient}在 Web 請求之間進行持久化
     *
     * @param jdbcTemplate
     * @param clientRegistrationRepository
     * @return
     */
    @Bean
    public OAuth2AuthorizedClientService authorizedClientService(
            JdbcTemplate jdbcTemplate,
            ClientRegistrationRepository clientRegistrationRepository) {
        return new JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository);
    }

    /**
     * OAuth2AuthorizedClientRepository 是一個容器類,用于在請求之間保存和持久化授權客戶端
     *
     * @param authorizedClientService
     * @return
     */
    @Bean
    public OAuth2AuthorizedClientRepository authorizedClientRepository(
            OAuth2AuthorizedClientService authorizedClientService) {
        return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
    }


我們不在使用基于內存的用戶名密碼,在初始化數據庫時我們已經將用戶名密碼添加到user表中,所以我們需要實現UserDetailsService接口用于Form認證時獲取用戶信息:

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

這里UserRepository繼承了JpaRepository,提供user表的CRUD,詳細代碼可以在文末鏈接中獲取。


現在我們將要解決如何將IdP服務用戶角色映射為RP服務已有的角色,在前面文章中曾使用GrantedAuthoritiesMapper映射角色。在本文中我們將使用OAuth2UserService添加角色映射策略,它與GrantedAuthoritiesMapper相比更加靈活:

public class OidcRoleMappingUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
    private OidcUserService oidcUserService;
    private final OAuth2ClientRoleRepository oAuth2ClientRoleRepository;

    //...

    @Override
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        OidcUser oidcUser = oidcUserService.loadUser(userRequest);

        OidcIdToken idToken = userRequest.getIdToken();
        List<String> role = idToken.getClaimAsStringList("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());
        oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());

        return oidcUser;
    }
}

最后我們將創建HomeController,通過控制頁面中展示內容使測試效果視覺上更加顯著,我們將根據角色展示不同信息,使用thymeleaf模版引擎渲染。

@Controller
public class HomeController {

    private static Map<String, List<String>> articles = new HashMap<>();

    static {
        articles.put("ROLE_OPERATION", Arrays.asList("Java"));
        articles.put("ROLE_SYSTEM", Arrays.asList("Java", "Python", "C++"));
    }

    @GetMapping("/home")
    public String home(Authentication authentication, Model model) {
        String authority = authentication.getAuthorities().iterator().next().getAuthority();
        model.addAttribute("articles", articles.get(authority));
        return "home";
    }
}

完成配置后,我們可以訪問 http://127.0.0.1:8070/login 進行測試。

結論

在本文中分享了Spring Security對于OpenID Connect的支持。如果您有任何問題,請在下面發表評論。

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

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容