使用 OAuth2 的 SSO:Angular JS 與 Spring Security 第五部分

工程 | Dave Syer | 2015 年 2 月 3 日 | ...

注意:此部落格的原始碼和測試持續演進,但文字的變更未在此處維護。請參閱 教學版本 以取得最新內容。

在本文中,我們繼續 我們的討論,探討如何在「單頁應用程式」中使用 Spring SecurityAngular JS。在此,我們展示如何將 Spring Security OAuthSpring Cloud 結合使用,以擴展我們的 API 閘道,使其執行單一登入和 OAuth2 權杖驗證到後端資源。這是系列文章的第五篇,您可以透過閱讀 第一篇文章 來了解應用程式的基本建構區塊或從頭開始建構它,或者您可以直接前往 Github 中的原始碼。在 上一篇文章 中,我們建構了一個小型分散式應用程式,該應用程式使用 Spring Session 來驗證後端資源,並使用 Spring Cloud 在 UI 伺服器中實作嵌入式 API 閘道。在本文中,我們將驗證職責提取到單獨的伺服器,使我們的 UI 伺服器成為授權伺服器的潛在多個單一登入應用程式中的第一個。這是當今許多應用程式中的常見模式,無論是在企業還是在社交新創公司中。我們將使用 OAuth2 伺服器作為驗證器,以便我們也可以使用它來授予後端資源伺服器的權杖。Spring Cloud 會自動將存取權杖轉送到我們的後端,並使我們能夠進一步簡化 UI 和資源伺服器的實作。

提醒:如果您正在使用範例應用程式完成本文,請務必清除瀏覽器快取中的 Cookie 和 HTTP Basic 認證。在 Chrome 中,針對單個伺服器執行此操作的最佳方法是打開一個新的無痕視窗。

建立 OAuth2 授權伺服器

我們的第一步是建立一個新的伺服器來處理驗證和權杖管理。按照 第一部分 中的步驟,我們可以從 Spring Boot Initializr 開始。例如,在類似 UN*X 的系統上使用 curl

$ curl https://start.spring.io/starter.tgz -d style=web \
-d style=security -d name=authserver | tar -xzvf - 

然後,您可以將該專案(預設情況下是一個普通的 Maven Java 專案)匯入您最喜歡的 IDE,或者只需在命令列中使用檔案和 "mvn"。

新增 OAuth2 依賴項

我們需要新增 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 之後)

  • 註冊一個具有密碼和一些授權授予類型(包括 "authorization_code")的客戶端 "acme"。

  • 從 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 驗證的保護。要啟動 授權碼權杖授予,您需要訪問授權端點,例如在 http://localhost:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com 一旦您通過驗證,您將被重新導向到 example.com,並附加一個授權碼,例如 http://example.com/?code=jYWioI

注意:為了此範例應用程式的目的,我們建立了一個沒有註冊重新導向的客戶端 "acme",這使我們能夠重新導向到 example.com。在生產應用程式中,您應始終註冊重新導向(並使用 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: http://localhost:9999/uaa/user

這會告知伺服器,它可以使用權杖存取 "/user" 端點,並使用該端點來取得驗證資訊(這有點像 Facebook API 中的 "/me" 端點)。實際上,它提供了一種資源伺服器解碼權杖的方法,如 Spring OAuth2 中的 ResourceServerTokenServices 介面所表示的那樣。

請注意:userInfoUri 絕不是將資源伺服器連接到解碼令牌的唯一方法。事實上,它有點像是最低共同分母(且不屬於規格的一部分),但通常可從 OAuth2 供應商(如 Facebook、Cloud Foundry、Github)獲得,並且還有其他選擇。例如,您可以將使用者身份驗證編碼到令牌本身中(例如,使用 JWT),或使用共享的後端儲存。Cloud Foundry 中還有一個 /token_info 端點,它提供的資訊比使用者資訊端點更詳細,但需要更徹底的身份驗證。不同的選項(自然地)提供不同的好處和權衡,但對這些進行完整的討論不在本文的範圍內。

實作使用者端點

在授權伺服器上,我們可以輕鬆新增該端點

@SpringBootApplication
@RestController
@EnableResourceServer
public class AuthserverApplication {

  @RequestMapping("/user")
  public Principal user(Principal user) {
    return user;
  }

  ...

}

我們新增了一個 @RequestMapping,與 第二部分 中的 UI 伺服器相同,並且新增了來自 Spring OAuth 的 @EnableResourceServer 註解,預設情況下,它會保護授權伺服器中的所有內容,除了 "/oauth/*" 端點。

有了該端點,我們可以測試它和 greeting 資源,因為它們現在都接受由授權伺服器建立的 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"}

(替換從您自己的授權伺服器獲得的 access token 值,使其正常運作)。

UI 伺服器

我們需要完成的這個應用程式的最後一部分是 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: http://localhost:9000
    user:
      path: /user/**
      url: http://localhost:9999/uaa/user

最後,我們需要將 WebSecurityConfigurerAdapter 變更為 OAuth2SsoConfigurerAdapter,因為現在它將用於修改 @EnableOAuth2Sso 設定的 SSO 過濾器鏈中的預設值

  @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: http://localhost:9999/uaa/oauth/token
      userAuthorizationUri: http://localhost:9999/uaa/oauth/authorize
      clientId: acme
      clientSecret: acmesecret
    resource:
      userInfoUri: http://localhost: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>

Spring Security 會處理這個連結前往的 "/login" 端點,如果使用者未通過身份驗證,它將導致重新導向到授權伺服器。

我們也可以移除 "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;
    });
  }

});

它是如何運作的?

現在一起執行所有伺服器,並在瀏覽器中造訪 http://localhost:8080。點擊 "login" 連結,您將被重新導向到授權伺服器進行身份驗證(HTTP Basic 彈出視窗)並批准令牌授權(白色標籤 HTML),然後使用與我們驗證 UI 相同的令牌重新導向到 UI 中的首頁,其中包含從 OAuth2 資源伺服器取得的 greeting。

如果您使用一些開發人員工具(通常按 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 partial
GET /user 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 /user 200 (已代理) JSON 驗證使用者
GET /home.html 200 首頁的 HTML partial
GET /resource 200 (已代理) JSON greeting

以 (uaa) 為前綴的請求是發送到授權伺服器的請求。標記為 "已忽略" 的回應是 Angular 在 XHR 呼叫中收到的回應,由於我們沒有處理該資料,因此它們被丟棄了。我們確實會在 "/user" 資源的情況下尋找經過身份驗證的使用者,但由於第一次呼叫中沒有,因此該回應被丟棄了。

在 UI 的 "/trace" 端點中(向下捲動到底部),您將看到代理的後端請求到 "/user" 和 "/resource",其中 remote:true 和 bearer 令牌取代 cookie(就像在 第四部分 中一樣)用於身份驗證。Spring Cloud Security 為我們處理了這個問題:透過識別我們有 @EnableOAuth2Sso@EnableZuulProxy,它已經確定(預設情況下)我們要將令牌轉發到代理的後端。

注意:與之前的文章一樣,嘗試為 "/trace" 使用不同的瀏覽器,以避免身份驗證交叉(例如,如果您使用 Chrome 測試 UI,則使用 Firefox)。

登出體驗

如果您點擊 "logout" 連結,您會看到首頁發生變化(不再顯示 greeting),因此使用者不再透過 UI 伺服器進行身份驗證。但是,如果您點擊 "login" 返回,您實際上不需要再次經歷授權伺服器中的身份驗證和批准週期(因為您尚未從該伺服器登出)。關於這是否是一個理想的使用者體驗,意見會有所分歧,而且這是一個出了名的棘手問題(Single Sign Out:Science Direct 文章Shibboleth 文件)。理想的使用者體驗在技術上可能不可行,而且有時候您也必須懷疑使用者是否真的想要他們所說的。 "我希望 'logout' 將我登出" 聽起來很簡單,但顯而易見的回應是 "從什麼登出?您想要從這個 SSO 伺服器控制的所有系統登出,還是僅從您點擊 'logout' 連結的系統登出?" 我們沒有空間在這裡更廣泛地討論這個主題,但它確實值得更多關注。如果您有興趣,那麼在 Open ID Connect 規範中,有一些關於原則的討論和一些(相當令人倒胃口的)關於實作的想法。

結論

我們的 Spring Security 和 Angular JS 淺層導覽即將結束。 我們現在有一個很棒的架構,在三個獨立的元件(UI/API 閘道、資源伺服器和授權伺服器/Token 授予者)中具有明確的職責。 所有層中的非業務程式碼量現在都已降至最低,並且很容易看到在哪裡擴展和改進實作,以添加更多業務邏輯。 下一步將是整理我們授權伺服器中的 UI,並可能添加更多測試,包括 JavaScript 客户端的測試。 另一個有趣的任務是提取所有樣板程式碼並將其放入一個函式庫中(例如 "spring-security-angular"),其中包含 Spring Security 和 Spring Session 自動配置,以及 Angular 部分導航控制器的某些 webjars 資源。 閱讀了本系列文章後,任何希望學習 Angular JS 或 Spring Security 內部運作的人可能會感到失望,但如果您想了解它們如何協同工作,以及少量的配置如何產生很大的作用,那麼希望您能有一次良好的體驗。 Spring Cloud 是一個新專案,這些範例在撰寫時需要快照版本,但現在已有候選發佈版本,GA 發佈版本也即將推出,因此請查看並透過 Githubgitter.im 發送一些回饋。

本系列中的下一篇文章是有關於存取決策(超越身份驗證),並在同一個代理伺服器後面部署多個 UI 應用程式。

附錄:授權伺服器的 Bootstrap UI 和 JWT Token

您可以在 Github 上的原始碼 中找到此應用程式的另一個版本,該版本有一個漂亮的登入頁面和使用者批准頁面,其實作方式與我們在本系列文章的第二篇中實作登入頁面的方式類似。 它還使用 JWT 對 token 進行編碼,因此資源伺服器可以從 token 本身提取足夠的資訊來進行簡單的身份驗證,而無需使用 "/user" 端點。 瀏覽器用戶端仍然使用它,透過 UI 伺服器代理,以便它可以確定使用者是否已通過身份驗證(與真實應用程式中可能對資源伺服器的調用次數相比,它不需要經常這樣做)。

獲取 Spring 電子報

與 Spring 電子報保持聯繫

訂閱

領先一步

VMware 提供培訓和認證,以加速您的進度。

瞭解更多

獲得支援

Tanzu Spring 在一個簡單的訂閱中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位檔案。

瞭解更多

即將舉行的活動

查看 Spring 社群中所有即將舉行的活動。

查看所有