11 Practical Case Using Spring Security to Build a Oauth 2.0 Architecture Based on JWT

11 Practical Case Using Spring Security to Build a OAuth 2 #

After the launch of the “OAuth 2.0 Practical Course,” I was also one of the first to pay attention to this course. In the preface, I saw some students asking, “How can we use Spring Security to implement OAuth 2.0?” At that moment, I recalled that I had previously written a related article, so I left a comment directly below the preface. Soon after, I received many likes and affirmations from users, and Geektime editors invited me to write an extra article for the column from my perspective. Well, not wanting to let my skills go to waste, I have iterated and organized the old article I wrote into the content of today’s lecture, hoping to help you master OAuth 2.0.

If you are familiar with Spring Security, you must know that its powerful and complex features are due to its abundant functionalities, highly abstracted components, and diverse configuration methods. As a result, the learning curve for Spring Security is almost the highest among the Spring family. However, not only that, when using Spring Security to handle complex real-world business scenarios, we also need to understand the working principles and processes of some components; otherwise, we will be at a loss when it comes to customizing and extending the framework. This increases the threshold for using Spring Security.

Therefore, before deciding to use Spring Security to build a complete security system (authorization, authentication, permissions, auditing), we also need to consider: in the future, will our business become more complex, is it cost-effective to build a security system from scratch, or is it better to use Spring Security? I believe this is also one of the reasons why Wang Lao Shi did not use Spring Security to demonstrate the OAuth 2.0 process in the course’s accompanying code.

On the other hand, if your application already uses Spring Security for authentication, authorization, and access control, the cost of using Spring Security to implement OAuth is very low. Moreover, after solidifying our foundation by learning the OAuth 2.0 process, we won’t feel as lost when configuring OAuth 2.0 with Spring Security. This is also my intuitive experience of using Spring Security to implement OAuth 2.0 in my work.

Therefore, I will combine my practical experience and knowledge to guide you, step by step, in using Spring Security to build a JWT-based OAuth 2.0 authorization system. These contents will cover the three roles of OAuth 2.0 (client, authorization server, protected resource), as well as the three commonly used authorization grant types: resource owner credentials grant, client credentials grant, and authorization code grant (implicit grant type, which is not very secure and not commonly used). At the same time, I will also demonstrate OAuth 2.0’s permission control and how to implement SSO (Single Sign-On) using OAuth 2.0.

As a result, today’s lecture will involve several processes, and the content will be quite long. However, don’t worry, I will guide you from scratch, complete the entire program, and provide demonstrations for all the processes.

Project Preparation #

Before we start the actual implementation, let’s set up the project parent dependencies and initialize the database structure to prepare for the specific coding tasks later.

First, let’s create a parent POM with three modules:

- springsecurity101-cloud-oauth2-client, which plays the role of a client.
- springsecurity101-cloud-oauth2-server, which plays the role of an authorization server.
- springsecurity101-cloud-oauth2-userservice, which is a user service and acts as a resource provider.

Here is the XML code for the parent POM:

<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://maven.apache.org/POM/4.0.0"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>me.josephzhu</groupId>
    <artifactId>springsecurity101</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
        <relativePath/>
    </parent>

    <modules>
        <module>springsecurity101-cloud-oauth2-client</module>
        <module>springsecurity101-cloud-oauth2-server</module>
        <module>springsecurity101-cloud-oauth2-userservice</module>
    </modules>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
  
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Greenwich.SR4</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
  
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Next, let’s create an “oauth” database and initialize the 5 tables that will be used in the future.

  • authorities table: Records the authority of an account, which will be configured later.
  • oauth_approvals table: Records the approval status of authorization requests.
  • oauth_client_details table: Records OAuth clients, which will be configured later.
  • oauth_code table: Records authorization codes.
  • users table: Records user accounts, which will be initialized later.

Here is the SQL code to create and initialize these tables:

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

DROP TABLE IF EXISTS `authorities`;
CREATE TABLE `authorities` (
  `username` varchar(50) NOT NULL,
  `authority` varchar(50) NOT NULL,
  UNIQUE KEY `ix_auth_username` (`username`,`authority`),
  CONSTRAINT `fk_authorities_users` FOREIGN KEY (`username`) REFERENCES `users` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

DROP TABLE IF EXISTS `oauth_approvals`;
CREATE TABLE `oauth_approvals` (
  `userId` varchar(256) DEFAULT NULL,
  `clientId` varchar(256) DEFAULT NULL,
  `partnerKey` varchar(32) DEFAULT NULL,
  `scope` varchar(256) DEFAULT NULL,
  `status` varchar(10) DEFAULT NULL,
  `expiresAt` datetime DEFAULT NULL,
  `lastModifiedAt` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
  `client_id` varchar(255) NOT NULL,
  `resource_ids` varchar(255) DEFAULT NULL,
  `client_secret` varchar(255) DEFAULT NULL,
  `scope` varchar(255) DEFAULT NULL,
  `authorized_grant_types` varchar(255) DEFAULT NULL,
  `web_server_redirect_uri` varchar(255) DEFAULT NULL,
  `authorities` varchar(255) DEFAULT NULL,
  `access_token_validity` int(11) DEFAULT NULL,
  `refresh_token_validity` int(11) DEFAULT NULL,
  `additional_information` varchar(4096) DEFAULT NULL,
  `autoapprove` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

DROP TABLE IF EXISTS `oauth_code`;
CREATE TABLE `oauth_code` (
  `code` varchar(255) DEFAULT NULL,
  `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
  `username` varchar(50) NOT NULL,
  `password` varchar(100) NOT NULL,
  `enabled` tinyint(1) NOT NULL,
  PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;

These 5 tables are storage tables required by Spring Security OAuth. We should not modify the existing table structures. As you can see, we did not create the corresponding tables in the database to store access tokens and refresh tokens. This is because our implementation will use JWT for token transmission and local validation, so it is not necessary to store them in the database. These tables can be extended by inheriting and implementing some existing classes of Spring, but we will not go into that here.

Next, we will start building the authorization server and the protected resource server.

Building the Authorization Server #

First, we create the first module, which is the authorization server. Start by creating the pom.xml file and configure the dependencies:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <parent>
        <artifactId>springsecurity101</artifactId>
        <groupId>me.josephzhu</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <modelVersion>4.0.0</modelVersion>
    <artifactId>springsecurity101-cloud-oauth2-server</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
    </dependencies>
</project>

Here, we use the Spring Cloud spring-cloud-starter-oauth2 component instead of directly using Spring Security. The former provides some automated configuration, making it more convenient to use.

In addition, we add dependencies for data access, web, etc. in the pom.xml file. This is because our protected resource server needs to use a database to store client information, user information, and other data. We also include the Thymeleaf template engine dependency to slightly enhance the login page.

Next, create a configuration file application.yml to implement program configuration:

server:
  port: 8080
spring:
  application:
    name: oauth2-server
  datasource:
    url: jdbc:mysql://localhost:6657/oauth?useSSL=false
    username: root
    password: kIo9u7Oi0eg
    driver-class-name: com.mysql.jdbc.Driver

Here, we configure the connection string for the oauth database and define that the authorization server should listen on port 8080.

Lastly, use the keytool tool to generate a key pair and save the key file jks to the resource directory. Export a public key for future use.

With the above steps, the project framework setup is complete. Now, let’s start coding.

The first step is to create the most important class for configuring the authorization server. The purpose of each code segment is explained in comments, so you can read through them directly:

@Configuration
@EnableAuthorizationServer
public class OAuth2ServerConfiguration extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private DataSource dataSource;
    @Autowired
    private AuthenticationManager authenticationManager;
    
    /*     
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource);
    }
    
    /*     
     * Here, two things are done. First, token access authorization for validation is opened (for demonstration purposes).
     * Then, client secret is allowed to be stored in plaintext and can be submitted via a form (instead of only through Basic Auth), which will be demonstrated later.
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.checkTokenAccess("permitAll()").allowFormAuthenticationForClients().passwordEncoder(NoOpPasswordEncoder.getInstance());
    }
    
    /*     
     * The following four things are done:
     * 1. Configure our token storage method as JWT instead of in-memory, database, or Redis.
     * JWT is short for Json Web Token, which is a token wrapped in JSON format and divided into three parts: header, payload, and signature.
     * Although JWT is easy to use for storing tokens, it is not very secure. It is generally used internally, requires HTTPS, and has a relatively short expiration time.
     * 2. Configure JWT token signing with asymmetric encryption.
     * 3. Configure a custom TokenEnhancer to include more information in the token.
     * 4. Configure using JDBC database to store user authorization approval records.
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(
                Arrays.asList(tokenEnhancer(), jwtTokenEnhancer()));
        endpoints.approvalStore(approvalStore())
                .authorizationCodeServices(authorizationCodeServices())
                .tokenStore(tokenStore())
                .tokenEnhancer(tokenEnhancerChain)
                .authenticationManager(authenticationManager);
    }
    
    /*     
     * Use JDBC database to store authorization codes.
     * @return
     */
    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new JdbcAuthorizationCodeServices(dataSource);
    }
    
    /*     
     * Use JWT for token storage.
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtTokenEnhancer());
    }
}
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtTokenEnhancer());
    }

    /**
     * 使用JDBC数据库方式来保存用户的授权批准记录
     * @return
     */
    @Bean
    public JdbcApprovalStore approvalStore() {
        return new JdbcApprovalStore(dataSource);
    }

    /**
     * 自定义的Token增强器,把更多信息放入Token中
     * @return
     */
    @Bean
    public TokenEnhancer tokenEnhancer() {
        return new CustomTokenEnhancer();
    }

    /**
     * 配置JWT使用非对称加密方式来验证
     * @return
     */
    @Bean
    protected JwtAccessTokenConverter jwtTokenEnhancer() {
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "mySecretKey".toCharArray());
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyStoreKeyFactory.getKeyPair("jwt"));
        return converter;
    }

    /**
     * 配置登录页面的视图信息(其实可以独立一个配置类,这样会更规范)
     */
    @Configuration
    static class MvcConfig implements WebMvcConfigurer {
        @Override
        public void addViewControllers(ViewControllerRegistry registry) {
            registry.addViewController("login").setViewName("login");
        }
    }
}

第二步还记得吗刚才在第一步的代码中我们还用到了一个自定义的 Token 增强器把用户信息嵌入到 JWT Token 中去如果使用的是客户端凭据许可类型这段代码无效因为和用户没关系)。

这是一个常见需求因为默认情况下 Token 中只会有用户名这样的基本信息我们往往需要把关于用户的更多信息返回给客户端在实际应用中你可能会从数据库或外部服务查询更多的用户信息加入到 JWT Token 中去)。这个时候我们就可以自定义增强器来丰富 Token 的内容

public class CustomTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Authentication userAuthentication = authentication.getUserAuthentication();
        if (userAuthentication != null) {
            Object principal = authentication.getUserAuthentication().getPrincipal();
            Map<String, Object> additionalInfo = new HashMap<>();
            additionalInfo.put("userDetails", principal);
            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
        }
        return accessToken;
    }
}
第三步实现安全方面的配置你可以直接看下代码注释来了解关键代码的作用

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private DataSource dataSource;

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 配置用户账户的认证方式。显然,我们把用户存在了数据库中希望配置JDBC的方式。
     * 此外,我们还配置了使用BCryptPasswordEncoder哈希来保存用户的密码(生产环境中,用户密码肯定不能是明文保存的)

     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication()
                .dataSource(dataSource)
                .passwordEncoder(new BCryptPasswordEncoder());
    }

    /**
     * 开放/login和/oauth/authorize两个路径的匿名访问。前者用于登录,后者用于换授权码,这两个端点访问的时机都在登录之前。
     * 设置/login使用表单验证进行登录。
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login", "/oauth/authorize")
                .permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().loginPage("/login");
    }
}

第四步在资源目录下创建一个 templates 文件夹然后创建一个 login.html 登录页

<body class="uk-height-1-1">
<div class="uk-vertical-align uk-text-center uk-height-1-1">
        <div class="uk-vertical-align-middle" style="width: 250px;">
            <h1>Login Form</h1>
            <p class="uk-text-danger" th:if="${param.error}">
                用户名或密码错误...
            </p>
            <form class="uk-panel uk-panel-box uk-form" method="post" th:action="@{/login}">
                <div class="uk-form-row">
                    <input class="uk-width-1-1 uk-form-large" type="text" placeholder="Username" name="username"
                           value="reader"/>
                </div>
                <div class="uk-form-row">
                    <input class="uk-width-1-1 uk-form-large" type="password" placeholder="Password" name="password"
                           value="reader"/>
                </div>
                <div class="uk-form-row">
                    <button class="uk-width-1-1 uk-button uk-button-primary uk-button-large">Login</button>
                </div>
            </form>
        </div>
</div>
</body>

至此,授权服务器的编码工作就完成了。

Setting up a protected resource server #

Next, let’s set up a user service as a simulated resource provider (protected resource server). First, let’s take a look at the project initialization.

This time, the created POM file is nothing special, it only depends on spring-cloud-starter-oauth2:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <parent>
        <artifactId>springsecurity101</artifactId>
        <groupId>me.josephzhu</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <modelVersion>4.0.0</modelVersion>
    <artifactId>springsecurity101-cloud-oauth2-userservice</artifactId>
  
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

The configuration file is very simple, just declaring the resource server’s port as 8081:

server.port=8081

Also, remember to name the public key of the key pair generated in the project preparation as public.cert and put it in the resource folder. This allows the resource server to locally validate the legitimacy of the JWT. The content should look like this:

MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwR84LFHwnK5GXErnwkmD
mPOJl4CSTtYXCqmCtlbF+5qVOosu0YsM2DsrC9O2gun6wVFKkWYiMoBSjsNMSI3Z
w5JYgh+ldHvA+MIex2QXfOZx920M1fPUiuUPgmnTFS+Z3lmK3/T6jJnmciUPY1pe
h4MXL6YzeI0q4W9xNBBeKT6FDGpduc0FC3OlXHfLbVOThKmAUpAWFDwf9/uUA//l
3PLchmV6VwTcUaaHp5W8Af/GU4lPGZbTAqOxzB9ukisPFuO1DikacPhrOQgdxtqk
LciRTa884uQnkFwSguOEUYf3ni8GNRJauIuW0rVXhMOs78pKvCKmo53M0tqeC6ul
+QIDAQAB

Alright, let’s start coding.

Step 1: Create an interface GET /hello that is accessible without logging in, used to test server-side resources that can be accessed without logging in:

@RestController
public class HelloController {
    @GetMapping("hello")
    public String hello() {
        return "Hello";
    }
}

Step 2: Create three interfaces that require login and authorization to access. We use @PreAuthorize to control permissions before method execution:

Interface GET /user/name, can be accessed with read or write permission, and returns the username after login;

Interface GET /user, can be accessed with read or write permission, and returns the user information after login;

Interface POST /user, can only be accessed with write permission, and returns the additional information in the access token (which is the extra information added to the access token by the custom token enhancer CustomTokenEnhancer, the userDetails is the key). Here, we also demonstrated how to parse the token using TokenStore.

@RestController
@RequestMapping("user")
public class UserController {
    @Autowired
    private TokenStore tokenStore;
    
    /**
     * Can be accessed with read or write permission, returns the username after login
     * @param authentication
     * @return
     */
    @PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
    @GetMapping("name")
    public String name(OAuth2Authentication authentication) {
        return authentication.getName();
    }
    
    /**
     * Can be accessed with read or write permission, returns the user information after login
     * @param authentication
     * @return
     */
    @PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
    @GetMapping
    public OAuth2Authentication user(OAuth2Authentication authentication) {
        return authentication;
    }
    
    /**
     * Can only be accessed with write permission, returns the additional information in the access token
     * @param authentication
     * @return
     */
    @PreAuthorize("hasAuthority('WRITE')")
    @PostMapping
    public Map<String, Object> user(@RequestBody OAuth2Authentication authentication) {
        OAuth2AccessToken accessToken = tokenStore.readAccessToken((String) authentication.getCredentials());
        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) accessToken.getAdditionalInformation().get("userDetails");
        return details != null ? details.getDecodedDetails() : new HashMap<>();
    }
}
/* 
 * Accessible with read or write permission, returns the logged-in user information
 * @param authentication
 * @return
 */
@PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
@GetMapping
public OAuth2Authentication read(OAuth2Authentication authentication) {
    return authentication;
}

/* 
 * Only accessible with write permission, returns additional information from the access token
 * @param authentication
 * @return
 */
@PreAuthorize("hasAuthority('WRITE')")
@PostMapping
public Object write(OAuth2Authentication authentication) {
    OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
    OAuth2AccessToken accessToken = tokenStore.readAccessToken(details.getTokenValue());
    return accessToken.getAdditionalInformation().getOrDefault("userDetails", null);
}

Step 3: Create the core resource server configuration class. Here are two important points to note:

  • We hardcode the resource server’s ID as userservice
  • We are using the JWT method without a database + asymmetric encryption. Therefore, we need to verify using the local public key. Here, we configure the public key path.
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
    /* 
     * Declares the resource server ID as `userservice` and the TokenStore as JWT
     * @param resources
     * @throws Exception
     */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("userservice").tokenStore(tokenStore());
    }

    /* 
     * Configures the TokenStore
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    /* 
     * Configures the public key
     * @return
     */
    @Bean
    protected JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        Resource resource = new ClassPathResource("public.cert");
        String publicKey = null;
        try {
            publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
        } catch (IOException e) {
            e.printStackTrace();
        }
        converter.setVerifierKey(publicKey);
        return converter;
    }

    /* 
     * Configures anonymous access to requests except for '/user' paths
     * @param http
     * @throws Exception
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/user/**").authenticated()
            .anyRequest().permitAll();
    }
}

At this point, let’s think about what we can do if the token generated by the authorization server is not in JWT format:

  • First, the token can be stored in a database or Redis, and the resource server and authorization server can share a common TokenStore for verification.
  • Then, the resource server can use RemoteTokenServices to validate the token with the authorization server’s /oauth/check_token endpoint.

With these configurations, the resource server is now complete. We have also created two controllers, HelloController and UserController, in the resource server to test both anonymous and protected resources.

Initializing Data Configuration #

After implementing the authorization server and protected resource server code, it is easy to understand how to initialize the data in the oauth database. In summary, we need to configure three parts: users, authorities, and clients.

Configure two users. The reader user has read permission with the password “reader”, while the writer user has read and write permissions with the password “writer”. Remember, we are using BCryptPasswordEncoder for password hashing, to be precise.

INSERT INTO `users` VALUES ('reader', '$2a$04$C6pPJvC1v6.enW6ZZxX.luTdpSI/1gcgTVN7LhvQV6l/AfmzNU/3i', 1);

INSERT INTO `users` VALUES ('writer', '$2a$04$M9t2oVs3/VIreBMocOujqOaB/oziWL0SnlWdt8hV4YnlhQrORA0fS', 1);

Configure two authorities. This means assigning read permission to the reader user and read and write permissions to the writer user.

INSERT INTO `authorities` VALUES ('reader', 'READ');

INSERT INTO `authorities` VALUES ('writer', 'READ,WRITE');

Configure three clients. The client “userservice1” uses the resource owner password credentials grant type, the client “userservice2” uses the client credentials grant type, and the client “userservice3” uses the authorization code grant type.

INSERT INTO `oauth_client_details` VALUES ('userservice1', 'userservice', '1234', 'FOO', 'password,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL, 'true');

INSERT INTO `oauth_client_details` VALUES ('userservice2', 'userservice', '1234', 'FOO', 'client_credentials,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL, 'true');

INSERT INTO `oauth_client_details` VALUES ('userservice3', 'userservice', '1234', 'FOO', 'authorization_code,refresh_token', 'https://baidu.com,http://localhost:8082/ui/login,http://localhost:8083/ui/login,http://localhost:8082/ui/remoteCall', 'READ,WRITE', 7200, NULL, NULL, 'false');

It is worth mentioning that:

All three client accounts can access the resource with the ID “userservice”, which corresponds to the resource ID configured in the protected resource server. They need to match.

The password for all three client accounts is “1234”.

The authorization scope for all three client accounts is “FOO” (not sensitive information), and they have read and write permissions. However, for authorization grant types related to users (such as resource owner password credentials grant and authorization code grant), the final permissions depend on the intersection of client permissions and user permissions.

Different authorization grant types are supported by configuring the grant_types field. Here, for the sake of testing and observation, we have configured each client with a different authorization grant type. In a real business scenario, you can configure a client to support all four OAuth 2.0 grant types.

For userservice1 and userservice2, we have configured automatic user approval for authorization (no popup page asking the user for authorization).

Demonstration of Three Types of Authorization Grants #

Now that the authorization server and the protected resource server programs have been set up, and the database has been configured with test users, permissions, and clients, let’s use Postman to manually test the three types of authorization grants in OAuth 2.0: authorization code grant, resource owner credentials grant, and client credentials grant.

Resource Owner Credentials Grant #

First, let’s test the resource owner credentials grant. The POST request URL is:

http://localhost:8080/oauth/token?grant_type=password&client_id=userservice1&client_secret=1234&username=writer&password=writer

The obtained result is shown in the following figures:

As you can see, the token indeed contains the userDetails custom information added by the token enhancer. If we paste the public key into the page, we can see that the JWT is successfully verified:

In addition to local verification, we can also verify the JWT by accessing the authorization server:

http://localhost:8080/oauth/check_token?client_id=userservice1&client_secret=1234&token=...

The result is as follows:

Client Credentials Grant #

Next, let’s test the client credentials grant. The POST request URL is:

http://localhost:8080/oauth/token?grant_type=client_credentials&client_id=userservice2&client_secret=1234

As shown in the figure below, you can directly get the token:

Note that no refresh token is provided. This is because the refresh token is used to avoid the need for users to log in again after the access token expires. However, the client credentials grant does not involve a user, so there is no refresh token and no additional userDetails information can be injected.

You can also try accessing the authorization server to verify the JWT if the allowFormAuthenticationForClients parameter (allow form authentication) is not enabled:

Authorization Code Grant #

Finally, let’s test the more complex authorization code grant.

Step 1: Open the browser and access the URL:

http://localhost:8080/oauth/authorize?response_type=code&client_id=userservice3&redirect_uri=https://baidu.com

We have previously configured this URL in the database. After accessing it, the page will be redirected to the login page. Use the username “reader” and password “reader” to log in:

Since we have disabled automatic approval of authorization in the database, we arrive at the approval page after logging in:

After clicking “Agree,” you can see that the authorization record is also generated in the database:

Step 2: We can see that the browser is redirected to Baidu and provides us with an authorization code:

The authorization code is also recorded in the database:

Then POST to the following URL (replace the code parameter with the obtained authorization code):

http://localhost:8080/oauth/token?grant_type=authorization_code&client_id=userservice3&client_secret=1234&code=XKkHGY&redirect_uri=https://baidu.com

You can exchange the authorization code for an access token:

Although the userservice3 client can have both read and write permissions, because we logged in as the user reader, who only has read permission, the resulting token only has read permission.

Demo of Access Control #

Now let’s test the two accounts we defined earlier, namely the read account and the write account, and see if their access control is effective.

First, let’s test our security configuration. Accessing the /hello endpoint does not require authentication and can be accessed anonymously:

Accessing the /user endpoint requires authentication:

Regardless of which mode we obtain the access token, we use the access token with read permission to access the resource server at the following address

(Add the following to the request header: Authorization: Bearer XXXXXXXXXX, where XXXXXXXXXX represents the access token):

http://localhost:8081/user/

The result is as follows:

Accessing http://localhost:8081/user/ via POST is obviously unsuccessful:

Because this interface requires write permission:

@PreAuthorize("hasAuthority('WRITE')")
@PostMapping
public Object write(OAuth2Authentication authentication) {

Let’s try with an access token that has both read and write permissions:

As we can see, the access is successful. The content output here is the additional information of the userDetails in the Token, indicating that the access control of the resource server is effective.

Building a Client Application #

In the previous demonstration, we used Postman, which is a manual way of making HTTP requests, to request and use tokens. Finally, let’s build an OAuth client application to automate this process.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://maven.apache.org/POM/4.0.0"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <parent>
        <artifactId>springsecurity101</artifactId>
        <groupId>me.josephzhu</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>springsecurity101-cloud-oauth2-client</artifactId>
    <modelVersion>4.0.0</modelVersion>
  
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
    </dependencies>
</project>

The configuration file is as follows:

server:
  port: 8083
  servlet:
    context-path: /ui
security:
  oauth2:
    client:
      clientId: userservice3
      clientSecret: 1234
      accessTokenUri: http://localhost:8080/oauth/token
      userAuthorizationUri: http://localhost:8080/oauth/authorize
      scope: FOO
    resource:
      jwt:
        key-value: |
          -----BEGIN PUBLIC KEY-----
          ***
          -----END PUBLIC KEY-----          
spring:
  thymeleaf:
    cache: false

The client application runs on port 8082. Here are a few things that need to be explained:

  • When testing locally, there is a pitfall. We need to configure context-path, otherwise there may be interference between client and server cookies, causing the CSRF defense to be triggered. After this problem occurs, there are no error logs. It can only be seen in the DEBUG logs, so this problem is very difficult to troubleshoot. To be honest, I don’t know why Spring does not output this information as a WARN-level log.

  • As an OAuth client, we need to configure the addresses for obtaining tokens and authorizations (obtaining authorization code) from the OAuth server. We also need to configure the client ID, password, and authorization scope.

  • Because we are using a JWT token, we need to configure the public key. Of course, if the public key is not configured directly here, we can also configure it to be fetched from the authorization server server.

  • Next, we can start coding.

Step 1: Implement the MVC configuration:

@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {

    @Bean
    public RequestContextListener requestContextListener() {
        return new RequestContextListener();
    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/")
                .setViewName("forward:/index");
        registry.addViewController("/index");
    }
}

Here, two things are done:

  • Configuring RequestContextListener to enable session-scoped beans.

  • Configuring the index path controller as the homepage.

Step 2: Implement security configuration:

@Configuration
@Order(200)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/", "/login**")
                .permitAll()
                .anyRequest()
                .authenticated();
    }
}

Here, we allow access to the “/” and “/login” paths, while other paths require authentication.

Step 3: Create a controller:

@RestController
public class DemoController {

    @Autowired
    OAuth2RestTemplate restTemplate;

    @GetMapping("/securedPage")
    public ModelAndView securedPage(OAuth2Authentication authentication) {
        return new ModelAndView("securedPage").addObject("authentication", authentication);
    }

    @GetMapping("/remoteCall")
    public String remoteCall() {
        ResponseEntity<String> responseEntity = restTemplate.getForEntity("http://localhost:8081/user/name", String.class);
        return responseEntity.getBody();
    }
}

Here, we implement two functionalities:

  • securedPage: This page passes the user information as a model to the view, so that the username and permissions can be displayed when the page is opened.

  • remoteCall: This endpoint uses OAuth2RestTemplate to directly fetch resources from the protected resource server after being logged in, without the need to manually obtain an access token and add it to the request header.

Step 4: Configure the OAuth2RestTemplate bean and enable OAuth2Sso functionality:

@Configuration
@EnableOAuth2Sso
public class OAuthClientConfig {

    @Bean
    public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oAuth2ClientContext,
                                                 OAuth2ProtectedResourceDetails details) {
        return new OAuth2RestTemplate(details, oAuth2ClientContext);
    }
}

Step 5: Implement the homepage:

<body>
<div class="container">
    <div class="col-sm-12">
        <h1>Spring Security SSO Client</h1>
        <a class="btn btn-primary" href="securedPage">Login</a>
    </div>
</div>
</body>

And the securedPage page that can only be accessed after logging in:

<body>
<div class="container">
    <div class="col-sm-12">
        <h1>Secured Page</h1>
        Welcome, <span th:text="${authentication.name}">Name</span>
        <br />
        Your authorities are <span th:text="${authentication.authorities}">authorities</span>
    </div>
</div>
</body>

Demonstration of Single Sign-On #

Alright, after setting up the client program, let’s first test the functionality of single sign-on. Start the client project and open the browser to access:

http://localhost:8082/ui/securedPage

You will see that the page automatically redirects to the login page of the authorization server (port 8080):

After logging in, the current username and permissions are displayed:

Now let’s start another client website with the port changed to 8083, and access the same address:

You can see that you are directly in the logged-in state, indicating the successful testing of single sign-on. Isn’t it convenient? In fact, to achieve the effect of single sign-on, the program automatically performs multiple 302 redirects in the background. The entire process is as follows:

http://localhost:8083/ui/securedPage ->

http://localhost:8083/ui/login ->

http://localhost:8080/oauth/authorize?client_id=userservice3&redirect_uri=http://localhost:8083/ui/login&response_type=code&scope=FOO&state=Sobjqe ->

http://localhost:8083/ui/login?code=CDdvHa&state=Sobjqe ->

http://localhost:8083/ui/securedPage

Demo Client Requesting Resource Server Resources #

Do you remember? In the previous section “Building a Client Program”, we also defined a remoteCall interface, which directly uses OAuth2RestTemplate to access resources on the remote resource server. Now, let’s test whether this interface can implement the automatic OAuth flow. Access:

    http://localhost:8082/ui/remoteCall

You will be redirected to the authorization server for login, and then automatically redirected back:

You can see that the username is outputted. The corresponding resource server backend API is:

    @PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
    @GetMapping("name")
    public String name(OAuth2Authentication authentication) {
        return authentication.getName();
    }

Try logging in as a writer user, and you will also get the correct output:

Summary #

In today’s lecture, we demonstrated how to use the OAuth 2.0 component of Spring Cloud to implement three types of OAuth 2.0 authorization grants (resource owner password credentials grant, client credentials grant, and authorization code grant) based on three program roles (authorization server, resource server, and client).

We first demonstrated the manual process for the three types of authorization grants, and then also showed how to implement authentication and single sign-on, as well as how to use client programs to automate the OAuth 2.0 process.

I have placed all the code used today on GitHub, and you can click on this link to view it.