多個 UI 應用程式和閘道:使用 Spring 和 Angular JS 的單頁應用程式 Part VI

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

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

在本文中,我們繼續討論如何在「單頁應用程式」中使用 Spring SecurityAngular JS。在這裡,我們展示如何使用 Spring Session 以及 Spring 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 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: http://localhost:8081
    resource:
      url: http://localhost:9000
security:
  user:
    password:
      password
  sessions: ALWAYS

代理中有 2 條路由,分別用於 UI 和資源伺服器,我們已設定預設密碼和 Session 持續性策略(告訴 Spring Security 始終在身份驗證時建立 Session)。這最後一點很重要,因為我們希望在閘道器中管理身份驗證,因此也管理 Session。

啟動並執行

我們現在有三個元件,在 3 個埠上執行。如果您將瀏覽器指向 http://localhost: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 身份驗證
GET /resource/ 200 JSON 問候語

您可能看不到 401,因為瀏覽器將首頁載入視為單一互動。所有請求都經過代理(閘道器中目前沒有任何內容,除了用於管理的 Actuator 端點)。

萬歲,它運作了!您有兩個後端伺服器,其中一個是 UI,每個都有獨立的功能並且能夠獨立測試,並且它們透過您控制的安全閘道器連接在一起,您已經為其配置了身份驗證。如果瀏覽器無法存取後端,這並不重要(事實上,這可能是一個優勢,因為它可以讓您更好地控制物理安全性)。

新增登入表單

就像第一部分中的「基本」範例一樣,我們現在可以將登入表單新增到閘道器,例如,透過複製第二部分中的程式碼。當我們這樣做時,我們也可以在閘道器中新增一些基本的導覽元素,因此使用者不必知道代理中 UI 後端的路徑。所以讓我們首先將靜態資源從「單一」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 中的範例,它還有一個帶有「登出」按鈕的最小導覽列。以下是登入表單的螢幕截圖

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 中的內容,並且我們想將對此功能的存取權限限制為具有特殊角色的使用者。因此,我們將在代理背後新增一個「管理」應用程式,並且系統將如下所示

Components of the System

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

zuul:
  routes:
    ui:
      url: http://localhost:8081
    admin:
      url: http://localhost:8082
    resource:
      url: http://localhost:9000

現有 UI 可供具有「USER」角色的使用者使用,這在上面的方塊圖中的閘道器方塊(綠色字母)中指示,並且需要「ADMIN」角色才能進入管理應用程式也是如此。可以在閘道器中應用「ADMIN」角色的存取決策,在這種情況下,它會出現在 WebSecurityConfigurerAdapter 中,或者可以在管理應用程式本身中應用它(我們將在下面看到如何做到這一點)。

此外,假設在管理應用程式中,我們想區分「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 Samples中。

存取決策進入管理應用程式。對於「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」端點,然後它提取一些資料,設定已驗證的標誌,如果使用者已通過驗證,則透過查看使用者資料來計算模板。

為了支援後端上的這個函數,我們需要一個端點,例如在我們的主應用程式類別中

@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);
  }

}

注意:角色名稱從帶有「ROLE_」前綴的「/user」端點傳回,因此我們可以將它們與其他種類的授權區分開來(這是 Spring Security 的事情)。因此,JavaScript 中需要「ROLE_」前綴,但在 Spring Security 配置中不需要,因為從方法名稱中可以清楚地看出「角色」是操作的重點。

我們為什麼在這裡?

現在我們有一個不錯的小系統,具有 2 個獨立的使用者介面和一個後端資源伺服器,所有這些都受到閘道器中相同身份驗證的保護。閘道器充當微代理的事實使後端安全問題的實作變得非常簡單,它們可以自由地專注於自己的業務問題。Spring Session 的使用(再次)避免了大量的麻煩和潛在的錯誤。

一個強大的功能是,後端可以獨立地具有他們喜歡的任何種類的身份驗證(例如,如果您知道其物理地址和一組本地憑證,則可以直接進入 UI)。閘道器施加了一組完全不相關的約束,只要它可以驗證使用者身份並為他們分配滿足後端存取規則的元資料即可。這是一個出色的設計,能夠獨立開發和測試後端元件。如果我們願意,我們可以返回到外部 OAuth2 伺服器(如第五部分中),甚至在閘道器中使用完全不同的東西進行身份驗證,並且不需要觸摸後端。

這個架構(單一閘道控制驗證,並且跨所有組件共享 Session Token)的一個額外好處是,「單一登出」功能會自動實現。我們在第五部分中認為這個功能難以實現。更精確地說,單一登出的其中一種用戶體驗方法會在我們的完成系統中自動提供:如果使用者從任何 UI(閘道、UI 後端或管理後端)登出,則他/她也會從所有其他 UI 登出,前提是每個 UI 都以相同的方式實作「登出」功能(使 Session 失效)。

如果您仍然覺得有趣,請嘗試閱讀該系列的下一篇文章,該文章主要關於 Javascript,但仍然展示了 Spring 後端如何使事情變得更容易。

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

獲取 Spring 電子報

與 Spring 電子報保持聯繫

訂閱

領先一步

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

了解更多

取得支援

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

了解更多

即將到來的活動

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

查看所有