朱晔和你聊Spring系列S1E10:强大且复杂的Spring Security(含OAuth2三角色+三模式完整例子)

Spring Security功能多,组件抽象程度高,配置方式多样,致使了Spring Security强大且复杂的特性。Spring Security的学习成本几乎是Spring家族中最高的,Spring Security的精良设计值得咱们学习,可是结合实际复杂的业务场景,咱们不但须要理解Spring Security的扩展方式还须要去理解一些组件的工做原理和流程(不然怎么去继承并改写须要改写的地方呢?),这又带来了更高的门槛,所以,在决定使用Spring Security搭建整套安全体系(受权、认证、权限、审计)以前仍是须要考虑一下未来咱们的业务会多复杂,咱们徒手写一套安全体系来的划算仍是使用Spring Security更好。css

短短的一篇文章不可能覆盖Spring Security的方方面面,在最近的工做中会比较多接触OAuth2,所以本文以这个维度来简单阐述一下若是使用Spring Security搭建一套OAuth2受权&SSO架构。html

OAuth2简介

OAuth2.0是一套受权体系的开放标准,定义了四大角色:java

  1. 资源拥有者,也就是用户,由用于授予三方应用权限
  2. 客户端,也就是三方应用程序,在访问用户资源以前须要用户受权
  3. 资源提供者,或者说资源服务器,提供资源,须要实现Token和ClientID的校验,以及作好相应的权限控制
  4. 受权服务器,验证用户身份,为客户端颁发Token,而且维护管理ClientID、Token以及用户

其中后三项均可以是独立的程序,在本文的例子中咱们会为这三者创建独立的项目。OAuth2.0标准同时定义了四种受权模式,这里介绍最经常使用的三种,也是后面会演示的三种(在以后的介绍中令牌=Token,码=Code,可能会混合表达):mysql

  1. 无论是哪一种模式,通用流程以下:
    • 三方网站(或者说客户端)须要先向受权服务器去申请一套接入的ClientID+ClientSecret
    • 用任意一种模式拿到访问Token(流程见下)
    • 拿着访问Token去资源服务器请求资源
    • 资源服务器根据Token查询到Token对应的权限进行权限控制
  2. 受权码模式,最标准最安全的模式,适合和外部交互,流程是:
    • 三方网站客户端转到受权服务器,上送ClientID,受权范围Scope、重定向地址RedirectUri等信息
    • 用户在受权服务器进行登陆而且进行受权批准(受权批准这步能够配置为自动完成)
    • 受权完成后重定向回到以前客户端提供的重定向地址,附上受权码
    • 三方网站服务端经过受权码+ClientID+ClientSecret去受权服务器换取Token(Token含访问Token和刷新Token,访问Token过去后用刷新Token去得到新的访问Token)
    • 你可能会问这个模式为何这么复杂,为何安全呢?由于咱们不会对外暴露ClientSecret,不会对外暴露访问Token,使用受权码换取Token的过程是服务端进行,客户端拿到的只是一次性的受权码
  3. 密码凭证模式,适合内部系统之间使用的模式(客户端是本身人,客户端须要拿到用户账号密码),流程是:
    • 用户提供账号密码给客户端
    • 客户端凭着用户的账号密码,以及客户端本身的ClientID+ClientSecret去受权服务器换取Token
  4. 客户端模式,适合内部服务端之间使用的模式:
    • 和用户没有关系,不是基于用户的受权
    • 客户端凭着本身的ClientID+ClientSecret去受权服务器换取Token

下面,咱们来搭建程序实际体会一下这几种模式。git

搭建受权服务器

首先来建立一个父POM,内含三个模块:github

<?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">
    <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.0.6.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>Finchley.SR2</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>

    <repositories>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/libs-milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>

</project>
复制代码

而后咱们建立第一个模块,资源服务器:web

<?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>
复制代码

这边咱们除了使用了Spring Cloud的OAuth2启动器以外还使用数据访问、Web等依赖,由于咱们的资源服务器须要使用数据库来保存客户端的信息、用户信息等数据,咱们同时也会使用thymeleaf来稍稍美化一下登陆页面。 如今咱们来建立一个配置文件application.yml:ajax

server:
  port: 8080

spring:
  application:
    name: oauth2-server
  datasource:
    url: jdbc:mysql://localhost:3306/oauth?useSSL=false
    username: root
    password: root
    driver-class-name: com.mysql.jdbc.Driver
复制代码

能够看到,咱们会使用oauth数据库,受权服务器的端口是8080。 数据库中咱们须要初始化一些表:spring

  1. 用户表users:存放用户名密码
  2. 受权表authorities:存放用户对应的权限
  3. 客户端信息表oauth_client_details:存放客户端的ID、密码、权限、容许访问的资源服务器ID以及容许使用的受权模式等信息
  4. 受权码表oauth_code:存放了受权码
  5. 受权批准表oauth_approvals:存放了用户受权第三方服务器的批准状况

DDL以下:sql

-- ----------------------------
-- Table structure for authorities
-- ----------------------------
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 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Table structure for oauth_approvals
-- ----------------------------
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;
-- ----------------------------
-- Table structure for oauth_client_details
-- ----------------------------
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 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Table structure for users
-- ----------------------------
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 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Table structure for oauth_code
-- ----------------------------
DROP TABLE IF EXISTS `oauth_code`;
CREATE TABLE `oauth_code` (
  `code` varchar(255) DEFAULT NULL,
  `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
复制代码

在以后演示的时候会看到这些表中的数据。这里能够看到咱们并无在数据库中建立相应的表来存放访问令牌、刷新令牌,这是由于咱们以后的实现会把令牌信息使用JWT来传输,不会存放到数据库中。基本上全部的这些表都是能够本身扩展的,只须要继承实现Spring的一些既有类便可,这里不作展开。 下面,咱们建立一个最核心的类用于配置受权服务器:

package me.josephzhu.springsecurity101.cloud.oauth2.server;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
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;
import org.springframework.security.oauth2.provider.approval.JdbcApprovalStore;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.sql.DataSource;
import java.util.Arrays;

@Configuration
@EnableAuthorizationServer
public class OAuth2ServerConfiguration extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private DataSource dataSource;
    @Autowired
    private AuthenticationManager authenticationManager;

    /** * 代码1 * @param clients * @throws Exception */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource);
    }

    /** * 代码2 * @param security * @throws Exception */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.checkTokenAccess("permitAll()")
                .allowFormAuthenticationForClients().passwordEncoder(NoOpPasswordEncoder.getInstance());
    }

    /** * 代码3 * @param endpoints * @throws Exception */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(
                Arrays.asList(tokenEnhancer(), jwtTokenEnhancer()));

        endpoints.approvalStore(approvalStore())
                .authorizationCodeServices(authorizationCodeServices())
                .tokenStore(tokenStore())
                .tokenEnhancer(tokenEnhancerChain)
                .authenticationManager(authenticationManager);
    }

    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new JdbcAuthorizationCodeServices(dataSource);
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtTokenEnhancer());
    }

    @Bean
    public JdbcApprovalStore approvalStore() {
        return new JdbcApprovalStore(dataSource);
    }

    @Bean
    public TokenEnhancer tokenEnhancer() {
        return new CustomTokenEnhancer();
    }

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

    /** * 代码4 */
    @Configuration
    static class MvcConfig implements WebMvcConfigurer {
        @Override
        public void addViewControllers(ViewControllerRegistry registry) {
            registry.addViewController("login").setViewName("login");
        }
    }
}
复制代码

分析下这个类:

  1. 首先咱们能够看到,咱们须要经过注解@EnableAuthorizationServer来开启受权服务器
  2. 代码片断1中,咱们配置了使用数据库来维护客户端信息,固然在各类Demo中咱们常常看到的是在内存中维护客户端信息,经过配置直接写死在这里,对于实际的应用咱们通常都会用数据库来维护这个信息,甚至还会创建一套工做流来容许客户端本身申请ClientID
  3. 代码片断2中,针对受权服务器的安全,咱们干了两个事情,首先打开了验证Token的访问权限(以便以后咱们演示),而后容许ClientSecret明文方式保存而且能够经过表单提交(而不只仅是Basic Auth方式提交),以后会演示到这个
  4. 代码片断3中,咱们干了几个事情:
    • 配置咱们的Token存放方式不是内存方式、不是数据库方式、不是Redis方式而是JWT方式,JWT是Json Web Token缩写也就是使用JSON数据格式包装的Token,由.句号把整个JWT分隔为头、数据体、签名三部分,JWT保存Token虽然易于使用可是不是那么安全,通常用于内部,而且须要走HTTPS+配置比较短的失效时间
    • 配置了JWT Token的非对称加密来进行签名
    • 配置了一个自定义的Token加强器,把更多信息放入Token中
    • 配置了使用JDBC数据库方式来保存用户的受权批准记录
  5. 代码片断4中,咱们配置了登陆页面的视图信息(其实能够独立一个配置类更规范)

针对刚才的代码,咱们须要补充一些东西到资源目录下,首先须要在资源目录下建立一个templates文件夹而后建立一个login.html登陆模板:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" class="uk-height-1-1">
<head>
    <meta charset="UTF-8"/>
    <title>OAuth2 Demo</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/uikit/2.26.3/css/uikit.gradient.min.css"/>
</head>

<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>
</html>
复制代码

而后,咱们须要使用keytool工具生成密钥,把密钥文件jks保存到目录下,而后还要导出一个公钥留做之后使用。刚才在代码中咱们还用到了一个自定义的Token加强器,实现以下:

package me.josephzhu.springsecurity101.cloud.oauth2.server;

import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;

import java.util.HashMap;
import java.util.Map;

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;
    }
}
复制代码

这段代码很是简单,就是把用户信息以userDetails这个Key存放到Token中去(若是受权模式是客户端模式这段代码无效,由于和用户不要紧)。这是一个常见需求,默认状况下Token中只会有用户名这样的基本信息,咱们每每须要把有关用户的更多信息返回给客户端(在实际应用中你可能会从数据库或外部服务查询更多的用户信息加入到JWT Token中去),这个时候就能够自定义加强器来丰富Token的内容。 到此受权服务器的核心配置已经完成,如今咱们再来实现一下安全方面的配置:

package me.josephzhu.springsecurity101.cloud.oauth2.server;

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.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import javax.sql.DataSource;


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

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

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login", "/oauth/authorize")
                .permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().loginPage("/login");
    }
}
复制代码

这里咱们主要作了两个事情:

  1. 配置用户帐户的认证方式,显然,咱们把用户存在了数据库中但愿配置JDBC的方式,此外,咱们还配置了使用BCryptPasswordEncoder加密来保存用户的密码(生产环境的用户密码确定不能是明文保存)
  2. 开放/login和/oauth/authorize两个路径的匿名访问,前者用于登陆,后者用于换受权码,这两个端点访问的时候都在登陆以前

最后配置一个主程序:

package me.josephzhu.springsecurity101.cloud.oauth2.server;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OAuth2ServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(OAuth2ServerApplication.class, args);
    }
}
复制代码

至此,受权服务器的配置完成。

搭建资源服务器

先来建立项目:

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

    </dependencies>
</project>
复制代码

配置及其简单,声明资源服务端口8081

server:
  port: 8081
复制代码

还记得在资源文件夹下放咱们以前经过密钥导出的公钥文件,相似:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwR84LFHwnK5GXErnwkmD
mPOJl4CSTtYXCqmCtlbF+5qVOosu0YsM2DsrC9O2gun6wVFKkWYiMoBSjsNMSI3Z
w5JYgh+ldHvA+MIex2QXfOZx920M1fPUiuUPgmnTFS+Z3lmK3/T6jJnmciUPY1pe
h4MXL6YzeI0q4W9xNBBeKT6FDGpduc0FC3OlXHfLbVOThKmAUpAWFDwf9/uUA//l
3PLchmV6VwTcUaaHp5W8Af/GU4lPGZbTAqOxzB9ukisPFuO1DikacPhrOQgdxtqk
LciRTa884uQnkFwSguOEUYf3ni8GNRJauIuW0rVXhMOs78pKvCKmo53M0tqeC6ul
+QIDAQAB
-----END PUBLIC KEY-----
复制代码

先来建立一个能够匿名访问的接口GET /hello:

package me.josephzhu.springsecurity101.cloud.oauth2.userservice;

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

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

再来建立一个须要登陆+受权才能访问到的一些接口:

package me.josephzhu.springsecurity101.cloud.oauth2.userservice;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationDetails;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("user")
public class UserController {

    @Autowired
    private TokenStore tokenStore;

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

    @PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
    @GetMapping
    public OAuth2Authentication read(OAuth2Authentication authentication) {
        return authentication;
    }

    @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);
    }
}
复制代码

这里咱们配置了三个接口,而且经过@PreAuthorize在方法执行前进行权限控制:

  1. GET /user/name接口读写权限均可以访问
  2. GET /user接口读写权限均可以访问,返回整个OAuth2Authentication
  3. POST /user接口只有写权限能够访问,返回以前的CustomTokenEnhancer加入到Token中的额外信息,Key是userDetails,这里也演示了使用TokenStore来解析Token的方式

下面咱们来建立核心的资源服务器配置类:

package me.josephzhu.springsecurity101.cloud.oauth2.userservice;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.util.FileCopyUtils;

import java.io.IOException;

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

    /** * 代码1 * @param resources * @throws Exception */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("foo").tokenStore(tokenStore());
    }
    
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
    
    @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;
    }

    /** * 代码2 * @param http * @throws Exception */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/user/**").authenticated()
                .anyRequest().permitAll();
    }
}
复制代码

这里咱们干了四件事情:

  1. @EnableResourceServer启用资源服务器
  2. @EnableGlobalMethodSecurity(prePostEnabled = true)启用方法注解方式来进行权限控制
  3. 代码1,声明了资源服务器的ID是foo,声明了资源服务器的TokenStore是JWT以及公钥
  4. 代码2,配置了除了/user路径以外的请求能够匿名访问

咱们想一下,若是受权服务器产生Token的话,资源服务器必须是要有一种办法来验证Token的,若是是非JWT的方式,咱们能够这么办:

  1. Token能够保存在数据库或Redis中,资源服务器和受权服务器共享底层的TokenStore来验证
  2. 资源服务器可使用RemoteTokenServices来从受权服务器的/oauth/check_token端点进行Token校验(还记得吗,咱们以前开放过这个端口)

如今咱们使用的是不落地的JWT方式+非对称加密,须要经过本地公钥进行验证,所以在这里咱们配置了公钥的路径。 最后建立一个启动类:

package me.josephzhu.springsecurity101.cloud.oauth2.userservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class UserServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }

}
复制代码

至此,资源服务器配置完成,咱们还在资源服务器中分别建了两个控制器,用于测试匿名访问和收到资源服务器权限保护的资源。

初始化数据配置

如今咱们来看一下如何配置数据库实现:

  1. 两个用户,读用户reader具备读权限,写用户writer具备读写权限
  2. 两个权限,读和写
  3. 三个客户端:
    • userservice1这个客户端使用密码凭证模式
    • userservice2这个客户端使用客户端模式
    • userservice3这个客户端使用受权码模式

首先是oauth_client_details表:

INSERT INTO `oauth_client_details` VALUES ('userservice1', 'foo', '1234', 'FOO', 'password,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL, 'true');
INSERT INTO `oauth_client_details` VALUES ('userservice2', 'foo', '1234', 'FOO', 'client_credentials,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL, 'true');
INSERT INTO `oauth_client_details` VALUES ('userservice3', 'foo', '1234', 'FOO', 'authorization_code,refresh_token', 'https://baidu.com', 'READ,WRITE', 7200, NULL, NULL, 'false');
复制代码

如以前所说,这里配置了三条记录:

  1. 它们能使用的资源ID都是foo,对应咱们资源服务器userservice的配置
  2. 它们的受权范围都是FOO,能够拿到的权限是读写(但对于用户关联的模式,最终拿到的权限还取决于客户端权限和用户权限的交集)
  3. 经过grant_types字段配置了支持的不一样的受权模式,这里咱们为了便于测试观察给三个客户端各自配置了一个模式,你彻底能够为一个客户端配置支持OAuth2.0的那四种模式
  4. userservice1和2咱们配置了用户自动批准受权(不会弹出一个页面要求用户进行受权那种)

而后是authorities表,其中咱们配置了两条记录,配置reader用户具备读权限,writer用户具备写权限:

INSERT INTO `authorities` VALUES ('reader', 'READ');
INSERT INTO `authorities` VALUES ('writer', 'READ,WRITE');
复制代码

最后是users表配置了两个用户的帐户名和密码:

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);
复制代码

还记得吗,密码咱们使用的是BCryptPasswordEncoder加密(准确说是哈希),可使用一些在线工具进行哈希

演示三种受权模式

客户端模式

POST请求地址: http://localhost:8080/oauth/token?grant_type=client_credentials&client_id=userservice2&client_secret=1234 以下图所示,直接能够拿到Token:

这里注意到并无提供刷新令牌,刷新令牌用于避免访问令牌失效后还须要用户登陆,客户端模式没有用户概念,没有刷新令牌。咱们把获得的Token粘贴到https://jwt.io/#debugger-io查看:
若是粘贴进去公钥的话还能够看到Token签名验证成功:
也能够试一下,若是咱们的受权服务器没有allowFormAuthenticationForClients的话,客户端的凭证须要经过Basic Auth传而不是Post过去:
还能够访问受权服务器来校验Token: http://localhost:8080/oauth/check_token?client_id=userservice1&client_secret=1234&token=... 获得以下结果:

密码凭证模式

POST请求地址: http://localhost:8080/oauth/token?grant_type=password&client_id=userservice1&client_secret=1234&username=writer&password=writer 获得以下图结果:

再看下Token中的信息:
能够看到果真包含了咱们TokenEnhancer加入的userDetails自定义信息。

受权码模式

首先打开浏览器访问地址: http://localhost:8080/oauth/authorize?response_type=code&client_id=userservice3&redirect_uri=https://baidu.com 注意,咱们客户端跳转地址须要和数据库中配置的一致,百度的URL咱们以前已经在数据库中有配置了,访问后页面会跳转到登陆界面,使用reader:reader登陆:

因为咱们数据库中设置的是禁用自动批准受权的模式,因此登陆后来到了批准界面:
点击赞成后能够看到数据库中也会产生受权经过记录:
而后咱们能够看到浏览器转到了百度而且提供给了咱们受权码: www.baidu.com/?code=O8RiC… 数据库中也记录了受权码:
而后POST访问: http://localhost:8080/oauth/token?grant_type=authorization_code&client_id=userservice3&client_secret=1234&code=O8RiCe&redirect_uri=https://baidu.com 能够获得访问令牌:
虽然userservice3客户端能够有READ和WRITE权限,可是咱们登陆的用户reader只有READ权限,最后拿到的权限只有READ

演示资源服务器权限控制

首先咱们能够测试一下咱们的安全配置,访问/hello端点不须要认证能够匿名访问:

访问/user须要身份认证:
无论以哪一种模式拿到访问令牌,咱们用具备读权限的访问令牌GET访问资源服务器以下地址(请求头加入Authorization: Bearer XXXXXXXXXX,其中XXXXXXXXXX表明Token): http://localhost:8081/user/ 能够获得以下结果:
以POST方式访问http://localhost:8081/user/显然是失败的:
咱们换一个具备读写权限的令牌来试试:
果真能够成功,说明资源服务器的权限控制有效。

搭建客户端程序

在以前,咱们使用的是裸HTTP请求手动的方式来申请和使用令牌,最后咱们来搭建一个OAuth客户端程序自动实现这个过程:

<?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>
复制代码

配置文件以下:

server:
  port: 8082
  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-----
          MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwR84LFHwnK5GXErnwkmD
          mPOJl4CSTtYXCqmCtlbF+5qVOosu0YsM2DsrC9O2gun6wVFKkWYiMoBSjsNMSI3Z
          w5JYgh+ldHvA+MIex2QXfOZx920M1fPUiuUPgmnTFS+Z3lmK3/T6jJnmciUPY1pe
          h4MXL6YzeI0q4W9xNBBeKT6FDGpduc0FC3OlXHfLbVOThKmAUpAWFDwf9/uUA//l
          3PLchmV6VwTcUaaHp5W8Af/GU4lPGZbTAqOxzB9ukisPFuO1DikacPhrOQgdxtqk
          LciRTa884uQnkFwSguOEUYf3ni8GNRJauIuW0rVXhMOs78pKvCKmo53M0tqeC6ul
          +QIDAQAB
          -----END PUBLIC KEY-----
spring:
  thymeleaf:
    cache: false

#logging:
# level:
# ROOT: DEBUG
复制代码

客户端项目端口8082,几个须要说明的地方:

  1. 本地测试的时候一个坑就是咱们须要配置context-path不然可能会出现客户端和受权服务器服务端Cookie干扰致使CSRF防护触发的问题,这个问题出现后程序没有任何错误日志输出,只有开启DEBUG模式后才能看到DEBUG日志里有提示,这个问题很是难以排查,也不知道Spring为啥不把这个信息做为WARN级别
  2. 做为OAuth客户端,咱们须要配置OAuth服务端获取令牌的地址以及受权(获取受权码)的地址,以及须要配置客户端的ID和密码,以及受权范围
  3. 由于使用的是JWT Token,咱们须要配置公钥(固然,若是不在这里直接配置公钥的话也能够配置公钥从受权服务器服务端获取)

首先实现MVC的配置:

package me.josephzhu.springsecurity101.cloud.auth.client;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestContextListener;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

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

}
复制代码

这里作了两个事情:

  1. 配置RequestContextListener用于启用session scope的Bean
  2. 配置了index路径的首页Controller 而后实现安全方面的配置:
package me.josephzhu.springsecurity101.cloud.auth.client;

import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@Order(200)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/", "/login**")
                .permitAll()
                .anyRequest()
                .authenticated();
    }
}
复制代码

这里咱们实现的是/路径和/login路径容许访问,其它路径须要身份认证后才能访问。 而后咱们来建立一个控制器:

package me.josephzhu.springsecurity101.cloud.auth.client;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;

@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();
    }
}
复制代码

这里能够看到:

  1. 对于securedPage,咱们把用户信息做为模型传入了视图
  2. 咱们引入了OAuth2RestTemplate,在登陆后就可使用凭据直接从资源服务器拿资源,不须要繁琐的实现得到访问令牌,在请求头里加入访问令牌的过程 在开始的时候咱们定义了index页面,模板以下:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <title>Spring Security SSO Client</title>
    <link rel="stylesheet"
          href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css"/>
</head>

<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>
</html>
复制代码

如今又定义了securedPage页面,模板以下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <title>Spring Security SSO Client</title>
    <link rel="stylesheet"
          href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css"/>
</head>

<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>
</html>
复制代码

接下去最关键的一步是启用@EnableOAuth2Sso,这个注解包含了@EnableOAuth2Client:

package me.josephzhu.springsecurity101.cloud.auth.client;

import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.OAuth2ClientContext;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;

@Configuration
@EnableOAuth2Sso
public class OAuthClientConfig {
    @Bean
    public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oAuth2ClientContext, OAuth2ProtectedResourceDetails details) {
        return new OAuth2RestTemplate(details, oAuth2ClientContext);
    }
}
复制代码

此外,咱们这里还定义了OAuth2RestTemplate,网上一些比较老的资料给出的是手动读取配置文件来实现,最新版本已经能够自动注入OAuth2ProtectedResourceDetails。 最后是启动类:

package me.josephzhu.springsecurity101.cloud.auth.client;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OAuth2ClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(OAuth2ClientApplication.class, args);
    }
}
复制代码

演示单点登陆

启动客户端项目,打开浏览器访问http://localhost:8082/ui/securedPage: 能够看到页面自动转到了受权服务器的登陆页面:

点击登陆后出现以下错误:
显然,以前咱们数据库中配置的redirect_uri是百度首页,须要包含咱们的客户端地址,咱们把字段内容修改成4个地址: baidu.com, http://localhost:8082/ui/login,http://localhost:8083/ui/login,http://localhost:8082/ui/remoteCall 刷新页面,登陆成功:
咱们再启动另外一个客户端网站,端口改成8083,而后访问一样地址:
能够看到一样是登陆状态,SSO单点登陆测试成功,是否是很方便。

演示客户端请求资源服务器资源

最后,咱们来访问一下remoteCall接口:

能够看到输出了用户名,对应的资源服务器服务端是:

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

换一个用户登陆试试:

总结

本文以OAuth 2.0这个维度来小窥了一下Spring Security的功能,介绍了OAuth 2.0的基本概念,体验了三种经常使用模式,也使用Spring Security实现了OAuth 2.0的三个组件,客户端、受权服务器和资源服务器,实现了资源服务器的权限控制,最后还使用客户端测试了一下SSO和OAuth2RestTemplate使用,全部代码见个人Github github.com/JosephZhu19… ,但愿本文对你有用。

相关文章
相关标签/搜索