朱曄和你聊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登陸:

因爲咱們數據庫中設置的是禁用自動批准受權的模式,因此登陸後來到了批准界面:

點擊贊成後能夠看到數據庫中也會產生受權經過記錄:

而後咱們能夠看到瀏覽器轉到了百度而且提供給了咱們受權碼:
https://www.baidu.com/?code=O8RiCe
數據庫中也記錄了受權碼:

而後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個地址:
https://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 https://github.com/JosephZhu1983/SpringSecurity101 ,但願本文對你有用。

相關文章
相關標籤/搜索