多個 UI 應用和一個閘道器:Spring 和 Angular JS 的單頁應用程式(第六部分)

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

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

在本文中,我們將繼續我們關於如何在“單頁應用程式”中使用Spring SecurityAngular JS 的討論。在這裡,我們展示瞭如何將Spring SessionSpring Cloud 結合使用,以整合我們在第二部分和第四部分中構建的系統的功能,並實際上構建了 3 個具有不同職責的單頁應用程式。目標是構建一個閘道器(如第四部分中的那樣),該閘道器不僅用於 API 資源,還用於從後端伺服器載入 UI。我們透過使用閘道器將身份驗證傳遞給後端,從而簡化了第二部分中的令牌處理部分。然後,我們擴充套件該系統,以展示如何在後端進行本地、細粒度的訪問決策,同時仍然在閘道器處控制身份和身份驗證。這是構建分散式系統的一種非常強大的模型,並且具有許多好處,我們可以在引入我們構建的程式碼中的功能時進行探索。

提醒:如果您正在透過示例應用程式閱讀本文,請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑據。在 Chrome 中,最佳方法是開啟一個新的隱身視窗。

目標架構

這是我們將要構建的基本系統的圖片

Components of the System

與本系列中的其他示例應用程式一樣,它有一個 UI(HTML 和 JavaScript)和一個資源伺服器。與第四部分中的示例一樣,它有一個閘道器,但這裡它是獨立的,不屬於 UI。UI 實際上成為了後端的一部分,這給了我們更多配置和重新實現功能的選擇,也帶來了其他好處,正如我們將看到的。

瀏覽器向閘道器請求所有內容,它不必瞭解後端架構(根本上,它不知道有後端)。瀏覽器在此閘道器中執行的操作之一是身份驗證,例如,它像第二部分一樣傳送使用者名稱和密碼,並獲得一個 cookie 作為回報。在後續請求中,它會自動提供 cookie,閘道器會將其傳遞給後端。無需在客戶端編寫程式碼即可實現 cookie 傳遞。後端使用 cookie 進行身份驗證,因為所有元件共享一個會話,所以它們共享相同的使用者資訊。與第五部分進行對比,在該部分中,cookie 必須在閘道器中轉換為訪問令牌,然後所有後端元件都必須獨立解碼該訪問令牌。

第四部分一樣,閘道器簡化了客戶端和伺服器之間的互動,並提供了一個小巧、定義明確的表面來處理安全性。例如,我們不必擔心跨源資源共享,這真是令人欣慰,因為這很容易出錯。

我們將在Github 上提供我們將要構建的完整專案的原始碼,因此如果您願意,可以直接克隆該專案並從中開始。在該系統的最終狀態(“double-admin”)中有一個額外的元件,暫時忽略它。

構建後端

在此架構中,後端與我們在第三部分中構建的“spring-session”示例非常相似,不同之處在於它實際上不需要登入頁面。要獲得我們想要的東西,最簡單的方法可能是複製第三部分的“resource”伺服器,並從第一部分中的“basic”示例中獲取 UI。要從“basic” UI 獲得我們想要的 UI,我們只需要新增幾個依賴項(就像我們在第三部分中第一次使用Spring Session時一樣)。

<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
  <version>1.0.0.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

並將@EnableRedisHttpSession註解新增到主應用程式類

@SpringBootApplication
@EnableRedisHttpSession
public class UiApplication {

public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

}

由於這現在是一個 UI,因此不再需要“/resource”端點。完成此操作後,您將擁有一個非常簡單的 Angular 應用程式(與“basic”示例相同),這極大地簡化了其行為的測試和推理。

最後,我們希望此伺服器作為後端執行,因此我們將為其分配一個非預設埠來監聽(在application.properties中)

server.port: 8081
security.sessions: NEVER

如果這是application.properties的*整個*內容,那麼該應用程式將是安全的,並且可以被一個名為“user”的使用者訪問,其密碼是隨機的,但在啟動時會在控制檯(INFO 日誌級別)上列印。 “security.sessions”設定意味著 Spring Security 將接受 cookie 作為身份驗證令牌,但除非它們已經存在,否則不會建立它們。

資源伺服器

資源伺服器可以輕鬆地從我們現有的一個示例生成。它與第三部分中的“spring-session”資源伺服器相同:只有一個“/resource”端點和@EnableRedisHttpSession來獲取分散式會話資料。我們希望此伺服器監聽非預設埠,並且我們希望能夠從會話中查詢身份驗證,因此我們需要此配置(在application.properties中)

server.port: 9000
security.sessions: NEVER

完整的示例在 Github 上,如果您想一探究竟。

閘道器

對於閘道器的初始實現(最簡單的可行方案),我們可以只使用一個空的 Spring Boot Web 應用程式並新增@EnableZuulProxy註解。正如我們在第一部分中看到的,有幾種方法可以做到這一點,一種是使用Spring Initializr來生成一個骨架專案。更簡單的是使用Spring Cloud Initializr,它與 Spring Initializr 相同,但用於Spring Cloud應用程式。使用與第一部分相同的命令列操作序列

$ mkdir gateway && cd gateway
$ curl https://cloud-start.spring.io/starter.tgz -d style=web \
  -d style=security -d style=cloud-zuul -d name=gateway \
  -d style=redis | tar -xzvf - 

然後,您可以將該專案(預設情況下是普通的 Maven Java 專案)匯入到您喜歡的 IDE 中,或者直接在命令列中使用檔案和“mvn”進行操作。有一個版本在 github 上,如果您想從那裡開始,但它有一些我們還不需要的額外功能。

從空白的 Initializr 應用程式開始,我們新增 Spring Session 依賴項(如上面的 UI),以及@EnableRedisHttpSession註解

@SpringBootApplication
@EnableRedisHttpSession
@EnableZuulProxy
public class GatewayApplication {

public static void main(String[] args) {
    SpringApplication.run(GatewayApplication.class, args);
  }

}

閘道器已準備好執行,但它尚未了解我們的後端服務,因此讓我們將其設定為在application.yml中(如果您上面執行了 curl 操作,請將其從application.properties重新命名)

zuul:
  routes:
    ui:
      url: https://:8081
    resource:
      url: https://:9000
security:
  user:
    password:
      password
  sessions: ALWAYS

代理中有 2 個路由,一個用於 UI,一個用於資源伺服器,並且我們已經設定了預設密碼和會話持久化策略(告知 Spring Security 在身份驗證時始終建立會話)。最後這一點很重要,因為我們希望在閘道器中管理身份驗證和因此的會話。

啟動並執行

我們現在有三個元件,執行在 3 個埠上。如果您將瀏覽器指向 https://:8080/ui/,您應該會收到一個 HTTP Basic 挑戰,並且您可以作為“user/password”(在閘道器中的憑據)進行身份驗證,一旦完成,您應該會透過代理到資源伺服器的後端呼叫在 UI 中看到問候。

如果您使用一些開發者工具(通常按 F12 可開啟),您可以在瀏覽器中看到瀏覽器和後端之間的互動(在 Chrome 中預設可用,在 Firefox 中需要外掛)。摘要如下

動詞 路徑 狀態 響應
GET /ui/ 401 瀏覽器提示進行身份驗證
GET /ui/ 200 index.html
GET /ui/css/angular-bootstrap.css 200 Twitter Bootstrap CSS
GET /ui/js/angular-bootstrap.js 200 Bootstrap 和 Angular JS
GET /ui/js/hello.js 200 應用程式邏輯
GET /ui/user 200 authentication
GET /resource/ 200 JSON 問候語

您可能看不到 401,因為瀏覽器將主頁載入視為一次互動。所有請求都被代理(閘道器中除了用於管理的 Actuator 端點之外,還沒有其他內容)。

太棒了,成功了!您有兩個後端伺服器,其中一個是 UI,它們都具有獨立的功能,並且可以單獨測試,它們透過您控制並配置了身份驗證的、安全的閘道器連線在一起。如果後端對瀏覽器不可訪問也沒關係(事實上,這可能是一個優勢,因為它為您提供了更多的物理安全控制)。

新增登入表單

就像第一部分中的“basic”示例一樣,我們現在可以在閘道器中新增一個登入表單,例如透過複製第二部分中的程式碼。這樣做時,我們還可以在閘道器中新增一些基本的導航元素,這樣使用者就無需記住代理中 UI 後端的路徑。因此,讓我們首先將靜態資源從“single” UI 複製到閘道器,刪除訊息渲染,並在我們的主頁(在<body/>中的某個位置)中插入一個登入表單。

<body ng-app="hello" ng-controller="navigation" ng-cloak
	class="ng-cloak">
  ...
  <div class="container" ng-show="!authenticated">
    <form role="form" ng-submit="login()">
      <div class="form-group">
        <label for="username">Username:</label> <input type="text"
          class="form-control" id="username" name="username"
          ng-model="credentials.username" />
      </div>
      <div class="form-group">
        <label for="password">Password:</label> <input type="password"
          class="form-control" id="password" name="password"
          ng-model="credentials.password" />
      </div>
      <button type="submit" class="btn btn-primary">Submit</button>
    </form>
  </div>
</body>

而不是訊息渲染,我們將有一個大的導航按鈕

<div class="container" ng-show="authenticated">
  <a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>

如果您檢視 Github 中的示例,它還有一個最小的導航欄,帶有一個“Logout”按鈕。這是登入表單的螢幕截圖

Login Page

為了支援登入表單,我們需要一些 JavaScript 和一個“navigation”控制器,該控制器實現我們在<form/>中宣告的login()函式,並且我們需要設定authenticated標誌,以便主頁根據使用者是否已認證而呈現不同的內容。例如

angular.module('hello', []).controller('navigation',
function($scope, $http) {

  ...
  
  authenticate();
  
  $scope.credentials = {};

$scope.login = function() {
    authenticate($scope.credentials, function() {
      if ($scope.authenticated) {
        console.log("Login succeeded")
        $scope.error = false;
        $scope.authenticated = true;
      } else {
        console.log("Login failed")
        $scope.error = true;
        $scope.authenticated = false;
      }
    })
  };

}

其中authenticate()函式的實現與第二部分中的類似。

var authenticate = function(credentials, callback) {

  var headers = credentials ? {
    authorization : "Basic "
        + btoa(credentials.username + ":"
            + credentials.password)
  } : {};

  $http.get('user', {
    headers : headers
  }).success(function(data) {
    if (data.name) {
      $scope.authenticated = true;
    } else {
      $scope.authenticated = false;
    }
    callback && callback();
  }).error(function() {
    $scope.authenticated = false;
    callback && callback();
  });

}

我們可以使用$scope來儲存authenticated標誌,因為在這個簡單的應用程式中只有一個控制器。

如果我們執行這個增強的閘道器,而不是記住 UI 的 URL,我們可以直接載入主頁並點選連結。這是已認證使用者的首頁

Home Page

後端細粒度訪問決策

到目前為止,我們的應用程式在功能上與第三部分第四部分中的應用程式非常相似,但增加了一個專用的閘道器。額外層的優勢可能還不明顯,但我們可以透過擴充套件系統來強調它。假設我們想使用該閘道器來公開另一個後端 UI,供使用者“管理”主 UI 中的內容,並且我們希望限制對具有特殊角色的使用者的訪問。因此,我們將在代理後面新增一個“Admin”應用程式,系統將如下所示:

Components of the System

閘道器的application.yml中有一個新元件(Admin)和一個新路由。

zuul:
  routes:
    ui:
      url: https://:8081
    admin:
      url: https://:8082
    resource:
      url: https://:9000

上面塊圖中的綠色字母表示現有 UI 對“USER”角色使用者可用,而“ADMIN”角色是訪問 Admin 應用程式所必需的。對“ADMIN”角色的訪問決策可以在閘道器中應用,在這種情況下,它會出現在WebSecurityConfigurerAdapter中,或者可以在 Admin 應用程式本身中應用(我們將在下面看到如何做到這一點)。

此外,假設在 Admin 應用程式內部,我們想區分“READER”和“WRITER”角色,以便我們允許(比如說)審計員檢視主要管理員使用者所做的更改。這是一個細粒度的訪問決策,其中規則只在後端應用程式中已知,並且應該只在該應用程式中已知。在閘道器中,我們只需要確保我們的使用者帳戶具有所需的角色,並且此資訊可用,但閘道器不需要知道如何解釋它。在閘道器中,我們建立使用者帳戶以使示例應用程式自包含。

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

  @Autowired
  public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
      .withUser("user").password("password").roles("USER")
    .and()
      .withUser("admin").password("admin").roles("USER", "ADMIN", "READER", "WRITER")
    .and()
      .withUser("audit").password("audit").roles("USER", "ADMIN", "READER");
  }
  
}

其中“admin”使用者已增強了 3 個新角色(“ADMIN”、“READER”和“WRITER”),我們還添加了一個具有“ADMIN”訪問許可權但沒有“WRITER”許可權的“audit”使用者。

題外話:在生產系統中,使用者帳戶資料將管理在後端資料庫(最可能是目錄服務)中,而不是硬編碼在 Spring 配置中。連線到此類資料庫的示例應用程式很容易在網際網路上找到,例如在Spring Security 示例中。

訪問決策放在 Admin 應用程式中。對於“ADMIN”角色(此後端全域性必需),我們在 Spring Security 中這樣做。

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

@Override
  protected void configure(HttpSecurity http) throws Exception {
    http
    ...
      .authorizeRequests()
        .antMatchers("/index.html", "/login", "/").permitAll()
        .antMatchers("/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
    ...
  }
  
}

對於“READER”和“WRITER”角色,應用程式本身是分開的,並且由於該應用程式是用 JavaScript 實現的,因此我們需要在那裡做出訪問決策。一種方法是使用帶有嵌入式計算檢視的主頁。

<div class="container">
  <h1>Admin</h1>
  <div ng-show="authenticated" ng-include="template"></div>
  <div ng-show="!authenticated" ng-include="'unauthenticated.html'"></div>
</div>

Angular JS 將“ng-include”屬性值評估為表示式,然後使用結果載入模板。

提示:更復雜的應用程式可能會使用其他機制來模組化其自身,例如我們在本系列幾乎所有其他應用程式中使用的$routeProvider服務。

template變數在我們的控制器中初始化,首先透過定義一個實用函式。

var computeDefaultTemplate = function(user) {
  $scope.template = user && user.roles
      && user.roles.indexOf("ROLE_WRITER")>0 ? "write.html" : "read.html";		
}

然後,當控制器載入時,透過使用該實用函式。

angular.module('admin', []).controller('home',

function($scope, $http) {
	
  $http.get('user').success(function(data) {
    if (data.name) {
      $scope.authenticated = true;
      $scope.user = data;
      computeDefaultTemplate(data);
    } else {
      $scope.authenticated = false;
    }
    $scope.error = null
  })
  ...
      
})

應用程式首先會檢視本系列中常用的“/user”端點,然後提取一些資料,設定authenticated標誌,如果使用者已認證,則透過檢視使用者資料計算模板。

為了在後端支援此功能,我們需要一個端點,例如在我們的主應用程式類中。

@SpringBootApplication
@RestController
@EnableRedisHttpSession
public class AdminApplication {

  @RequestMapping("/user")
  public Map<String, Object> user(Principal user) {
    Map<String, Object> map = new LinkedHashMap<String, Object>();
    map.put("name", user.getName());
    map.put("roles", AuthorityUtils.authorityListToSet(((Authentication) user)
        .getAuthorities()));
    return map;
  }

  public static void main(String[] args) {
    SpringApplication.run(AdminApplication.class, args);
  }

}

注意:角色名稱從“/user”端點返回時帶有“ROLE_”字首,以便我們區分它們與其他型別的許可權(這是 Spring Security 的一個特性)。因此,JavaScript 中需要“ROLE_”字首,但在 Spring Security 配置中不需要,因為從方法名稱可以清楚地看出“roles”是操作的焦點。

我們為什麼在這裡?

現在我們有了一個不錯的系統,它包含 2 個獨立的 UI 和一個後端資源伺服器,所有這些都透過閘道器中的相同身份驗證進行保護。閘道器充當微代理的事實使得後端安全問題的實現極其簡單,它們可以專注於自己的業務問題。Spring Session 的使用(再次)避免了大量的麻煩和潛在的錯誤。

一個強大的特性是後端可以獨立地擁有任何它們喜歡的身份驗證(例如,如果您知道其物理地址和本地憑據集,您可以直接訪問 UI)。閘道器施加了一套完全無關的限制,只要它能夠驗證使用者並將元資料分配給他們,從而滿足後端中的訪問規則。這是獨立開發和測試後端元件的絕佳設計。如果我們願意,我們可以回到外部 OAuth2 伺服器(如第五部分),或者甚至完全不同的東西)來處理閘道器的身份驗證,而無需觸動後端。

此架構的一個獎勵功能(單一閘道器控制身份驗證,以及跨所有元件共享的會話令牌)是“單點登出”,這是我們在第五部分中認為難以實現的特性,可以免費獲得。更準確地說,單點登出使用者體驗的一種特定方法在我們完成的系統中是自動可用的:如果使用者從任何 UI(閘道器、UI 後端或 Admin 後端)登出,他將從所有其他 UI 登出,前提是每個單獨的 UI 都以相同的方式實現了“登出”功能(使會話失效)。

如果您仍然玩得很開心,請嘗試閱讀本系列的下一篇文章,它主要關於 JavaScript,但仍然展示了 Spring 後端如何讓事情變得更容易。

致謝:我想再次感謝所有幫助我開發本系列文章的人,特別是Rob WinchThorsten Späth,他們對文章和原始碼進行了仔細的審查。自從第一部分釋出以來,它沒有太大變化,但所有其他部分都根據讀者的評論和見解進行了改進,因此也感謝所有閱讀文章並花時間參與討論的人。

獲取 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速進步。

瞭解更多

獲得支援

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案,只需一份簡單的訂閱。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視所有