弹簧安全和OpenID连接
#安全 #java #oauth2 #springsecurity

概述

OpenID Connect是OpenID基金会于2014年2月发布的开放标准。它定义了使用OAuth 2.0执行用户身份验证的互动方式。 OpenID Connect直接在OAuth 2.0上构建,并与之兼容。

当授权服务器支持OIDC时,有时将其称为身份提供商(IDP),因为它向 client 提供了有关资源所有者的信息。 client 也称为OpenID Connect进程中的依赖方(RP)。

OpenID连接流程看起来与OAuth2相同。主要区别在于,在授权请求中,使用特定的范围openid,而在获得令牌中,登录依赖方(RP)同时接收访问令牌和ID令牌(签名JWT) 。 ID令牌与访问令牌不同,该ID令牌是由登录依赖方(RP)发送并解析的。

在本文中,您将学习:

  • 配置授权服务以支持OpenID Connect
  • 自定义ID令牌
  • 登录依赖方实施权限映射通过OAuth2UserService

先决条件:

  • Java 8+
  • mysql

使用春季授权服务器来构建身份提供商服务器(IDP)ð

在本节中,我们将使用Spring Authorization Server来构建身份提供商服务,并通过OAuth2TokenCustomizer实现自定义ID代币。

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

我们配置的属性是:

  • 客户端 - 身份提供商将使用它来确定哪个客户试图访问资源
  • 客户端 - 客户和身份提供商服务都知道的秘密,该服务提供了两者之间的信任
  • clientauthenticationmethod-客户端身份验证方法,在我们的示例中,我们将支持基本和发布身份验证方法
  • 授权granttype-授予类型,支持授权代码和刷新令牌
  • redirecturi-重定向URI,客户将在基于重定向的流程中使用
  • 范围 - 此参数定义客户端可能拥有的权限。在我们的情况下,我们将拥有所需的 openID profile emali ,以获取其他身份信息

OpenID Connect使用特殊的权限范围值OpenID来控制对UserInfo端点的访问。 OpenID Connect定义了一组标准化的OAuth2权限示波器,对应于用户属性配置文件,电子邮件,电话,地址,请参阅表:

权威范围 语句
openID sub
个人资料 name,family_name,fived_name,midder_name,nickname,preferred_username,个人资料,图片,网站,网站,性别,出生日期,Zoneinfo,Zoneinfo,locale,locale,Updated_at
电子邮件 电子邮件,email_verified
地址 地址是一个JSON对象,包含格式化,street_address,局部,地区,postal_code,country
电话 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 + "@outlook.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("13028903134").phoneNumberVerified("false");
            }
        }
        return builder.build();
    }
}

接下来,我们将配置一个BEAN应用默认的OAuth2安全性。使用上述OidcUserInfoService在OIDC中配置UserInfomapper。 OAuth2Resourceserver()配置资源服务器使用JWT身份验证来保护Spring Security提供的 /UserInfo端点。对于未经验证的请求,我们将其重定向到 /登录登录页面。< /p>

注意:有时“授权服务器”和“资源服务器”是同一服务器。

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

        //custom User Mapper
        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();
    }

    //...
}

在这里我们使用表单身份验证,因此我们还需要提供登录身份验证的用户名和密码。

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

我们将更改 ID令牌索赔,并添加用户角色属性以将角色信息传递给客户。

@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语句可以从here

获得。

Image description

配置

首先,我们在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>>

接下来,我们将启用弹簧安全配置。使用表单身份验证,并使用 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();
    }

接下来,我们将基于MySQL数据库配置OAuth2客户端的存储方法。您也可以从Spring Security Persistent OAuth2 client中了解更多有关它的信息。

    /**
     * Define the JDBC client registration repository
     *
     * @param jdbcTemplate
     * @return
     */
    @Bean
    public ClientRegistrationRepository clientRegistrationRepository(JdbcTemplate jdbcTemplate) {
        return new JdbcClientRegistrationRepository(jdbcTemplate);
    }

    /**
     * Responsible for {@link OAuth2AuthorizedClient} persistence between web requests
     *
     * @param jdbcTemplate
     * @param clientRegistrationRepository
     * @return
     */
    @Bean
    public OAuth2AuthorizedClientService authorizedClientService(
            JdbcTemplate jdbcTemplate,
            ClientRegistrationRepository clientRegistrationRepository) {
        return new JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository);
    }

    /**
     * OAuth2AuthorizedClientRepository is a container class for saving and persisting authorized clients between requests
     *
     * @param authorizedClientService
     * @return
     */
    @Bean
    public OAuth2AuthorizedClientRepository authorizedClientRepository(
            OAuth2AuthorizedClientService authorizedClientService) {
        return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
    }

我们不再使用基于内存的用户名和密码。初始化数据库时,我们已将用户名和密码添加到用户表中,因此我们需要在形式身份验证期间实现UserDetailsService接口以获取用户信息。

@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,并为用户表提供数据库操作。可以从文章末尾的链接中获得详细的代码。

现在,我们将解决如何将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/home进行测试。

结论

在本文中,Spring Security对OpenID连接的支持是共享的。与往常一样,本文中使用的源代码可用on GitHub

感谢您的阅读! ð