Spring OAuth2 Development Guide: OAuth2 password pattern development example

Posted by Pellefant on Mon, 03 Jan 2022 20:49:38 +0100

Spring OAuth2 Development Guide (II): OAuth2 password pattern development example

1, Opening

This is the second article in the Spring OAuth2 Development Guide series. It introduces the development details of OAuth2 password mode through code examples. There are many and messy code demonstrations about OAuth2 development on the network, which are basically excerpts from the official manual, or too much subject to the framework itself, such as Spring Security. There are too many constraints and lack of systematicness, which is easy to cause students to be confused and move hard sets.

I advocate that in the process of development and landing, we should neither make wheels by ourselves nor rely on wheels. We should start from the essence and choose appropriate methods under the condition of clarifying technical principles and details. Based on this principle, this paper will show its code implementation according to the process nodes described in "typical architecture level and main process of password mode" (see Spring OAuth2 Development Guide (I)). In addition, the key point of this paper is that in the second half, two implementation methods of resource server-side authentication / permission control and authorization server-side authentication / permission control are proposed.

Note that the password mode is due to oauth2 1 is not recommended, so only the old component code version is provided. For details, see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-02

2, Demonstration case

We continue to use the Photo Album Preview System (PAPS) as a demonstration case.

PAPS is a subsystem of a social platform. Similar to IBCS, it uses RESTful API for external interaction. Its main function is to allow users to preview their own photo albums. The following are the necessary services of PAPS demonstration project:

service namecategorydescribeTechnology selection
photo-serviceInternal servicesResource server role, album preview serviceRESTful services developed by Spring Boot
idpInternal servicesAuthorization server role, specifically responsible for authentication, authorization and authenticationSpring Boot development
demo-h5External applicationFront end of demo applicationUse Postman instead

To this end, we will build two projects: photo service and idp. The client is replaced by Postman.

3, Engineering structure

Next, we will demonstrate the framework code of the two projects. This part of the code includes the framework structure of the project, the basic configuration of Spring Security and OAuth2, and is written in the most concise way as far as possible. Other projects can copy this part of the code as a basic template.

Photo service photo album service

  • Basic engineering structure
src/main
    java
        com.example.demophoto
            config
                oauth2
                    CheckTokenAuthentication.java
                    CheckTokenFilter.java
                    CustomPermissionEvaluator.java
                    CustomRemoteTokenServices.java
                    ResourceServerConfigurer.java
            service
                PermisionEvaluatingService.java
            web
                PhotoController.java
            DemoPhotoApplication.java
    resources
        applicaton.yaml
  • pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.4.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>oauth2-demo-1a-photo-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>oauth2-demo-1a-photo-service</name>
    <description>oauth2-demo-1a-photo-service</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.springframework.security.oauth.boot/spring-security-oauth2-autoconfigure -->
        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
            <version>2.1.2.RELEASE</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>
  • applicaton.yaml
server:
  port: 8010

security:
  oauth2:
    client:
      clientId: client2
      clientSecret: client2p
    resource:
      tokenInfoUri: http://127.0.0.1:8000/oauth/check_token
  • ResourceServerConfigurer.java
package com.example.demophoto.config.oauth2;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;

@Configuration
@EnableResourceServer
public class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {
    /**
     * spring-security-oauth2 General configuration of components
     *
     * @param resources
     */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources.resourceId("demo-1");
    }

    /**
     * spring-security-oauth2 General configuration of components
     *
     * @param http
     * @throws Exception
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated();
    }
}

idp authorization service

  • Basic engineering structure
src/main
    java
        com.example.demoidp
            config
                oauth2
                    AuthorizationServerConfigurer.java
                    CheckTokenInterceptor.java
                    WebSecurityConfig.java
            service
                Business logic, such as authentication logic
            DemoIdpApplication.java
    resources
        applicaton.yaml
  • pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.4.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>oauth2-demo-1a-idp</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>oauth2-demo-1a-idp</name>
    <description>oauth2-demo-1a-idp</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.springframework.security.oauth/spring-security-oauth2 -->
        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>2.3.8.RELEASE</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>
  • applicaton.yaml
server:
  port: 8000
  • AuthorizationServerConfigurer.java
package com.example.demoidp.config.oauth2;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {
    private AuthenticationManager authenticationManager;

    /**
     * spring-security-oauth2 General configuration of components
     *
     * @param authenticationManager
     */
    @Autowired
    public AuthorizationServerConfigurer(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    /**
     * Configure password encryption method
     */
    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    /**
     * spring-security-oauth2 General configuration of components
     *
     * @param endpoints
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.authenticationManager(authenticationManager);
    }

    /**
     * spring-security-oauth2 General configuration of components
     *
     * @param security
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security
                // /oauth/check_token request release
                .checkTokenAccess("permitAll()")
                .passwordEncoder(passwordEncoder());
    }
}
  • WebSecurityConfig.java
package com.example.demoidp.config.oauth2;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * spring-security-oauth2 General configuration of components
     *
     * @return AuthenticationManager
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

4, Code implementation

As shown in the figure, it is the most streamlined architecture level and main process of password mode. Let's implement the process step by step:

1) Phase I: authentication and authorization phase

1) The user agent (demo-h5) sends the user name and password entered by the user to the client (Demo service)

We use Postman to perform this step, which will not be introduced here.

2) The client (Demo service) sends the user name and password entered by the user together with client_id + client_secret (assigned by idp) to idp to request a token. If idp has agreed on a scope, it also needs to bring the scope parameter

We use Postman to perform this step, which will not be introduced here. It should be noted that Postman is still a client role here_ ID represents itself. The requested URL is:

POST http://127.0.0.1:8000/oauth/token
3) idp first validates the client_ id + client_ The validity of secret, and then check whether the scope is correct. Finally, verify whether the user name and password are correct. If they are correct, a token is generated. This step is also called "certification"

To implement this step, we add the following code to the AuthorizationServerConfigurer class of the idp project:

  • The first is the client_ id + client_ Verification of secret + scope
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {

    ...

    /**
     * 3. [Step 3 in the typical architecture hierarchy and main process] of password mode:
     *    idp First verify the client_ id + client_ The validity of secret, and then check whether the scope is correct
     *
     *    PS: For the convenience of demonstration, the account is created locally, and the production environment should be replaced by database query
     */
    private class MockJDBCClientDetailsService implements ClientDetailsService {
        @Override
        public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
            /**
             * GrantedAuthority Associated with hasAuthority()
             */
            Set<GrantedAuthority> authorities = new HashSet<>();
            authorities.add(new SimpleGrantedAuthority("READ"));
            authorities.add(new SimpleGrantedAuthority("WRITE"));
    
            BaseClientDetails details1 = new BaseClientDetails();
            details1.setClientId("client1");
            details1.setClientSecret(passwordEncoder().encode("client1p"));
            details1.setAuthorizedGrantTypes(Arrays.asList("password"));
            details1.setScope(Arrays.asList("resource:write", "resource:read"));
            details1.setResourceIds(Arrays.asList("demo-1"));
            details1.setAuthorities(authorities);
    
            BaseClientDetails details2 = new BaseClientDetails();
            details2.setClientId("client2");
            details2.setClientSecret(passwordEncoder().encode("client2p"));
            details2.setAuthorizedGrantTypes(Arrays.asList("client_credentials"));
            details2.setScope(Arrays.asList("resource:write", "resource:read"));
            details2.setResourceIds(Arrays.asList("demo-1"));
            details2.setAuthorities(authorities);
    
            BaseClientDetails details3 = new BaseClientDetails();
            details3.setClientId("client3");
            details3.setClientSecret(passwordEncoder().encode("client3p"));
            details3.setAuthorizedGrantTypes(Arrays.asList("password"));
            details3.setScope(Arrays.asList("resource:write", "resource:read"));
            details3.setResourceIds(Arrays.asList("demo-1"));
            details3.setAuthorities(authorities);
    
            Map<String, ClientDetails> clients = new HashMap<>();
            clients.put("client1", details1);
            clients.put("client2", details2);
            clients.put("client3", details3);
    
            if (!clients.containsKey(clientId)) {
                throw new ClientRegistrationException("Client not found");
            }
    
            return clients.get(clientId);
        }
    }
    
    /**
     * spring-security-oauth2 General configuration of components
     * Configure custom ClientDetails
     *
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(new MockJDBCClientDetailsService());
    }
    
    ...
}
  • Then there is the verification of user name and password
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 3. [Step 3 in the typical architecture hierarchy and main process] of password mode:
     *    Verify whether the user name and password are correct, and generate a token if they are correct
     *
     *    PS: For the convenience of demonstration, the account is created locally, and the production environment should be replaced by database query
     */
    private class MockJDBCUserDeatilsService implements UserDetailsService {
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            Map<String, String> users = new HashMap<>();
            users.put("user1", "pwd1");
            users.put("user2", "pwd2");

            if (!users.containsKey(username)) {
                throw new UsernameNotFoundException("User not found");
            }

            return User.withDefaultPasswordEncoder()
                    .username(username)
                    .password(users.get(username))
                    .roles("USER")
                    .build();
        }
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        return new MockJDBCUserDeatilsService();
    }
}

When client_ id + client_ After the secret + scope, user name and password are verified, spring-security-oauth2 will call the appropriate tokenServices to generate a token. Interested students can check the whole process of source code tracking by themselves. Here is the entry method of source code tracking:

We know that the demo-h5 client (Postman) first reports to the http://127.0.0.1:8000/oauth/token To initiate the request, we found the / OAuth / token endpoint in the spring-security-oauth2 component source code. The specific path is:

org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.postAccessToken()
4) idp returns the authentication result to the client. If the authentication passes, it returns a token. If the authentication fails, it returns 401. If the authentication is successful, this step is also called "authorization"

spring-security-oauth2 has been handled for us in this step, and no additional processing is required. Students who want to track the source code process can refer to the entry method introduced in the previous step.

5) After receiving the token, the client performs temporary storage and creates the corresponding session

This step is demonstrated by Postman (just copy the returned token string directly). It is not introduced here.

6) The client issues cookie s to the user agent / browser

This step is demonstrated by Postman, which will not be introduced here.

2) Phase 2: request resources after authorization

7) The user accesses the "my album" page through the user agent (demo-h5), and the user agent initiates a request to the client (Demo service) with a cookie

This step is performed using Postman and does not expand the description.

8) The client finds the corresponding token through the session and sends a request to the photo service with this token

This step is executed by Postman. We take the token obtained in step 5) as the Bearer Token and send a request to the photo service. The URL of the request is:

GET http://127.0.0.1:8010/api/photo

The request only needs to carry token No other parameters are required
9) The photo service requests idp to verify the validity of the token

Before introducing how to process the request, we will first add relevant codes in the photo service project:

  • PhotoController.java
package com.example.demophoto.web;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/")
public class PhotoController {
    @GetMapping("/photo")
    public String fetchPhoto() {
        return "GET photo";
    }
}

In addition, there are several key configurations:

  1. ResourceServerConfigurerAdapter. The configure (httpsecurity HTTP) method configures http authorizeRequests(). anyRequest(). Authenticated () enables all requests to be authenticated first;
  2. application. Client is configured in yaml_ id,client_secret and resource tokenInfoUri: when the resource service receives the request, it will carry the token to initiate an authentication request to the address specified by tokenInfoUri.

By default, when demo-h5 sends a resource access request to the photo service, the photo service will send the obtained token to idp for verification. In this process, spring-security-oauth2 will not process the scope. We know that scope is used to restrict the permission range of client s, so scope permission check (also regarded as one of the authentication tasks) needs to be coded and implemented by ourselves.

Generally speaking, the business logic of scope permission check can be set flexibly or even ignored. This paper introduces two implementation methods of scope check:

  1. Resource server check;
  2. Authorization server-side check.

The following step 10) will be divided into two methods, which will be introduced respectively.

10) [method 1: scope check on the resource server] idp checks the validity of the token, and the resource server checks the scope

idp verifies the validity of the token. If it passes, it returns the client related information (including the scope) to the photo service, and then the photo service judges whether the client (demo-h5) has the permission to call this API according to the scope. If it passes the check, it continues to the next step, otherwise it returns 403 error to demo-h5. This step is also called "authentication"

We add the following code to the photo service project:

  • ResourceServerConfigurer.java
@Configuration
@EnableResourceServer
public class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {
    ...
    
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/api/photo/**").access("#oauth2.hasScope('resource:read')")
                .antMatchers("/api/photo2/**").access("#oauth2.hasScope('resource:read')")
                .antMatchers("/api/photo3/**").access("#oauth2.hasScope('resource:write')")
                .anyRequest().authenticated();
    }
    
    ...
}

The access("#oauth2.hasScope('resource:write ')") method enables scope checking on the resource server side. The main processes are:

  1. After receiving the client request, the photo service sends the obtained token to idp for verification;
  2. After the idp verification is passed, return the clientDetails information to the photo service, including the scope parameter;
  3. After the photo service gets the scope, it judges whether the request is within the scope according to access("#oauth2.hasScope('resource:write ')").
10) [method 2: idp end scope check] idp verifies the validity of token + scope

idp verifies the validity of the token, Then judge the client according to the scope (demo-h5) whether you have permission to call this API, and finally return the verification result to the resource server. Since spring-security-oauth2 does not handle the scope check itself, and by default, photo service does not carry any other request information when requesting token authentication from idp, idp cannot know the details of this request, so it cannot perform the socpe check.

Therefore, there are two key points: one is how to carry the details of the request when the photo service requests token authentication from idp (for example, what resources are accessed and which API is requested?); the other is how to intercept the token authentication process so that the scope verification fails and returns a 403 error?

Of course, there are many ways to achieve this goal. This paper adopts a more intuitive method: using Filter.

We add the following code to the photo service project:

  • ResourceServerConfigurer.java
package com.example.demophoto.config.oauth2;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;

@Configuration
@EnableResourceServer
public class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {
    private final ResourceServerProperties resource;

    @Autowired
    protected ResourceServerConfigurer(ResourceServerProperties resource) {
        this.resource = resource;
    }

    /**
     * Customize RemoteTokenServices to replace the default used by the resource server
     * RemoteTokenServices Initiate / OAuth / check to IDP_ Token authentication request
     *
     * @return
     */
    public CustomRemoteTokenServices customRemoteTokenServices() {
        CustomRemoteTokenServices services = new CustomRemoteTokenServices();
        services.setCheckTokenEndpointUrl(this.resource.getTokenInfoUri());
        services.setClientId(this.resource.getClientId());
        services.setClientSecret(this.resource.getClientSecret());
        return services;
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources.resourceId("demo-1")
                .tokenServices(customRemoteTokenServices());
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(new CheckTokenFilter(), AbstractPreAuthenticatedProcessingFilter.class);
        http.authorizeRequests()
                .antMatchers("/api/photo/**").access("#oauth2.hasScope('resource:read')")
                .antMatchers("/api/photo2/**").access("#oauth2.hasScope('resource:read')")
                .antMatchers("/api/photo3/**").access("#oauth2.hasScope('resource:write')")
                .anyRequest().authenticated();
    }
}
  • CheckTokenFilter.java
package com.example.demophoto.config.oauth2;

import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * Initiating / OAuth / check to IDP_ Before the token request, store the request details in the SecurityContext,
 * To customremotetokenservices Loadauthentication() can get the request details
 */
public class CheckTokenFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException,
            ServletException {
        HttpServletRequest request = (HttpServletRequest) req;

        String uri = request.getRequestURI();
        String method = request.getMethod();

        /**
         * Process only / api/**
         */
        if (!uri.startsWith("/api/")) {
            chain.doFilter(req, res);
            return;
        }

        SecurityContext sc = SecurityContextHolder.getContext();
        CheckTokenAuthentication authentication = (CheckTokenAuthentication) sc.getAuthentication();

        if (authentication == null) {
            authentication = new CheckTokenAuthentication(null);
        }

        /**
         * Details of accessing the resource server by user agent or other service requests (HTTP method + URI here)
         * Stored in the authentication object of SecurityContext
         */
        Map<String, Object> details = new HashMap<>();
        details.put("uri", uri);
        details.put("method", method);

        authentication.setDetails(details);
        sc.setAuthentication(authentication);

        chain.doFilter(req, res);
    }
}
  • CustomRemoteTokenServices.java
package com.example.demophoto.config.oauth2;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.*;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.codec.Base64;
import org.springframework.security.oauth2.client.resource.OAuth2AccessDeniedException;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.AccessTokenConverter;
import org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.DefaultResponseErrorHandler;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;

/**
 * Take RemoteTokenServices as the template
 * The basic idea is to initiate / OAuth / check to IDP_ In the request of token,
 * Add the details of the API for user agents or other services to request access to this resource server,
 * So that IDP can determine whether the user agent or other services (i.e. client) can call this API
 * <p>
 * (PS: The IDP can also return the ClientDetails to the resource service, which handles the release logic)
 */
public class CustomRemoteTokenServices implements ResourceServerTokenServices {

    protected final Log logger = LogFactory.getLog(getClass());

    private RestOperations restTemplate;

    private String checkTokenEndpointUrl;

    private String clientId;

    private String clientSecret;

    private String tokenName = "token";

    /**
     * Parameters agreed with IDP to store API request details
     */
    private String reqPayload = "payload";

    private AccessTokenConverter tokenConverter = new DefaultAccessTokenConverter();

    public CustomRemoteTokenServices() {
        restTemplate = new RestTemplate();
        ((RestTemplate) restTemplate).setErrorHandler(new DefaultResponseErrorHandler() {
            @Override
            // Ignore 400
            public void handleError(ClientHttpResponse response) throws IOException {
                Integer statusCode = response.getRawStatusCode();
                if (statusCode != 400) {
                    if (statusCode == 401 || statusCode == 403) {
                        HttpStatus status = HttpStatus.resolve(statusCode);
                        throw new AccessDeniedException(status.toString());
                    }
                    super.handleError(response);
                }
            }
        });
    }

    public void setRestTemplate(RestOperations restTemplate) {
        this.restTemplate = restTemplate;
    }

    public void setCheckTokenEndpointUrl(String checkTokenEndpointUrl) {
        this.checkTokenEndpointUrl = checkTokenEndpointUrl;
    }

    public void setClientId(String clientId) {
        this.clientId = clientId;
    }

    public void setClientSecret(String clientSecret) {
        this.clientSecret = clientSecret;
    }

    public void setAccessTokenConverter(AccessTokenConverter accessTokenConverter) {
        this.tokenConverter = accessTokenConverter;
    }

    public void setTokenName(String tokenName) {
        this.tokenName = tokenName;
    }

    /**
     * After replacing the default tokenServices with custom tokenServices,
     * Step 9 of the original process is executed by this method.
     *
     * 9. [Step 9 in the typical architecture hierarchy and main process] of password mode:
     * The photo service requests idp to verify the validity of the token
     *
     * @param accessToken
     * @return
     * @throws AuthenticationException
     * @throws InvalidTokenException
     */
    @Override
    public OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException {
        Map<String, Object> authDetails = new HashMap<>();

        /**
         * Get the API request details placed in the CheckTokenFilter filter filter
         */
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null) {
            authDetails = (Map<String, Object>) authentication.getDetails();
        }

        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add(tokenName, accessToken);
        if (!authDetails.isEmpty()) {
            formData.add(reqPayload, authDetails.get("method") + " " + authDetails.get("uri"));
        }
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", getAuthorizationHeader(clientId, clientSecret));

        Map<String, Object> map = postForMap(checkTokenEndpointUrl, formData, headers);

        /**
         * 11. [Step 11 in the typical architecture hierarchy and main process] of password mode:
         *     If the token verification fails, 401 is returned to the client; if the scope check fails, 403 is returned
         */
        if (map.containsKey("error")) {
            if (logger.isDebugEnabled()) {
                logger.debug("check_token returned error: " + map.get("error"));
            }
            if (map.containsKey("status")) {
                if ("403".equals(map.get("status").toString())) {
                    throw new OAuth2AccessDeniedException(map.get("error").toString());
                }
            }
            throw new InvalidTokenException(accessToken);
        }

        // gh-838
        if (map.containsKey("active") && !"true".equals(String.valueOf(map.get("active")))) {
            logger.debug("check_token returned active attribute: " + map.get("active"));
            throw new InvalidTokenException(accessToken);
        }

        return tokenConverter.extractAuthentication(map);
    }

    @Override
    public OAuth2AccessToken readAccessToken(String accessToken) {
        throw new UnsupportedOperationException("Not supported: read access token");
    }

    private String getAuthorizationHeader(String clientId, String clientSecret) {

        if (clientId == null || clientSecret == null) {
            logger.warn("Null Client ID or Client Secret detected. Endpoint that requires authentication will reject request with 401 error.");
        }

        String creds = String.format("%s:%s", clientId, clientSecret);
        try {
            return "Basic " + new String(Base64.encode(creds.getBytes("UTF-8")));
        } catch (UnsupportedEncodingException e) {
            throw new IllegalStateException("Could not convert String");
        }
    }

    private Map<String, Object> postForMap(String path, MultiValueMap<String, String> formData, HttpHeaders headers) {
        if (headers.getContentType() == null) {
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        }
        @SuppressWarnings("rawtypes")
        Map<String, Object> result = new HashMap<>();
        try {
            Map map = restTemplate.exchange(path, HttpMethod.POST,
                    new HttpEntity<MultiValueMap<String, String>>(formData, headers), Map.class).getBody();
                    result = map;
        }
        catch (Exception e) {
            logger.error(e.getMessage());
        }

        return result;
    }

}
  • CheckTokenAuthentication.java
package com.example.demophoto.config.oauth2;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

public class CheckTokenAuthentication extends AbstractAuthenticationToken {

    /**
     * Creates a token with the supplied array of authorities.
     *
     * @param authorities the collection of <tt>GrantedAuthority</tt>s for the principal
     *                    represented by this authentication object.
     */
    public CheckTokenAuthentication(Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return null;
    }
}

Then add the following code to the idp project:

  • AuthorizationServerConfigurer.java
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {
    ...
    
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.authenticationManager(authenticationManager)

                // The user-defined authentication method is implemented by inserting an interceptor
                .addInterceptor(new CheckTokenInterceptor(endpoints.getTokenStore()));
    }
    
    ...
}
  • CheckTokenInterceptor.java
package com.example.demoidp.config.oauth2;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;

/**
 * /oauth/check_token Verification token request interceptor
 */
public class CheckTokenInterceptor implements HandlerInterceptor {
    private String TOKEN_NAME = "token";
    private final String TOKEN_INFO_URI = "/oauth/check_token";

    private TokenStore tokenStore;

    public CheckTokenInterceptor(TokenStore tokenStore) {
        this.tokenStore = tokenStore;
    }

    // for test only
    private final Map<String, String> clientScopes = new HashMap<String, String>() {
        {
            put("client1[resource:read]", "GET /api/photo");
            put("client1[resource:write]", "POST /api/photo");
            put("client2[resource:read]", "GET /api/photo2");
            put("client2[resource:write]", "POST /api/photo2");
            put("client3[resource:read]", "GET /api/photo3");
            put("client3[resource:write]", "POST /api/photo3");
        }
    };

    /**
     * 10. [Step 10 in the typical architecture hierarchy and main process] of password mode:
     *     idp Verify the validity of token and scope permission
     * <p>
     * That is, IDP judges the client (Demo service) according to the scope
     * Do you have permission to call this API and return the verification result to the resource server
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String uri = request.getRequestURI();

        /**
         * Block only / oauth/check_token
         */
        if (!TOKEN_INFO_URI.equals(uri)) {
            return true;
        }

        /**
         * payload It is the parameter transfer format agreed by IDP and resource server roles
         * That is, the details of the API that the client requests to access the resource server
         * It is required to carry payload
         *
         * This part can be handled according to the business logic
         */
        String paylad = request.getParameter("payload");
        if (StringUtils.isEmpty(paylad)) {
            throw new AccessDeniedException("insufficient_payload");
        }

        if ("GET /error".equals(paylad)) {
            return true;
        }

        /**
         * 10. [Step 10 in the typical architecture hierarchy and main process] of password mode:
         * [Method 2: idp end scope check] idp verifies the validity of token + scope
         * 
         * Check the clientId according to the token, and then check whether the client has permission to call this API according to the scope
         * This part can be processed according to the business logic, such as querying the relationship between client, API and scope from the database
         */
        String token = request.getParameter(TOKEN_NAME);
        OAuth2Authentication oAuth2Authentication = tokenStore.readAuthentication(token);
        OAuth2Request oAuth2Request = oAuth2Authentication.getOAuth2Request();
        String scopeKey = oAuth2Request.getClientId() + oAuth2Request.getScope();
        if (clientScopes.containsKey(scopeKey)) {
            if (!clientScopes.get(scopeKey).equals(paylad)) {
                throw new AccessDeniedException("insufficient_scope");
            }
        }

        return true;
    }
}

The scope check on idp side is a little troublesome. The main ideas are as follows:

  1. Initiate / OAuth / check to idp in photo service_ Before OAuth authentication request, add a filter to save the request details of the client to a global object;
  2. Replace the default token services of photo service and initiate / OAuth / check to idp_ In the process of OAuth authentication request, the request details are attached to the request;
  3. idp adds a custom Interceptor in the AuthorizationServerEndpointsConfigurer, and executes the custom Interceptor before each check token;
  4. idp takes out the request details in the custom Interceptor and performs scope check according to the request details and client details information (SCOPE).

Although the above methods are troublesome to implement, they are highly customizable and flexible, are not constrained by the framework, and can adapt to various complex business logic.

11) The resource server determines whether to return the user album data to the client according to the idp verification result (true/false or other equivalent means). If the token verification fails, it returns 401 to the client. If the scope check fails, it returns 403. This step is also called permission control

Similar to the scope range check in authentication, there are two methods to realize permission control:

  1. The authority control of the authorization server belongs to centralized authority control;
  2. The permission control of resource server belongs to decentralized permission control.

Among them, the permission control of the authorization server is relatively simple. In the checktokeninterceptor of the idp project Add the business code of permission control to the prehandle() method:

  • CheckTokenInterceptor.java
public class CheckTokenInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        ...

        /**
         * 11. [Step 11 in the typical architecture hierarchy and main process] of password mode:
         *  Authorization server short permission control, i.e. centralized permission control
         *
         * To achieve finer grained permission control, to some extent, this process can also be called authentication
         */
        // Logic of authorization server-side authentication / permission control service

        return true;
    }
}

Finally, let's look at the permission control on the resource server. We use the standard method provided by spring security to implement:

  1. PreAuthorize hasRole/hasAuthority on the resource server
  2. Resource server PreAuthorize custom implementation hasPermission

The above statement can also be understood as authentication to some extent.

First, we add or modify the relevant codes of the photo service project:

  • PhotoController.java
package com.example.demophoto.web;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 1, There are two types of permission control: resource server permission control and authorization server permission control
 * 2, Three methods of permission control:
 *      A, PreAuthorize hasRole/hasAuthority on the resource server
 *      B, Custom implementation of resource server HttpSecurity access hasPermission
 *      D, Authorization server-side HandlerInterceptor
 *     The above statement can also be understood as authentication to some extent.
 */
@RestController
@RequestMapping("/api/")
public class PhotoController {
    @GetMapping("/photo")
    @PreAuthorize("hasRole('USER') and hasAuthority('WRITE')")
    public String fetchPhoto() {
        return "GET photo";
    }

    @GetMapping("/photo2")
    public String fetchPhoto2() {
        return "GET photo 2";
    }

    @GetMapping("/photo3")
    @PreAuthorize("hasPermission('PhotoController', 'read')")
    public String fetchPhoto3() {
        return "GET photo 3";
    }
}
  • ResourceServerConfigurer.java
@Configuration
@EnableResourceServer
public class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {
    ...

    /**
     * The old version of spring-security-oauth2 also needs to execute resources expressionHandler(oAuth2WebSecurityExpressionHandler) 
     * To inject a custom expressionHandler, which is not required in the current and future versions
     * 
     * @return
     */
    @Bean
    public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler() {
        OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler = new OAuth2WebSecurityExpressionHandler();

        // In the new version of spring-security-oauth2, this line of code can be omitted,
        // The framework will automatically inject customPermissionEvaluator to replace the default DenyAllPermissionEvaluator
        // oAuth2WebSecurityExpressionHandler.setPermissionEvaluator(customPermissionEvaluator);
        return oAuth2WebSecurityExpressionHandler;
    }
    
    ...
}
  • CustomPermissionEvaluator.java
package com.example.demophoto.config.oauth2;

import com.example.demophoto.service.PermisionEvaluatingService;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.io.Serializable;

@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
    private PermisionEvaluatingService permisionEvaluatingService = new PermisionEvaluatingService();

    @Override
    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
        return permisionEvaluatingService.hasPermission(authentication, targetDomainObject, permission);
    }

    @Override
    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
        return permisionEvaluatingService.hasPermission(authentication, targetId, targetType, permission);
    }
}
  • PermisionEvaluatingService.java
package com.example.demophoto.service;

import org.springframework.security.core.Authentication;

import java.io.Serializable;

public class PermisionEvaluatingService {
    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
        // Business logic
        return true;
    }

    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
        // Business logic
        return true;
    }
}
  • DemoPhotoApplication.java
@SpringBootApplication
@EnableGlobalMethodSecurity(prePostEnabled = true)  // Enable hasRole/hasAuthority/hasPermission support
public class DemoPhotoApplication {
    ...
}

After the above configuration, when the client sends a GET /api/photo3 request to the photo service, it will enter the custompermissionevaluator The haspermission () method is used to judge, so it can realize very flexible resource server-side permission control.

Topics: Java Spring oauth2