多個 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 得到我們想要的,我們只需要新增幾個依賴(就像我們在第三部分第一次使用 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 認證
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

為了支援登入表單,我們需要一些帶有“navigation”控制器的 JavaScript,它實現我們在 <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 伺服器(如 第五部分 中所述,甚至是完全不同的東西)來進行閘道器層的認證,而後端則無需改動。

此架構(單一閘道器控制認證,並在所有元件之間共享會話令牌)的一個額外特性是“Single Logout”(單點登出),我們在 第五部分 中指出該功能難以實現,但在此處它是免費獲得的。更確切地說,我們的最終系統中自動提供了一種特定的單點登出使用者體驗方法:如果使用者從任何一個 UI(閘道器、UI 後端或 Admin 後端)登出,他也會從所有其他 UI 登出,前提是每個獨立的 UI 都以相同的方式實現了“logout”功能(使會話失效)。

如果您仍然樂在其中,請嘗試本系列下一篇文章,該文章主要介紹 Javascript,但仍然展示了 Spring 後端如何簡化事情。

致謝:我再次感謝所有幫助我開發本系列的人,特別是 Rob WinchThorsten Späth 對文章和原始碼的認真審閱。自 第一部分 發表以來,它變化不大,但所有其他部分都根據讀者的評論和見解進行了改進,所以也感謝所有閱讀了文章並積極參與討論的人。

訂閱 Spring 資訊

保持與 Spring 資訊的連線

訂閱

領先一步

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

瞭解更多

獲取支援

透過一個簡單的訂閱,Tanzu Spring 為 OpenJDK™、Spring 和 Apache Tomcat® 提供支援和二進位制檔案。

瞭解更多

近期活動

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

檢視全部