領先一步
VMware 提供培訓和認證,助您加速進步。
瞭解更多注意:本文的原始碼和測試將繼續演進,但文字的更改在此處不再維護。請參閱教程版本以獲取最新內容。
在本文中,我們將繼續 我們關於 如何在“單頁應用程式”中使用 Spring Security 和 Angular JS 的討論。在這裡,我們將展示如何使用 Spring Security OAuth 和 Spring Cloud 來擴充套件我們的 API 閘道器,以實現單點登入和 OAuth2 令牌身份驗證到後端資源。這是本系列文章的第五篇,您可以透過閱讀 第一篇文章 來了解應用程式的基本構建塊或從頭開始構建它,或者直接檢視 Github 上的原始碼。在 上一篇文章 中,我們構建了一個小型分散式應用程式,它使用 Spring Session 對後端資源進行身份驗證,並使用 Spring Cloud 在 UI 伺服器中實現了一個嵌入式 API 閘道器。在本文中,我們將身份驗證職責提取到一個單獨的伺服器,使我們的 UI 伺服器成為授權伺服器的眾多單點登入應用程式中的第一個。這在當今的許多應用程式中都很常見,無論是在企業中還是在社交初創公司中。我們將使用 OAuth2 伺服器作為身份驗證器,以便我們也可以使用它來授予後端資源伺服器的令牌。Spring Cloud 將自動將訪問令牌中繼到我們的後端,並使我們能夠進一步簡化 UI 和資源伺服器的實現。
提醒:如果您正在使用示例應用程式完成本文,請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,針對單個伺服器執行此操作的最佳方法是開啟一個新的隱身視窗。
我們的第一步是建立一個新的伺服器來處理身份驗證和令牌管理。按照 第一部分 中的步驟,我們可以從 Spring Boot Initializr 開始。例如,在類 Unix 系統上使用 curl:
$ curl https://start.spring.io/starter.tgz -d style=web \
-d style=security -d name=authserver | tar -xzvf -
然後,您可以將該專案(預設情況下是一個正常的 Maven Java 專案)匯入到您喜歡的 IDE 中,或者直接在命令列中使用檔案和“mvn”進行處理。
我們需要新增 Spring OAuth 依賴項,所以在我們的 POM 中,我們新增:
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.0.5.RELEASE</version>
</dependency>
授權伺服器的實現非常簡單。最小版本如下所示:
@SpringBootApplication
public class AuthserverApplication extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(AuthserverApplication.class, args);
}
@Configuration
@EnableAuthorizationServer
protected static class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("acme")
.secret("acmesecret")
.authorizedGrantTypes("authorization_code", "refresh_token",
"password").scopes("openid");
}
}
我們只需要做兩件事(在新增 @EnableAuthorizationServer 之後):
註冊一個客戶端“acme”,併為其設定金鑰和一些授權的 grant 型別,包括“authorization_code”。
注入 Spring Boot 自動配置中的預設 AuthenticationManager,並將其連線到 OAuth2 端點。
現在,讓我們在埠 9999 上執行它,並設定一個可預測的密碼用於測試:
server.port=9999
security.user.password=password
server.contextPath=/uaa
我們還設定了上下文路徑,這樣它就不會使用預設值(“/”),否則您可能會將 localhost 上其他伺服器的 cookie 傳送到錯誤的伺服器。所以,執行伺服器,我們可以確保它正常工作:
$ mvn spring-boot:run
或者在您的 IDE 中啟動 main() 方法。
我們的伺服器使用 Spring Boot 的預設安全設定,因此,就像 第一部分 中的伺服器一樣,它將受到 HTTP Basic 身份驗證的保護。要啟動一個 授權碼令牌授權,您需要訪問授權端點,例如在 https://:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com。在您成功身份驗證後,您將收到一個重定向到 example.com 的請求,其中附帶一個授權碼,例如 http://example.com/?code=jYWioI。
注意:對於本示例應用程式,我們建立了一個沒有註冊重定向 URI 的客戶端“acme”,這使得我們可以重定向到 example.com。在生產應用程式中,您應始終註冊重定向 URI(並使用 HTTPS)。
可以使用“acme”客戶端憑據在令牌端點上將程式碼兌換為訪問令牌:
$ curl acme:acmesecret@localhost:9999/uaa/oauth/token \
-d grant_type=authorization_code -d client_id=acme \
-d redirect_uri=http://example.com -d code=jYWioI
{"access_token":"2219199c-966e-4466-8b7e-12bb9038c9bb","token_type":"bearer","refresh_token":"d193caf4-5643-4988-9a4a-1c03c9d657aa","expires_in":43199,"scope":"openid"}
訪問令牌是一個 UUID(“2219199c...”),由伺服器中的記憶體令牌儲存提供支援。我們還獲得了一個重新整理令牌,噹噹前訪問令牌過期時,我們可以使用它來獲取新的訪問令牌。
注意:由於我們允許“acme”客戶端使用“password”授權型別,因此我們也可以使用 curl 和使用者憑據直接從令牌端點獲取令牌,而不是使用授權碼。這不適用於基於瀏覽器的客戶端,但對於測試很有用。
如果您點選了上面的連結,您將看到 Spring OAuth 提供的白標 UI。起初,我們將使用它,稍後我們可以回到這裡,像我們在 第二部分 中為獨立伺服器所做的那樣來加強它。
如果我們從 第四部分 繼續,我們的資源伺服器正在使用 Spring Session 進行身份驗證,因此我們可以將其移除並替換為 Spring OAuth。我們還需要移除 Spring Session 和 Redis 依賴項,因此將此替換為:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
<version>1.0.0.RC1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
替換為:
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
然後從 主應用程式類 中移除 session Filter,並用方便的 @EnableOAuth2Resource 註解(來自 Spring Cloud Security)替換它:
@SpringBootApplication
@RestController
@EnableOAuth2Resource
class ResourceApplication {
@RequestMapping('/')
def home() {
[id: UUID.randomUUID().toString(), content: 'Hello World']
}
static void main(String[] args) {
SpringApplication.run ResourceApplication, args
}
}
這足以讓我們擁有一個受保護的資源。執行應用程式,並使用命令列客戶端訪問主頁:
$ curl -v localhost:9000
> GET / HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:9000
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
...
< WWW-Authenticate: Bearer realm="null", error="unauthorized", error_description="An Authentication object was not found in the SecurityContext"
< Content-Type: application/json;charset=UTF-8
{"error":"unauthorized","error_description":"An Authentication object was not found in the SecurityContext"}
您將看到一個 401 響應,其中帶有“WWW-Authenticate”標頭,表明它需要一個 bearer 令牌。我們將新增少量外部配置(在“application.properties”中)以允許資源伺服器解碼收到的令牌並進行身份驗證:
...
spring.oauth2.resource.userInfoUri: https://:9999/uaa/user
這告訴伺服器它可以使用該令牌訪問“/user”端點並利用該端點派生身份驗證資訊(這有點像 Facebook API 中的 “/me”端點)。實際上,它為資源伺服器提供了一種解碼令牌的方式,如 Spring OAuth2 中的 ResourceServerTokenServices 介面所示。
注意:
userInfoUri絕不是將資源伺服器與令牌解碼方式連線的唯一方法。事實上,它是一種最低通用分母(並且不是規範的一部分),但通常可以從 OAuth2 提供商(如 Facebook、Cloud Foundry、Github)處獲得,並且還有其他選擇。例如,您可以在令牌本身中編碼使用者身份驗證(例如,使用 JWT),或使用共享的後端儲存。CloudFoundry 中還有一個“/token_info”端點,它提供比使用者 info 端點更詳細的資訊,但需要更徹底的身份驗證。不同的選項(自然)提供不同的好處和權衡,但對這些選項的全面討論超出了本文的範圍。
在授權伺服器上,我們可以輕鬆地新增該端點:
@SpringBootApplication
@RestController
@EnableResourceServer
public class AuthserverApplication {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
...
}
我們添加了一個 @RequestMapping,與 第二部分 中的 UI 伺服器相同,並且還添加了 Spring OAuth 中的 @EnableResourceServer 註解,預設情況下,它保護授權伺服器中的所有內容,除了“/oauth/*”端點。
有了該端點後,我們就可以測試它以及問候資源,因為它們現在都接受由授權伺服器建立的 bearer 令牌:
$ TOKEN=2219199c-966e-4466-8b7e-12bb9038c9bb
$ curl -H "Authorization: Bearer $TOKEN" localhost:9000
{"id":"03af8be3-2fc3-4d75-acf7-c484d9cf32b1","content":"Hello World"}
$ curl -H "Authorization: Bearer $TOKEN" localhost:9999/uaa/user
{"details":...,"principal":{"username":"user",...},"name":"user"}
(替換您從自己的授權伺服器獲取的訪問令牌的值以自行完成此操作)。
我們需要完成的應用程式的最後一部分是 UI 伺服器,提取身份驗證部分並委託給授權伺服器。因此,與 資源伺服器 一樣,我們首先需要移除 Spring Session 和 Redis 依賴項,並用 Spring OAuth2 替換它們。
完成後,我們可以移除 session filter 和“/user”端點,並設定應用程式以重定向到授權伺服器(使用 @EnableOAuth2Sso 註解):
@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication {
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
回想一下 第四部分,UI 伺服器透過 @EnableZuulProxy 的作用,充當 API 閘道器,我們可以在 YAML 中宣告路由對映。因此,“/user”端點可以代理到授權伺服器:
zuul:
routes:
resource:
path: /resource/**
url: https://:9000
user:
path: /user/**
url: https://:9999/uaa/user
最後,我們需要將 WebSecurityConfigurerAdapter 更改為 OAuth2SsoConfigurerAdapter,因為它現在將用於修改 @EnableOAuth2Sso 設定的 SSO filter 鏈中的預設值:
@Configuration
protected static class SecurityConfiguration extends OAuth2SsoConfigurerAdapter {
@Override
public void match(RequestMatchers matchers) {
matchers.anyRequest();
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/index.html", "/home.html", "/")
.permitAll().anyRequest().authenticated().and().csrf()
.csrfTokenRepository(csrfTokenRepository()).and()
.addFilterAfter(csrfHeaderFilter(), CsrfFilter.class);
}
... // the csrf*() methods are the same as the old WebSecurityConfigurerAdapter
}
主要更改(除了基類名稱)是 matchers 進入自己的方法,並且不再需要 formLogin()。
對於 @EnableOAuth2Sso 註解成功聯絡和身份驗證正確的授權伺服器,還需要一些強制性的外部配置屬性。因此,我們需要在 application.yml 中新增:
spring:
oauth2:
sso:
home:
secure: false
path: /,/**/*.html
client:
accessTokenUri: https://:9999/uaa/oauth/token
userAuthorizationUri: https://:9999/uaa/oauth/authorize
clientId: acme
clientSecret: acmesecret
resource:
userInfoUri: https://:9999/uaa/user
其中大部分是關於 OAuth2 客戶端(“acme”)和授權伺服器的位置。還有一個 userInfoUri(就像在資源伺服器中一樣),以便使用者可以在 UI 應用程式本身中進行身份驗證。“home”部分是關於允許匿名訪問我們單頁應用程式中的靜態資源的。
我們仍然需要在前端 UI 應用程式中進行一些小的調整,以觸發重定向到授權伺服器。第一個是在“index.html”的導航欄中,其中“login”連結從 Angular 路由更改為:
<div ng-controller="navigation" class="container">
<ul class="nav nav-pills" role="tablist">
...
<li><a href="#/login">login</a></li>
...
</ul>
</div>
更改為純 HTML 連結:
<div ng-controller="navigation" class="container">
<ul class="nav nav-pills" role="tablist">
...
<li><a href="login">login</a></li>
...
</ul>
</div>
此連結指向的“/login”端點由 Spring Security 處理,如果使用者未經驗證,則會導致重定向到授權伺服器。
我們還可以移除“navigation”控制器中 login() 函式的定義,以及 Angular 配置中的“/login”路由,這可以簡化實現:
angular.module('hello', [ 'ngRoute' ]).config(function($routeProvider) {
$routeProvider.when('/', {
templateUrl : 'home.html',
controller : 'home'
}).otherwise('/');
}). // ...
.controller('navigation',
function($rootScope, $scope, $http, $location, $route) {
$http.get('user').success(function(data) {
if (data.name) {
$rootScope.authenticated = true;
} else {
$rootScope.authenticated = false;
}
}).error(function() {
$rootScope.authenticated = false;
});
$scope.credentials = {};
$scope.logout = function() {
$http.post('logout', {}).success(function() {
$rootScope.authenticated = false;
$location.path("/");
}).error(function(data) {
$rootScope.authenticated = false;
});
}
});
現在將所有伺服器一起執行,並在瀏覽器中訪問 UI,地址為 https://:8080。點選“login”連結,您將被重定向到授權伺服器進行身份驗證(HTTP Basic 彈出視窗)和批准令牌授權(白標 HTML),然後被重定向到 UI 的主頁,該頁面顯示從 OAuth2 資源伺服器獲取的問候語,並使用與我們身份驗證 UI 相同的令牌。
如果您使用一些開發者工具(通常按 F12 可開啟),您可以在瀏覽器中看到瀏覽器和後端之間的互動(在 Chrome 中預設可用,在 Firefox 中需要外掛)。摘要如下
| 動詞 | 路徑 | 狀態 | 響應 |
|---|---|---|---|
| GET | / | 200 | index.html |
| GET | /css/angular-bootstrap.css | 200 | Twitter Bootstrap CSS |
| GET | /js/angular-bootstrap.js | 200 | Bootstrap 和 Angular JS |
| GET | /js/hello.js | 200 | 應用程式邏輯 |
| GET | /home.html | 200 | 主頁的 HTML 部分 |
| GET | /使用者 | 302 | 重定向到登入頁面 |
| GET | /login | 302 | 重定向到認證伺服器 |
| GET | (uaa)/oauth/authorize | 401 | (已忽略) |
| GET | /resource | 302 | 重定向到登入頁面 |
| GET | /login | 302 | 重定向到認證伺服器 |
| GET | (uaa)/oauth/authorize | 401 | (已忽略) |
| GET | /login | 302 | 重定向到認證伺服器 |
| GET | (uaa)/oauth/authorize | 200 | HTTP Basic 身份驗證發生在此 |
| POST | (uaa)/oauth/authorize | 302 | 使用者批准授權,重定向到 /login |
| GET | /login | 302 | 重定向到主頁 |
| GET | /使用者 | 200 | (代理的) JSON 身份驗證使用者 |
| GET | /home.html | 200 | 主頁的 HTML 部分 |
| GET | /resource | 200 | (代理)JSON 問候語 |
以 (uaa) 開頭的請求是傳送到授權伺服器的。標記為“ignored”的響應是 Angular 在 XHR 呼叫中收到的響應,由於我們沒有處理這些資料,因此它們被丟棄了。在“/user”資源的情況下,我們會查詢一個經過身份驗證的使用者,但由於它在第一次呼叫時不存在,因此該響應被丟棄。
在 UI 的“/trace”端點中(向下滾動到最後),您將看到代理到“/user”和“/resource”的後端請求,remote:true 和 bearer 令牌而不是 cookie(正如在 第四部分 中那樣)被用於身份驗證。Spring Cloud Security 為我們處理了這一點:透過識別我們具有 @EnableOAuth2Sso 和 @EnableZuulProxy,它已經推斷出(預設情況下)我們希望將令牌中繼到代理的後端。
注意:與之前的文章一樣,請嘗試為“/trace”使用不同的瀏覽器,以免發生身份驗證交叉(例如,如果您使用 Chrome 測試 UI,請使用 Firefox)。
如果您點選“logout”連結,您會發現主頁發生了變化(問候語不再顯示),因此使用者不再透過 UI 伺服器進行身份驗證。然而,再次點選“login”時,您實際上不需要再次經歷授權伺服器的身份驗證和批准週期(因為您還沒有從那裡登出)。關於這是否是一種理想的使用者體驗,人們可能會有不同的意見,而且這是一個出了名棘手的問題(單點登出:Science Direct 文章 和 Shibboleth 文件)。理想的使用者體驗可能在技術上不可行,而且您有時也必須懷疑使用者是否真的想要他們所說的。聲稱“我想要‘登出’將我登出”聽起來很簡單,但顯而易見的回應是,“登出什麼?您是想從該 SSO 伺服器控制的所有系統中登出,還是隻想從您點選了‘登出’連結的那個系統登出?”我們沒有篇幅在此更廣泛地討論這個主題,但它確實值得更多關注。如果您有興趣,可以在 Open ID Connect 規範中找到一些關於原則的討論和一些(相當不吸引人的)實現想法。
這幾乎是我們對 Spring Security 和 Angular JS 棧的簡短遊覽的結尾。我們現在有了一個很好的架構,在三個獨立的元件中具有清晰的職責:UI/API 閘道器、資源伺服器和授權伺服器/令牌授予器。所有層中的非業務程式碼量現在都已最小化,並且很容易看出在哪裡可以擴充套件和改進業務邏輯的實現。接下來的步驟將是整理我們授權伺服器中的 UI,並可能新增一些測試,包括對 JavaScript 客戶端的測試。另一個有趣的 MAF 是提取所有樣板程式碼並將其放入一個庫中(例如,“spring-security-angular”),該庫包含 Spring Security 和 Spring Session 的自動配置,以及 Angular 部分中導航控制器的一些 webjar 資源。讀完本系列的文章後,任何希望瞭解 Angular JS 或 Spring Security 內部機制的人可能會感到失望,但如果您想了解它們如何協同工作以及一點配置就能帶來多大的好處,那麼希望您會有一個愉快的體驗。 Spring Cloud 是新推出的,這些示例在編寫時需要快照版本,但現在已經有候選版本可用,並且即將釋出 GA 版本,所以請嘗試一下,並透過 Github 或 gitter.im 傳送一些反饋。
本系列的 下一篇文章 討論的是訪問決策(身份驗證之外),並在同一代理後面使用多個 UI 應用程式。
您將在 Github 原始碼 中找到此應用程式的另一個版本,它擁有一個漂亮的登入頁面和使用者批准頁面,實現方式與我們在 第二部分 中實現的登入頁面類似。它還使用 JWT 對令牌進行編碼,因此資源伺服器無需使用“/user”端點,而是可以從令牌本身提取足夠的資訊來進行簡單的身份驗證。瀏覽器客戶端仍然使用它,透過 UI 伺服器進行代理,以便它可以確定使用者是否已經驗證(與真實應用程式中資源伺服器的可能呼叫次數相比,它不需要非常頻繁地執行此操作)。