DEV Community

ReLive27
ReLive27

Posted on

Spring Security OAuth2 Login

Overview

OAuth 2.0 is not an authentication protocol.
What is identity authentication? Authentication is the solution to the "Who are you?". Authentication tells the app who the current user is and whether they are using the app.In practice, it may also tell you the user's name, email address, mobile phone number, etc.

If an extension to OAuth 2.0. Enables messages from authorization servers and protected resources to convey information about users and their authentication context. We can provide the client with all the information for the user to log in securely.
The main advantages of this identity authentication method based on the OAuth 2.0 authorization protocol:

  • The user performs authentication on the authorization server, the end user's original credentials are not passed to the client application through the OAuth 2.0 protocol.
  • Allows users to enforce consent decisions at runtime.
  • The user can also authorize access to other protected APIs along with his identity information. Through a call, the application can know whether the user is logged in, how to call the user, the user's mobile phone number, email address, etc.

In this article, we will use the OAuth 2.0 authorization code mode to securely pass the authorization service user information and log in to the client application.

In this article you will learn:

  • Build basic authorization service and client service.
  • Customize the authorization server access token and add role information.
  • Custom authorization server user info endpoint.
  • The client service uses GrantedAuthoritiesMapper for authorization mapping.
  • Client service custom OAuth2UserService implements parsing multi-layer Json data.

OAuth2 Authorization Server 🚀

In this section we will use Spring Authorization Server to build an authorization server.In addition, we will also customize the access_token and custom user information endpoints.

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 configure the service port 8080 through application.yml:

server:
  port: 8080
Enter fullscreen mode Exit fullscreen mode

Next we will create the OAuth2ServerConfig configuration class to define the specific beans needed for the OAuth2 authorization service. First we register an OAuth2 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-authorization-code")
    .scope(OidcScopes.PROFILE)
    .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 above stores the OAuth2 client in memory. If you need to use database persistence, please refer to the article Using JWT with Spring Security OAuth2. Specify the OAuth2 client information as follows:

Next let's configure other default configurations of the OAuth2 authorization service, and redirect unauthenticated authorization requests to the login page:

@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
  OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

  return http
    .exceptionHandling(exceptions -> exceptions.
                       authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
    .build();
}
Enter fullscreen mode Exit fullscreen mode

The authorization server token token format uses JWT RFC 7519, so we need a signing key for the token, let's generate an 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 customize the access_token access token and add role information to the token:

@Configuration(proxyBeanMethods = false)
public class AccessTokenCustomizerConfig {

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
        return (context) -> {
            if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
                context.getClaims().claims(claim -> {
                    claim.put("role", context.getPrincipal().getAuthorities().stream()
                            .map(GrantedAuthority::getAuthority).collect(Collectors.toSet()));
                });
            }
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

You can see that Spring Security provides us with OAuth2TokenCustomizer for extending token information. We get the current user information from OAuth2TokenContext, and extract the Authorities information from it and add it to the JWT claim.

Below we will create a Spring Security configuration class to configure the basic authentication capabilities of the authorization service.

@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/userInfo")
                .access("hasAnyAuthority('SCOPE_profile')")
                .mvcMatchers("/userInfo")
                .access("hasAuthority('SCOPE_profile')")
                .anyRequest().authenticated()
                .and()
                .formLogin(Customizer.withDefaults())
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        return http.build();
    }

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

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

In the above configuration class, we did the following things:

  1. Enable Form authentication method;
  2. Configure login username and password;
  3. Use oauth2ResourceServer() to configure JWT authentication, and declare JwtDecoder;
  4. Protecting the /userInfo endpoint requires profile permissions for access;

At this point, we also need to create a Controller class to provide the OAuth2 client service to obtain user information:

@RestController
public class UserInfoController {

    @PostMapping("/userInfo")
    public Map<String, Object> getUserInfo(@AuthenticationPrincipal Jwt jwt) {
        return Collections.singletonMap("data", jwt.getClaims());
    }
}
Enter fullscreen mode Exit fullscreen mode

We return user information in JSON format:

{
  "data":{
    "sub":"admin"
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

OAuth2 Client Service 🚀

This section will use Spring Security to configure OAuth2 client login. And we will use GrantedAuthoritiesMapper to map authority information. And we will create DefaultJsonOAuth2UserService for parsing multi-layer JSON user information data.

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

Enter fullscreen mode Exit fullscreen mode

Configuration

First, we specify the client service port number 8070, and configure the OAuth2 client related information. The client information needs to be consistent with the authorization server registration information.

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

spring:
  security:
    oauth2:
      client:
        registration:
          messaging-client-authorization-code:
            provider: client-provider
            client-id: relive-client
            client-secret: relive-client
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: profile
            client-name: messaging-client-authorization-code
        provider:
          client-provider:
            authorization-uri: http://127.0.0.1:8080/oauth2/authorize
            token-uri: http://127.0.0.1:8080/oauth2/token
            user-info-uri: http://127.0.0.1:8080/userInfo
            user-name-attribute: data.sub
            user-info-authentication-method: form

Enter fullscreen mode Exit fullscreen mode

Next, configure Spring Security related beans. We first enable Form form authentication and OAuth2 login capabilities. Here we specify to redirect to the /home path after successful authentication.

@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

Below we use GrantedAuthoritiesMapper to map user permissions:

@Bean
GrantedAuthoritiesMapper userAuthoritiesMapper() {
  //Role mapping relationship, authorization server ADMIN role corresponds to client OPERATION role
  Map<String, String> roleMapping = new HashMap<>();
  roleMapping.put("ROLE_ADMIN", "ROLE_OPERATION");
  return (authorities) -> {
    Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
    authorities.forEach(authority -> {
      if (OAuth2UserAuthority.class.isInstance(authority)) {
        OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;
        Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();
        List<String> role = (List) userAttributes.get("role");
        role.stream().map(roleMapping::get)
          .filter(StringUtils::hasText)
          .map(SimpleGrantedAuthority::new)
          .forEach(mappedAuthorities::add);
      }
    });
    return mappedAuthorities;
  };
}
Enter fullscreen mode Exit fullscreen mode

The above maps the OAuth2 authorization service ADMIN role to the client service OPERATION role. Of course, you can also expand to database operations, so you need to maintain the authorization service role and client service role mapping table, which will not be expanded here.

GrantedAuthoritiesMapper is used as an authority mapper in OAuth2 login, CAS login, SAML and LDAP.

The source code of GrantedAuthoritiesMapper in OAuth2LoginAuthenticationProvider is as follows:

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
    //...

    /* map authorities */
    Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
      .mapAuthorities(oauth2User.getAuthorities());
    /* map authorities */

    OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
      loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(),
      oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken());
    authenticationResult.setDetails(loginAuthenticationToken.getDetails());
    return authenticationResult;
}
Enter fullscreen mode Exit fullscreen mode

So when we custom implement GrantedAuthoritiesMapper. After successful OAuth2 login, store the mapped permission information in the authentication information OAuth2LoginAuthenticationToken.

Next will implement the OAuth2UserService custom DefaultJsonOAuth2UserService class. Of course Spring Security provides DefaultOAuth2UserService, so why not use it? The reason is simple. First, let us review the format of the authorization server returning user information:

{
  "data":{
    "sub":"admin"
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

The user information is nested in the data field, and the DefaultOAuth2UserService does not process this format when processing the user information response. The following is a snippet of the DefaultOAuth2UserService source code:

public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        Assert.notNull(userRequest, "userRequest cannot be null");
        if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
            OAuth2Error oauth2Error = new OAuth2Error("missing_user_info_uri", "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        } else {
            String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
            if (!StringUtils.hasText(userNameAttributeName)) {
                OAuth2Error oauth2Error = new OAuth2Error("missing_user_name_attribute", "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
                throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
            } else {
                RequestEntity<?> request = (RequestEntity)this.requestEntityConverter.convert(userRequest);
               /* Get user information */
              ResponseEntity<Map<String, Object>> response = this.getResponse(userRequest, request);
                //Get the response body information directly here. By default, this userAttributes contains relevant user information, and does not parse multi-layer JSON
                Map<String, Object> userAttributes = (Map)response.getBody();
               /* Get user information */
                Set<GrantedAuthority> authorities = new LinkedHashSet();
                authorities.add(new OAuth2UserAuthority(userAttributes));
                OAuth2AccessToken token = userRequest.getAccessToken();
                Iterator var8 = token.getScopes().iterator();

                while(var8.hasNext()) {
                    String authority = (String)var8.next();
                    authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
                }

                return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

When the DefaultOAuth2User is finally created, you will get the following error message:

Missing attribute 'sub' in attributes
Enter fullscreen mode Exit fullscreen mode

Below we use userNameAttributeName with "." as the separator. Extract user information to achieve. The following is the key code:

public class DefaultJsonOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    //...

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        //...
        RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
        ResponseEntity<JsonNode> response = getResponse(userRequest, request);
        JsonNode responseBody = response.getBody();

        //Multi-layer JSON extracts user information attributes
        Map<String, Object> userAttributes = new HashMap<>();
        if (userNameAttributeName.contains(".")) {
          String firstNodePath = userNameAttributeName.substring(0, userNameAttributeName.lastIndexOf("."));
          userAttributes = this.extractUserAttribute(responseBody, firstNodePath);
          userNameAttributeName = userNameAttributeName.substring(firstNodePath.length() + 1);
        } else {
          userAttributes = JsonHelper.parseMap(responseBody.toString());
        }

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

Finally, we create the Controller class and use the thymeleaf template engine to build the home page information. Different permission information sees different results in the home page list.

@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

Test

After we start the service, visit http://127.0.0.1:8070/login. After successful login with username, you will see:

Image description

We log out, and log in with OAuth2, you will see different information:

Image description

Conclusion

It is feasible for us to use the OAuth2.0 authorization protocol to build identity authentication proofs. But we cannot ignore the pitfalls in between.

  1. The token itself does not convey information about the authentication event. Tokens may be issued directly to clients, using the OAuth 2.0 Client Credentials model without user interaction.
  2. No client can get information about the user and his login status from the access_token. OAuth 2.0 access_token are intended for resource servers.(In this article, we use the JWT access_token to enable the client service to obtain information such as user permissions by customizing the access_token information. However, the OAuth2.0 protocol does not define the access_token format. We only use the characteristics of JWT to make it happen.)
  3. The client can present the access_token to the resource service to obtain user information. So it's easy to think that just having a valid access_token proves that the user is logged in, and this line of thinking is only true in some cases. For example, when a user completes identity authentication on an authorization server and immediately generates an access_token.(Because the access_token validity period may be much longer than the authentication session validity period.)
  4. The biggest problem with the user information API in the OAuth2.0 protocol is that different identity providers may have different user information APIs. A user's unique identifier may be "user_id" or "sub".

So we need a unified OAuth2.0 standard identity authentication protocol. OpenID Connect is an open standard that defines an interoperable way to perform user authentication using OAuth 2.0. This will be covered in a follow-up article.

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

Thanks for reading! 😘

Latest comments (0)