DEV Community

ReLive27
ReLive27

Posted on

Spring Security and OpenID Connect

Overview

OpenID Connect is an open standard published by the OpenID Foundation in February 2014. It defines an interworking way to perform user authentication using OAuth 2.0. OpenID Connect builds directly on OAuth 2.0 and remains compatible with it.

When an authorization server supports OIDC, it is sometimes called an Identity Provider (Idp), because it provides information to client about the owner of a resource. And the client is also referred to as the Relying Party (RP) in the OpenID Connect process.

The OpenID Connect flow looks the same as OAuth2. The main difference is that in the authorization request, a specific scope openid is used, while in the obtain token, the login relying party (RP) receives both an access token and an ID token (signed JWT). ID token differ from access token in that ID token are sent to and parsed by the login relying party (RP).

In this article you will learn:

  • Configure the authorization service to support OpenID Connect
  • Custom id token
  • The login relying party implements permission mapping through OAuth2UserService

Prerequisites:

  • java 8+
  • MySQL

Use Spring Authorization Server to build identity provider server (IdP) πŸš€

In this section, we will use Spring Authorization Server to build an identity provider service, and implement a custom ID token through OAuth2TokenCustomizer.

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

Enter fullscreen mode Exit fullscreen mode

Configuration

First we configure the identity provider service port 8080:

server:
  port: 8080
Enter fullscreen mode Exit fullscreen mode

Next we create the AuthorizationServerConfig configuration class, in which we configure OAuth2 and OICD related beans. We first register a client:

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

Enter fullscreen mode Exit fullscreen mode

The properties we are configuring are:

  • clientId -- The identity provider will use this to identify which client is trying to access the resource
  • clientSecret -- A secret known to both client and identity provider service, which provides trust between the two
  • clientAuthenticationMethod -- Client authentication method, in our example, we will support basic and post authentication methods
  • authorizationGrantType -- Grant type, support authorization code and refresh token
  • redirectUri -- The redirection URI, which the client will use in the redirection-based flow
  • scope -- This parameter defines the permissions the client may have. In our case, we will have the required openid and profile, emali, to fetch additional identity information

OpenID Connect uses a special permission scope value openid to control access to the UserInfo endpoint. OpenID Connect defines a set of standardized OAuth2 permission scopes, corresponding to the subset of user attributes profile, email, phone, address, see the table:

scope of authority statement
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 is a JSON object, contains formatted, street_address, locality, region, postal_code, country
phone phone_number, phone_number_verified

Let's define OidcUserInfoService according to the above specification, which is used to extend /userinfo user information endpoint response:

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll configure a bean to apply default OAuth2 security. Use the above OidcUserInfoService to configure UserInfoMapper in OIDC. oauth2ResourceServer() configures the resource server to use JWT authentication to secure the /userinfo endpoint provided by Spring Security. For unauthenticated requests we redirect it to the /login login page.

Note: Sometimes the "authorization server" and "resource server" are the same server.

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

Enter fullscreen mode Exit fullscreen mode

Each authorization server needs its signing key for tokens, 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

We will then enable the Spring Web Security module using a configuration class annotated with @EnableWebSecurity.

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

    //...
}
Enter fullscreen mode Exit fullscreen mode

Here we use Form authentication, so we also need to provide username and password for login authentication.

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

We will change the ID Token claim and add the user role attribute to pass the role information to the client.

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

Enter fullscreen mode Exit fullscreen mode

Relying Party Service (RP) Implementation πŸš€

In this section, we will use Spring Security to build the relying party service, and design the relevant database table structure to express the permission relationship between the associated identity provider service and the relying party service, and implement permission mapping through OAuth2UserService.

Part of the code in this section involves JPA-related knowledge. If you don’t understand it, it doesn’t matter. You can replace it with Mybatis.

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

Enter fullscreen mode Exit fullscreen mode

Relevant database table structure

This is the relevant database table used by the RP service in this article, and the SQL statements related to creating tables and initializing data can be obtained from here.

Image description

Configuration

First, we configure the service port and database connection information in the application.yml file.

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

Enter fullscreen mode Exit fullscreen mode

Next we will enable the Spring Security configuration. Use Form authentication, and use oauth2Login() to define the default configuration of OAuth2 login.

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

Enter fullscreen mode Exit fullscreen mode

Next, we will configure the storage method of the OAuth2 client based on the MySql database. You can also learn more about it from the 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);
    }

Enter fullscreen mode Exit fullscreen mode

We are no longer using memory-based username and password. We have added the username and password to the user table when initializing the database, so we need to implement the UserDetailsService interface to obtain user information during Form authentication.

@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

Here UserRepository extends JpaRepository and provides database operations for user tables. The detailed code can be obtained from the link at the end of the article.

Now we will solve how to map the IdP service user role to the existing role of the RP service. In the previous article, GrantedAuthoritiesMapper was used to map the role. In this article we will use OAuth2UserService to add role mapping strategy, which is more flexible than 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;
    }
}


Enter fullscreen mode Exit fullscreen mode

Finally, we will create a HomeController to make the test effect more visually significant by controlling the content displayed on the page. We will display different information according to the role and use the thymeleaf template engine to render.

@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";
    }
}
Enter fullscreen mode Exit fullscreen mode

After completing the configuration, we can visit http://127.0.0.1:8070/home for testing.

Conclusion

In this article, Spring Security's support for OpenID Connect is shared. As always, the source code used in this article is available on GitHub.

Thanks for reading! 😊

Top comments (0)