Spring Security功能多,元件抽象程度高,配置方式多樣,導致了Spring Security強大且複雜的特性。Spring Security的學習成本幾乎是Spring家族中最高的,Spring Security的精良設計值得我們學習,但是結合實際複雜的業務場景,我們不但需要理解Spring Security的擴充套件方式還需要去理解一些元件的工作原理和流程(否則怎麼去繼承並改寫需要改寫的地方呢?),這又帶來了更高的門檻,因此,在決定使用Spring Security搭建整套安全體系(授權、認證、許可權、審計)之前還是需要考慮一下將來我們的業務會多複雜,我們徒手寫一套安全體系來的划算還是使用Spring Security更好。
短短的一篇文章不可能覆蓋Spring Security的方方面面,在最近的工作中會比較多接觸OAuth2,因此本文以這個維度來簡單闡述一下如果使用Spring Security搭建一套OAuth2授權&SSO架構。
OAuth2簡介
OAuth2.0是一套授權體系的開放標準,定義了四大角色:
- 資源擁有者,也就是使用者,由用於授予三方應用許可權
- 客戶端,也就是三方應用程式,在訪問使用者資源之前需要使用者授權
- 資源提供者,或者說資源伺服器,提供資源,需要實現Token和ClientID的校驗,以及做好相應的許可權控制
- 授權伺服器,驗證使用者身份,為客戶端頒發Token,並且維護管理ClientID、Token以及使用者
其中後三項都可以是獨立的程式,在本文的例子中我們會為這三者建立獨立的專案。OAuth2.0標準同時定義了四種授權模式,這裡介紹最常用的三種,也是後面會演示的三種(在之後的介紹中令牌=Token,碼=Code,可能會混合表達):
- 不管是哪種模式,通用流程如下:
- 三方網站(或者說客戶端)需要先向授權伺服器去申請一套接入的ClientID+ClientSecret
- 用任意一種模式拿到訪問Token(流程見下)
- 拿著訪問Token去資源伺服器請求資源
- 資源伺服器根據Token查詢到Token對應的許可權進行許可權控制
- 授權碼模式,最標準最安全的模式,適合和外部互動,流程是:
- 三方網站客戶端轉到授權伺服器,上送ClientID,授權範圍Scope、重定向地址RedirectUri等資訊
- 使用者在授權伺服器進行登入並且進行授權批准(授權批准這步可以配置為自動完成)
- 授權完成後重定向回到之前客戶端提供的重定向地址,附上授權碼
- 三方網站服務端通過授權碼+ClientID+ClientSecret去授權伺服器換取Token(Token含訪問Token和重新整理Token,訪問Token過去後用重新整理Token去獲得新的訪問Token)
- 你可能會問這個模式為什麼這麼複雜,為什麼安全呢?因為我們不會對外暴露ClientSecret,不會對外暴露訪問Token,使用授權碼換取Token的過程是服務端進行,客戶端拿到的只是一次性的授權碼
- 密碼憑證模式,適合內部系統之間使用的模式(客戶端是自己人,客戶端需要拿到使用者帳號密碼),流程是:
- 使用者提供帳號密碼給客戶端
- 客戶端憑著使用者的帳號密碼,以及客戶端自己的ClientID+ClientSecret去授權伺服器換取Token
- 客戶端模式,適合內部服務端之間使用的模式:
- 和使用者沒有關係,不是基於使用者的授權
- 客戶端憑著自己的ClientID+ClientSecret去授權伺服器換取Token
下面,我們來搭建程式實際體會一下這幾種模式。
搭建授權伺服器
首先來建立一個父POM,內含三個模組:
<?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>
複製程式碼
然後我們建立第一個模組,資源伺服器:
<?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:
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。 資料庫中我們需要初始化一些表:
- 使用者表users:存放使用者名稱密碼
- 授權表authorities:存放使用者對應的許可權
- 客戶端資訊表oauth_client_details:存放客戶端的ID、密碼、許可權、允許訪問的資源伺服器ID以及允許使用的授權模式等資訊
- 授權碼錶oauth_code:存放了授權碼
- 授權批准表oauth_approvals:存放了使用者授權第三方伺服器的批准情況
DDL如下:
-- ----------------------------
-- 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");
}
}
}
複製程式碼
分析下這個類:
- 首先我們可以看到,我們需要通過註解@EnableAuthorizationServer來開啟授權伺服器
- 程式碼片段1中,我們配置了使用資料庫來維護客戶端資訊,當然在各種Demo中我們經常看到的是在記憶體中維護客戶端資訊,通過配置直接寫死在這裡,對於實際的應用我們一般都會用資料庫來維護這個資訊,甚至還會建立一套工作流來允許客戶端自己申請ClientID
- 程式碼片段2中,針對授權伺服器的安全,我們幹了兩個事情,首先開啟了驗證Token的訪問許可權(以便之後我們演示),然後允許ClientSecret明文方式儲存並且可以通過表單提交(而不僅僅是Basic Auth方式提交),之後會演示到這個
- 程式碼片段3中,我們幹了幾個事情:
- 配置我們的Token存放方式不是記憶體方式、不是資料庫方式、不是Redis方式而是JWT方式,JWT是Json Web Token縮寫也就是使用JSON資料格式包裝的Token,由.句號把整個JWT分隔為頭、資料體、簽名三部分,JWT儲存Token雖然易於使用但是不是那麼安全,一般用於內部,並且需要走HTTPS+配置比較短的失效時間
- 配置了JWT Token的非對稱加密來進行簽名
- 配置了一個自定義的Token增強器,把更多資訊放入Token中
- 配置了使用JDBC資料庫方式來儲存使用者的授權批准記錄
- 程式碼片段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");
}
}
複製程式碼
這裡我們主要做了兩個事情:
- 配置使用者賬戶的認證方式,顯然,我們把使用者存在了資料庫中希望配置JDBC的方式,此外,我們還配置了使用BCryptPasswordEncoder加密來儲存使用者的密碼(生產環境的使用者密碼肯定不能是明文儲存)
- 開放/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在方法執行前進行許可權控制:
- GET /user/name介面讀寫許可權都可以訪問
- GET /user介面讀寫許可權都可以訪問,返回整個OAuth2Authentication
- 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();
}
}
複製程式碼
這裡我們幹了四件事情:
- @EnableResourceServer啟用資源伺服器
- @EnableGlobalMethodSecurity(prePostEnabled = true)啟用方法註解方式來進行許可權控制
- 程式碼1,宣告瞭資源伺服器的ID是foo,宣告瞭資源伺服器的TokenStore是JWT以及公鑰
- 程式碼2,配置了除了/user路徑之外的請求可以匿名訪問
我們想一下,如果授權伺服器產生Token的話,資源伺服器必須是要有一種辦法來驗證Token的,如果是非JWT的方式,我們可以這麼辦:
- Token可以儲存在資料庫或Redis中,資源伺服器和授權伺服器共享底層的TokenStore來驗證
- 資源伺服器可以使用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);
}
}
複製程式碼
至此,資源伺服器配置完成,我們還在資源伺服器中分別建了兩個控制器,用於測試匿名訪問和收到資源伺服器許可權保護的資源。
初始化資料配置
現在我們來看一下如何配置資料庫實現:
- 兩個使用者,讀使用者reader具有讀許可權,寫使用者writer具有讀寫許可權
- 兩個許可權,讀和寫
- 三個客戶端:
- 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');
複製程式碼
如之前所說,這裡配置了三條記錄:
- 它們能使用的資源ID都是foo,對應我們資源伺服器userservice的配置
- 它們的授權範圍都是FOO,可以拿到的許可權是讀寫(但對於使用者關聯的模式,最終拿到的許可權還取決於客戶端許可權和使用者許可權的交集)
- 通過grant_types欄位配置了支援的不同的授權模式,這裡我們為了便於測試觀察給三個客戶端各自配置了一個模式,你完全可以為一個客戶端配置支援OAuth2.0的那四種模式
- 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,幾個需要說明的地方:
- 本地測試的時候一個坑就是我們需要配置context-path否則可能會出現客戶端和授權伺服器服務端Cookie干擾導致CSRF防禦觸發的問題,這個問題出現後程式沒有任何錯誤日誌輸出,只有開啟DEBUG模式後才能看到DEBUG日誌裡有提示,這個問題非常難以排查,也不知道Spring為啥不把這個資訊作為WARN級別
- 作為OAuth客戶端,我們需要配置OAuth服務端獲取令牌的地址以及授權(獲取授權碼)的地址,以及需要配置客戶端的ID和密碼,以及授權範圍
- 因為使用的是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");
}
}
複製程式碼
這裡做了兩個事情:
- 配置RequestContextListener用於啟用session scope的Bean
- 配置了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();
}
}
複製程式碼
這裡可以看到:
- 對於securedPage,我們把使用者資訊作為模型傳入了檢視
- 我們引入了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… ,希望本文對你有用。