登入頁面:Angular JS 和 Spring Security 第二部分

工程 | Dave Syer | 2015 年 1 月 12 日 | ...

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

在本文中,我們繼續 我們的討論,討論如何在「單頁應用程式」中使用 Spring SecurityAngular JS。 在此,我們展示如何使用 Angular JS 通過表單驗證使用者,並獲取安全資源以在 UI 中呈現。 這是系列文章中的第二篇,您可以通過閱讀第一篇文章來了解應用程式的基本構建模組或從頭開始構建它,或者您可以直接轉到 Github 中的原始程式碼。 在第一篇文章中,我們構建了一個簡單的應用程式,該應用程式使用 HTTP Basic 驗證來保護後端資源。 在這篇文章中,我們添加了一個登入表單,讓使用者可以控制是否進行身份驗證,並修復了第一次迭代的問題(主要是缺乏 CSRF 保護)。

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

向首頁添加導航

單頁應用程式的核心是靜態 "index.html"。 我們已經有一個非常基本的版本,但對於此應用程式,我們需要提供一些導航功能(登入、登出、首頁),所以讓我們修改它(在 "src/main/resources/static" 中)

<!doctype html>
<html>
<head>
<title>Hello AngularJS</title>
<link
	href="css/angular-bootstrap.css"
	rel="stylesheet">
<style type="text/css">
[ng\:cloak], [ng-cloak], .ng-cloak {
	display: none !important;
}
</style>
</head>

<body ng-app="hello" ng-cloak class="ng-cloak">
	<div ng-controller="navigation" class="container">
		<ul class="nav nav-pills" role="tablist">
			<li class="active"><a href="#/">home</a></li>
			<li><a href="#/login">login</a></li>
			<li ng-show="authenticated"><a href="" ng-click="logout()">logout</a></li>
		</ul>
	</div>
	<div ng-view class="container"></div>
	<script src="js/angular-bootstrap.js" type="text/javascript"></script>
	<script src="js/hello.js"></script>
</body>
</html>

實際上,它與原始版本沒有太大區別。 主要功能

  • 有一個 <ul> 用於導航欄。 所有連結都直接返回首頁,但 Angular 一旦我們使用 "routes" 進行設定,就會以某種方式識別。

  • 所有內容都將作為 "partials" 添加到標記為 "ng-view" 的 <div> 中。

  • "ng-cloak" 已向上移動到 body,因為我們希望隱藏整個頁面,直到 Angular 可以找出要呈現的位元。 否則,菜單和內容在頁面載入時可能會在移動時「閃爍」。

  • 第一篇文章一樣,前端資源 "angular-bootstrap.css" 和 "angular-bootstrap.js" 是在建置時從 JAR 庫生成的。

向 Angular 應用程式添加導航

讓我們修改 "hello" 應用程式(在 "src/main/resources/public/js/hello.js" 中)以添加一些導航功能。 我們可以從為路由添加一些配置開始,以便首頁中的連結實際執行某些操作。 例如

angular.module('hello', [ 'ngRoute' ])
  .config(function($routeProvider, $httpProvider) {

	$routeProvider.when('/', {
		templateUrl : 'home.html',
		controller : 'home'
	}).when('/login', {
		templateUrl : 'login.html',
		controller : 'navigation'
	}).otherwise('/');

    $httpProvider.defaults.headers.common["X-Requested-With"] = 'XMLHttpRequest';

  })
  .controller('home', function($scope, $http) {
    $http.get('/resource/').success(function(data) {
      $scope.greeting = data;
    })
  })
  .controller('navigation', function() {});

我們添加了對名為 "ngRoute" 的 Angular 模組的依賴項,這使我們能夠將一個神奇的 $routeProvider 注入到 config 函數中(Angular 通過命名約定進行依賴項注入,並識別您的函數參數的名稱)。 然後在函數內部使用 $routeProvider 來設定到 "/"(「首頁」控制器)和 "/login"(「登入」控制器)的連結。 "templateUrls" 是從路由的根目錄(即 "/")到 "partial" 視圖的相對路徑,該視圖將用於呈現每個控制器建立的模型。

自定義 "X-Requested-With" 是瀏覽器客戶端發送的傳統標頭,它曾經是 Angular 中的默認標頭,但他們在 1.3.0 中將其刪除。 Spring Security 通過不在 401 響應中發送 "WWW-Authenticate" 標頭來響應它,因此瀏覽器不會彈出身份驗證對話框(這在我們的應用程式中是可取的,因為我們想控制身份驗證)。

為了使用 "ngRoute" 模組,我們需要在建置靜態資源的 "wro.xml" 配置(在 "src/main/wro" 中)中添加一行

<groups xmlns="http://www.isdc.ro/wro">
  <group name="angular-bootstrap">
    ...
    <js>webjar:angularjs/1.3.8/angular-route.min.js</js>
   </group>
</groups>

問候語

來自舊首頁的問候語內容可以放在 "home.html" 中(緊挨著 "src/main/resources/static" 中的 "index.html")

<h1>Greeting</h1>
<div ng-show="authenticated">
	<p>The ID is {{greeting.id}}</p>
	<p>The content is {{greeting.content}}</p>
</div>
<div  ng-show="!authenticated">
	<p>Login to see your greeting</p>
</div>

由於使用者現在可以選擇是否登入(之前所有內容都由瀏覽器控制),我們需要在 UI 中區分安全內容和不安全內容。 我們通過添加對(尚未存在的)authenticated 變數的引用來預期了這一點。

登入表單

登入表單放在 "login.html" 中

<div class="alert alert-danger" ng-show="error">
	There was a problem logging in. Please try again.
</div>
<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>

這是一個非常標準的登入表單,包含 2 個用於使用者名稱和密碼的輸入,以及一個用於通過 ng-submit 提交表單的按鈕。 您不需要表單標籤上的 action,所以最好不要放入任何 action。 還有一個錯誤訊息,僅當 angular $scope 包含 error 時才會顯示。 表單控件使用 ng-model 在 HTML 和 Angular 控制器之間傳遞數據,在這種情況下,我們使用 credentials 對象來保存使用者名稱和密碼。 根據我們定義的路由,登入表單與 "navigation" 控制器連結,該控制器目前為空,所以讓我們轉到該控制器以填補一些空白。

身份驗證過程

為了支持我們剛剛添加的登入表單,我們需要添加更多功能。 在客戶端,這些將在 "navigation" 控制器中實現,在伺服器端,它將是 Spring Security 配置。

提交登入表單

為了提交表單,我們需要定義我們已經在表單中通過 ng-submit 引用的 login() 函數,以及我們通過 ng-model 引用的 credentials 對象。 讓我們充實 "hello.js" 中的 "navigation" 控制器(省略路由配置和 "home" 控制器)

angular.module('hello', [ 'ngRoute' ]) // ... omitted code
.controller('navigation',

  function($rootScope, $scope, $http, $location) {

  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) {
        $rootScope.authenticated = true;
      } else {
        $rootScope.authenticated = false;
      }
      callback && callback();
    }).error(function() {
      $rootScope.authenticated = false;
      callback && callback();
    });

  }

  authenticate();
  $scope.credentials = {};
  $scope.login = function() {
      authenticate($scope.credentials, function() {
        if ($rootScope.authenticated) {
          $location.path("/");
          $scope.error = false;
        } else {
          $location.path("/login");
          $scope.error = true;
        }
      });
  };
});

當頁面載入時,"navigation" 控制器中的所有程式碼都將執行,因為包含菜單欄的 <div> 可見並使用 ng-controller="navigation" 進行修飾。 除了初始化 credentials 對象外,它還定義了 2 個函數,即我們在表單中需要的 login() 和一個本地輔助函數 authenticate(),該函數嘗試從後端載入 "user" 資源。 呼叫 authenticate() 函數時,將載入控制器以查看使用者是否已經通過身份驗證(例如,如果他在會話中間刷新了瀏覽器)。 我們需要 authenticate() 函數來進行遠端呼叫,因為實際的身份驗證是由伺服器完成的,並且我們不想信任瀏覽器來追蹤它。

authenticate() 函數設定一個名為 authenticated 的應用程式範圍標誌,我們已經在 "home.html" 中使用它來控制呈現頁面的哪些部分。 我們使用 $rootScope 執行此操作,因為它既方便又易於遵循,並且我們需要在 "navigation" 和 "home" 控制器之間共享 authenticated 標誌。 Angular 專家可能更喜歡通過共享用戶定義的服務來共享數據(但最終會使用相同的機制)。

authenticate() 對於相對資源(相對於應用程式的部署根目錄)"/user" 進行 GET 請求。 從 login() 函數呼叫時,它會在標頭中添加 Base64 編碼的憑證,因此在伺服器端,它會執行身份驗證並返回接受一個 Cookie。 當我們獲得身份驗證的結果時,login() 函數還會相應地設置本地 $scope.error 標誌,該標誌用於控制登入表單上方錯誤訊息的顯示。

目前已驗證的使用者

為了支援 authenticate() 函式,我們需要在後端新增一個端點

@SpringBootApplication
@RestController
public class UiApplication {
  
  @RequestMapping("/user")
  public Principal user(Principal user) {
    return user;
  }

  ...

}

這在 Spring Security 應用程式中是一個有用的技巧。如果 "/user" 資源可以存取,它將會回傳目前已驗證的使用者 (一個 Authentication),否則 Spring Security 會攔截請求並透過 AuthenticationEntryPoint 發送 401 回應。

在伺服器上處理登入請求

Spring Security 讓處理登入請求變得容易。我們只需要在我們的 主要應用程式類別 中新增一些設定 (例如,作為一個內部類別)

@SpringBootApplication
@RestController
public class UiApplication {

  ...

  @Configuration
  @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
  protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
      http
        .httpBasic()
      .and()
        .authorizeRequests()
          .antMatchers("/index.html", "/home.html", "/login.html", "/").permitAll()
          .anyRequest().authenticated();
    }
  }

}

這是一個標準的 Spring Boot 應用程式,具有 Spring Security 客製化功能,僅允許匿名存取靜態 (HTML) 資源 (CSS 和 JS 資源預設已可存取)。 HTML 資源需要對匿名使用者可用,而不僅僅是被 Spring Security 忽略,原因稍後會說明。

CSRF 保護

這個應用程式幾乎可以使用了,但是如果您嘗試執行它,您會發現登入表單無法運作。查看瀏覽器中的回應,您就會明白原因

POST /login HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded

username=user&password=password

HTTP/1.1 403 Forbidden
Set-Cookie: JSESSIONID=3941352C51ABB941781E1DF312DA474E; Path=/; HttpOnly
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
...

{"timestamp":1420467113764,"status":403,"error":"Forbidden","message":"Expected CSRF token not found. Has your session expired?","path":"/login"}

這是好事,因為這表示 Spring Security 內建的 CSRF 保護機制已經啟動,以防止我們自找麻煩。它所需要的只是一個名為 "X-CSRF" 的標頭中傳送的 Token。 CSRF Token 的值可以從載入首頁的初始請求中的 HttpRequest 屬性在伺服器端取得。為了將它傳送到客戶端,我們可以透過伺服器上的動態 HTML 頁面來呈現它,或透過自定義端點公開它,或者我們可以將它作為 Cookie 發送。最後一個選擇是最好的,因為 Angular 具有基於 Cookie 的 對 CSRF 的內建支援 (它稱之為 "XSRF")。

因此,我們在伺服器上所需要做的就是一個自訂的 Filter,它會發送 Cookie。 Angular 希望 Cookie 名稱為 "XSRF-TOKEN",而 Spring Security 將它作為請求屬性提供,因此我們只需要將該值從請求屬性傳輸到 Cookie

public class CsrfHeaderFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(HttpServletRequest request,
      HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class
        .getName());
    if (csrf != null) {
      Cookie cookie = WebUtils.getCookie(request, "XSRF-TOKEN");
      String token = csrf.getToken();
      if (cookie==null || token!=null && !token.equals(cookie.getValue())) {
        cookie = new Cookie("XSRF-TOKEN", token);
        cookie.setPath("/");
        response.addCookie(cookie);
      }
    }
    filterChain.doFilter(request, response);
  }
}

為了完成工作並使其完全通用,我們應該小心地將 Cookie 路徑設定為應用程式的上下文路徑 (而不是硬編碼為 "/"),但這對於我們正在開發的應用程式來說已經足夠了。

我們需要在應用程式中的某個地方安裝這個 Filter,並且它需要在 Spring Security CsrfFilter 之後執行,以便請求屬性可用。由於我們有 Spring Security 保護這些資源,因此沒有比 Spring Security Filter 鏈更好的位置了,例如,擴展上面的 SecurityConfiguration

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .httpBasic().and()
      .authorizeRequests()
        .antMatchers("/index.html", "/home.html", "/login.html", "/").permitAll().anyRequest()
        .authenticated().and()
      .addFilterAfter(new CsrfHeaderFilter(), CsrfFilter.class);
  }
}

我們在伺服器上必須做的另一件事是告訴 Spring Security 期望以 Angular 想要傳回的格式 (一個名為 "X-XRSF-TOKEN" 的標頭,而不是預設的 "X-CSRF-TOKEN") 取得 CSRF Token。我們透過自訂 CSRF Filter 來做到這一點

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
    .httpBasic().and()
    ...
    .csrf().csrfTokenRepository(csrfTokenRepository());
}

private CsrfTokenRepository csrfTokenRepository() {
  HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
  repository.setHeaderName("X-XSRF-TOKEN");
  return repository;
}

完成這些更改後,我們不需要在客戶端執行任何操作,並且登入表單現在可以運作了。

登出

這個應用程式在功能上幾乎已經完成。我們需要做的最後一件事是實作我們在首頁中草擬的登出功能。以下是導覽列的外觀提醒

<div ng-controller="navigation" class="container">
  <ul class="nav nav-pills" role="tablist">
    <li class="active"><a href="#/">home</a></li>
    <li><a href="#/login">login</a></li>
    <li ng-show="authenticated"><a href="" ng-click="logout()">logout</a></li>
  </ul>
</div>

如果使用者已驗證,我們會顯示 "logout" 連結並將其連結到 "navigation" 控制器中的 logout() 函式。該函式的實作相對簡單

angular.module('hello', [ 'ngRoute' ]). 
// ...
.controller('navigation', function(...) {

...

$scope.logout = function() {
  $http.post('logout', {}).success(function() {
    $rootScope.authenticated = false;
    $location.path("/");
  }).error(function(data) {
    $rootScope.authenticated = false;
  });
}

...

});

它向 "/logout" 發送一個 HTTP POST,我們現在需要在伺服器上實作它。這很簡單

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
    ...
  .and()
    .logout()
    ...
  ;
}

(我們只是將 .logout() 新增到 HttpSecurity 設定產生器)。

它是如何運作的?

如果您使用一些開發人員工具 (通常 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 /user 401 未經授權
GET /home.html 200 首頁
GET /resource 401 未經授權
GET /login.html 200 Angular 登入表單 Partial
GET /user 401 未經授權
GET /user 200 傳送憑證並取得 JSON
GET /resource 200 JSON 歡迎訊息

上面標記為 "ignored" 的回應是 Angular 在 XHR 呼叫中接收到的 HTML 回應,由於我們沒有處理這些資料,因此 HTML 會被丟棄。 在 "/user" 資源的情況下,我們確實會尋找已驗證的使用者,但由於第一次呼叫中沒有該使用者,因此該回應會被丟棄。

仔細查看請求,您會發現它們都帶有 Cookie。 如果您從一個乾淨的瀏覽器 (例如 Chrome 中的無痕模式) 開始,則第一個請求沒有任何 Cookie 發送到伺服器,但是伺服器會發送回 "Set-Cookie" 用於 "JSESSIONID" (一般的 HttpSession) 和 "X-XSRF-TOKEN" (我們上面設定的 CRSF Cookie)。 後續的請求都具有這些 Cookie,並且它們非常重要:沒有它們,應用程式將無法運作,並且它們提供了一些非常基本的安全功能 (身份驗證和 CSRF 保護)。 使用者驗證後 (在 POST 之後),Cookie 的值會更改,這是另一個重要的安全功能 (防止 Session Fixation 攻擊)。

注意:僅僅依靠將 Cookie 發送回伺服器來進行 CSRF 保護是不夠的,因為即使您不在從您的應用程式載入的頁面中 (跨站腳本攻擊,也稱為 XSS),瀏覽器也會自動發送它。 標頭不會自動發送,因此來源受到控制。 您可能會看到在我們的應用程式中,CSRF Token 作為 Cookie 發送到客戶端,因此我們會看到它被瀏覽器自動發送回來,但是標頭提供了保護。

求助,我的應用程式將如何擴展?

「但是等等...」您會說,「在單頁應用程式中使用 Session 狀態是不是非常糟糕?」 這個問題的答案必須是「大部分」,因為使用 Session 進行身份驗證和 CSRF 保護絕對是一件好事。 該狀態必須儲存在某個地方,如果將它從 Session 中取出,則必須將其放置在其他地方,並在伺服器和客戶端上手動管理它。 這只會增加程式碼和維護成本,並且通常會重新發明一個非常好的輪子。

「但是,但是...」您會回應,「我現在該如何水平擴展我的應用程式?」 這是您在上面提出的「真正」問題,但它往往會縮短為「Session 狀態很糟糕,我必須是無狀態的」。 別驚慌。 這裡要採用的主要觀點是,安全性有狀態的。 您不能擁有安全的無狀態應用程式。 那麼您打算在哪裡儲存狀態? 這就是全部。Rob WinchSpring Exchange 2014 上發表了一個非常有幫助且有見地的演講,解釋了對狀態的需求 (以及它的普遍性 - TCP 和 SSL 都是有狀態的,因此無論您是否知道,您的系統都是有狀態的),如果您想更深入地研究這個主題,這可能值得一看。

好消息是您有選擇。最簡單的選擇是將 Session 資料儲存在記憶體中,並依賴負載平衡器的黏性 Session (sticky sessions) 將來自同一個 Session 的請求導回相同的 JVM (它們都以某種方式支援)。這足以讓您起步,並且適用於非常大量的使用案例。另一個選擇是在應用程式的實例之間共享 Session 資料。只要您嚴格限制只儲存安全性資料,它就會很小而且不常變更 (只有在使用者登入和登出,或他們的 Session 超時時才會變更),因此不應該有任何重大的基礎架構問題。使用 Spring Session 也很容易做到。我們將在本系列的下一篇文章中使用 Spring Session,所以這裡沒有必要詳細介紹如何設定,但它實際上只需要幾行程式碼和一個 Redis 伺服器,而且 Redis 伺服器速度非常快。

提示:另一種設定共享 Session 狀態的簡單方法是將您的應用程式作為 WAR 檔案部署到 Cloud Foundry Pivotal Web Services,並將其繫結到 Redis 服務。

但是,我的自訂 Token 實作呢?(它是無狀態的,你看!)

如果這是您對上一節的回應,請重新閱讀一遍,因為您可能第一次沒有理解。如果您將 Token 儲存在某個地方,它可能不是無狀態的,但即使您沒有 (例如,您使用 JWT 編碼的 Token),您將如何提供 CSRF 保護?這很重要。這裡有一個經驗法則 (歸功於 Rob Winch):如果您的應用程式或 API 將被瀏覽器訪問,您需要 CSRF 保護。並不是說您不能在沒有 Session 的情況下做到這一點,只是您必須自己編寫所有程式碼,而且這樣做的意義何在,因為它已經在 HttpSession 之上實現並且運作良好 (而 HttpSession 又是您正在使用的容器的一部分,並且從一開始就被納入規格中)?即使您決定不需要 CSRF,並且擁有一個完全 "無狀態" (非基於 Session) 的 Token 實作,您仍然必須在客戶端編寫額外的程式碼來使用它,而您可以直接委派給瀏覽器和伺服器自己的內建功能:瀏覽器總是發送 Cookie,並且伺服器總是擁有 Session (除非您關閉它)。這些程式碼不是業務邏輯,它們不會為您賺錢,它們只是一種開銷,所以更糟糕的是,它會花您的錢。

結論

我們現在的應用程式已經接近使用者在實際環境中對 "真實" 應用程式的期望,並且它可以作為一個範本,用來構建出具有該架構 (具有靜態內容和 JSON 資源的單一伺服器) 的更豐富功能的應用程式。我們正在使用 HttpSession 來儲存安全性資料,依賴我們的客戶端來尊重和使用我們發送給他們的 Cookie,我們對此感到滿意,因為它可以讓我們專注於我們自己的業務領域。在下一篇文章中,我們將架構擴展到一個單獨的身份驗證和 UI 伺服器,以及一個用於 JSON 的獨立資源伺服器。這顯然可以很容易地推廣到多個資源伺服器。我們還將把 Spring Session 引入到堆疊中,並展示如何使用它來共享身份驗證資料。

取得 Spring 電子報

隨時關注 Spring 電子報

訂閱

領先一步

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

了解更多

取得支援

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

了解更多

即將舉行的活動

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

查看全部