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

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

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

在本文中,我們將繼續討論如何在“單頁應用”中使用 Spring SecurityAngular JS。這裡我們將展示如何結合使用 Spring Security OAuthSpring Cloud 來擴充套件我們的 API Gateway,以實現對後端資源的單點登入(Single Sign On)和 OAuth2 令牌認證。這是系列文章中的第五篇,您可以閱讀第一篇文章來了解應用的基本構建模組或從頭開始構建它,或者直接前往 Github 上的原始碼。在上一篇文章中,我們構建了一個小型分散式應用,該應用使用 Spring Session 對後端資源進行認證,並使用 Spring Cloud 在 UI 伺服器中實現了一個嵌入式 API Gateway。在本文中,我們將認證職責提取到獨立的伺服器中,以便我們的 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 後)

  • 註冊一個客戶端“acme”,包含一個 secret 和一些授權型別,包括“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

注意:出於本示例應用的需要,我們建立了一個沒有註冊重定向的客戶端“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...”),由伺服器中的記憶體令牌儲存支援。我們還獲得了一個重新整理令牌(refresh token),可以在當前訪問令牌過期時使用它來獲取新的訪問令牌。

注意:由於我們允許“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 token。我們將新增少量外部配置(在“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 端點,它提供比 user 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

$ 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 伺服器

我們需要完成的這個應用的最後一部分是 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 Gateway,我們可以在 YAML 中宣告路由對映。因此,“/user”端點可以被代理到授權伺服器

zuul:
  routes:
    resource:
      path: /resource/**
      url: https://:9000
    user:
      path: /user/**
      url: https://: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: 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 的主頁,greeting 資訊將從 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 /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 片段
GET /resource 200 (代理)JSON greeting

字首為 (uaa) 的請求傳送到授權伺服器。標記為“忽略”的響應是 Angular 在 XHR 呼叫中接收到的響應,由於我們沒有處理這些資料,所以它們被丟棄了。在請求“/user”資源時,我們確實查詢認證使用者,但由於在第一次呼叫中不存在,該響應被丟棄。

在 UI 的“/trace”端點(向下滾動到底部),您將看到代理到後端“/user”和“/resource”的請求,其中帶有 remote:true 並使用 bearer token 而非 Cookie(就像在第四部分那樣)進行認證。Spring Cloud Security 已經為我們處理了這一切:透過識別我們使用了 @EnableOAuth2Sso@EnableZuulProxy,它預設推斷出我們想要將 token 中繼到代理的後端服務。

注意:與前幾篇文章一樣,請嘗試使用不同的瀏覽器來訪問“/trace”,這樣可以避免認證交叉的問題(例如,如果您使用 Chrome 測試 UI,則使用 Firefox 訪問“/trace”)。

登出體驗

如果您點選“logout”連結,您會看到主頁發生變化(不再顯示 greeting),因此使用者已不再在 UI 伺服器上認證。但是,如果您再次點選“login”,實際上您無需再次透過授權伺服器的認證和批准流程(因為您還沒有從那裡登出)。關於這是否是理想的使用者體驗,人們意見不一,這是一個出了名的棘手問題(單點登出:Science Direct 文章Shibboleth 文件)。理想的使用者體驗可能在技術上不可行,有時您也需要懷疑使用者是否真的想要他們所說的。說“我想‘logout’把我登出”聽起來很簡單,但顯而易見的回答是:“從哪裡登出?您是想從這個 SSO 伺服器控制的所有系統中登出,還是僅僅從您點選‘logout’連結的那個系統中登出?”我們在這裡沒有更多的空間來更廣泛地討論這個話題,但它確實值得更多關注。如果您感興趣,可以在Open ID Connect 規範中找到一些關於原則和一些(相當不吸引人的)實現思路的討論。

總結

我們關於 Spring Security 和 Angular JS 技術棧的淺嘗輒止之旅到這裡就差不多結束了。現在我們擁有一個很好的架構,在三個獨立的元件中職責明確:UI/API Gateway、資源伺服器和授權伺服器/令牌發放者。所有層中的非業務程式碼量現在都非常少,並且很容易看出在哪裡可以擴充套件和改進實現以新增更多業務邏輯。接下來的步驟將是整理授權伺服器中的 UI,並可能新增更多測試,包括對 JavaScript 客戶端的測試。另一個有趣的任務是提取所有樣板程式碼,並將其放入一個庫中(例如“spring-security-angular”),該庫包含 Spring Security 和 Spring Session 的自動配置以及 Angular 部分的導航控制器所需的 webjars 資源。讀完本系列文章後,任何希望學習 Angular JS 或 Spring Security 內部工作原理的人可能會感到失望,但如果您想了解它們如何良好地協同工作以及一點點配置如何大有幫助,那麼希望您會有一個愉快的體驗。Spring Cloud 是新的,這些示例在編寫時需要使用 snapshot 版本,但現在已有 release candidate 版本可用,並且 GA 版本即將釋出,所以請檢視並透過 Githubgitter.im 傳送反饋。

系列的下一篇文章將討論訪問決策(超越認證)以及如何在同一個代理背後使用多個 UI 應用。

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

您可以在Github 上的原始碼中找到這個應用的另一個版本,該版本實現了一個漂亮的登入頁和使用者批准頁,其方式類似於我們在第二部分中實現登入頁的方法。它還使用 JWT 來編碼令牌,因此資源伺服器可以直接從令牌本身提取足夠的資訊來進行簡單認證,而無需使用“/user”端點。瀏覽器客戶端仍然使用它(透過 UI 伺服器代理),以便確定使用者是否已認證(與實際應用中對資源伺服器可能的呼叫次數相比,這不需要經常進行)。

獲取 Spring 新聞郵件

訂閱 Spring 新聞郵件,保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助力您快速提升。

瞭解更多

獲取支援

Tanzu Spring 提供對 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案,一個簡單訂閱即可獲得。

瞭解更多

近期活動

檢視 Spring 社群的所有近期活動。

檢視全部