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

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

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

在本文中,我們繼續討論如何在“單頁應用”中使用Spring SecurityAngular JS。本文將展示如何使用 Angular JS 透過表單進行使用者身份驗證並獲取安全資源以在 UI 中渲染。這是系列文章的第二篇,您可以透過閱讀第一篇文章來了解應用程式的基本構建塊或從頭開始構建,或者直接檢視Github 中的原始碼。在第一篇文章中,我們構建了一個使用 HTTP 基本身份驗證來保護後端資源的簡單應用程式。在本文中,我們將新增一個登入表單,讓使用者控制是否進行身份驗證,並修復第一次迭代中的問題(主要是缺少 CSRF 保護)。

提醒:如果您正在使用示例應用程式閱讀本文,請務必清除瀏覽器的 cookie 和 HTTP 基本憑據快取。在 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 將能識別它們。

  • 所有內容將作為“區域性檢視”(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 設定指向“/”(“home”控制器)和“/login”(“login”控制器)的連結。“templateUrls”是從路由根目錄(即“/”)到“區域性”檢視的相對路徑,這些檢視將用於渲染由每個控制器建立的模型。

自定義的“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>

這是一個非常標準的登入表單,包含用於輸入使用者名稱和密碼的兩個輸入框以及一個透過ng-submit提交表單的按鈕。表單標籤上不需要 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”的頭部中傳送一個令牌。CSRF 令牌的值在伺服器端透過載入主頁的初始請求中的 HttpRequest 屬性可用。要將其傳送到客戶端,我們可以使用伺服器上的動態 HTML 頁面進行渲染,或者透過自定義端點暴露,或者將其作為 cookie 傳送。最後一個選擇是最好的,因為 Angular 內建了基於 cookie 的 CSRF 支援(它稱之為“XSRF”)。

所以我們在伺服器上只需要一個自定義過濾器來發送 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 路徑設定為應用程式的上下文路徑(而不是硬編碼為“/”),但這對於我們正在開發的應用程式來說已經足夠了。

我們需要在應用程式中的某個地方安裝此過濾器,並且它需要放在 Spring Security CsrfFilter 之後,以便請求屬性可用。由於我們有 Spring Security 保護這些資源,沒有比 Spring Security 過濾器鏈更好的地方了,例如擴充套件上面的 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 期望 CSRF 令牌的格式與 Angular 希望傳送回來的格式一致(一個名為“X-XRSF-TOKEN”的頭部,而不是預設的“X-CSRF-TOKEN”)。我們透過自定義 CSRF 過濾器來完成此操作

@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>

如果使用者已認證,則顯示“登出”連結並將其連線到“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 登入表單區域性檢視
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 的值會發生變化,這是另一個重要的安全功能(防止會話固定攻擊)。

注意:僅依靠將 cookie 傳送回伺服器不足以提供 CSRF 保護,因為即使你不在從應用程式載入的頁面中,瀏覽器也會自動傳送 cookie(這是跨站指令碼攻擊,也稱為XSS)。頭部不會自動傳送,因此來源是可控的。你可能會看到在我們的應用程式中,CSRF 令牌作為 cookie 傳送給客戶端,因此瀏覽器會自動將其傳送回去,但提供保護的是頭部資訊。

求助,我的應用程式如何擴充套件?

“等等……”你可能會說,“在單頁應用中使用會話狀態不是非常糟糕嗎?”這個問題的答案“大部分”是肯定的,因為使用會話進行身份驗證和 CSRF 保護絕對是一件好事。這個狀態必須儲存在某個地方,如果你不使用會話,你就必須將它放在別處,並在伺服器和客戶端自己手動管理。這隻會增加程式碼量,可能還會增加維護工作,基本上是重複造輪子。

“但是,但是……”你可能會反駁說,“我的應用程式現在如何橫向擴充套件?”這是你前面提出的“真正”問題,但它往往被簡化為“會話狀態不好,我必須是無狀態的”。不要驚慌。這裡需要理解的重點是安全性有狀態的。你不能有一個安全的、無狀態的應用程式。所以你要把狀態儲存在哪裡呢?事情就是這麼簡單。Rob WinchSpring Exchange 2014 上做了一個非常有用且富有洞察力的演講,解釋了狀態的必要性(以及它的普遍性——TCP 和 SSL 都是有狀態的,所以無論你知不知道,你的系統都是有狀態的),如果你想深入探討這個話題,這個演講可能值得一看。

好訊息是你有選擇。最簡單的選擇是將會議資料儲存在記憶體中,並依靠負載均衡器中的粘性會話將同一會話的請求路由回同一個 JVM(它們都以某種方式支援這一點)。這足以讓你起步,並且適用於非常多的用例。另一個選擇是在應用程式的多個例項之間共享會議資料。只要你嚴格只儲存安全資料,它就很小且不經常變化(僅在使用者登入和登出或會話超時時),因此不應該出現主要的インフラストラクチャ問題。使用Spring Session也很容易實現。在本系列的下一篇文章中,我們將使用 Spring Session,所以在此無需詳細介紹如何設定它,但它實際上只需幾行程式碼和一個 Redis 伺服器,速度非常快。

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

但是,我的自定義令牌實現呢(看,它是無狀態的)?

如果你對上一節的反應是這樣,那麼請再讀一遍,也許你第一次沒有理解。如果你將令牌儲存在某個地方,它可能不是無狀態的,但即使你沒有(例如,你使用 JWT 編碼的令牌),你如何提供 CSRF 保護?這很重要。這裡有一個經驗法則(歸功於 Rob Winch):如果你的應用程式或 API 將被瀏覽器訪問,你需要 CSRF 保護。並非沒有會話就無法實現,只是你必須自己編寫所有這些程式碼,這樣做有什麼意義呢?因為它已經在 HttpSession 之上完美實現了(而 HttpSession 本身就是你使用的容器的一部分,並且從一開始就內建在規範中)。即使你決定不需要 CSRF,並且有一個完全“無狀態”(非基於會話)的令牌實現,你仍然必須在客戶端編寫額外的程式碼來消費和使用它,而你本可以委託給瀏覽器和伺服器自身的內建功能:瀏覽器總是傳送 cookie,伺服器總是擁有會話(除非你將其關閉)。這些程式碼不是業務邏輯,它們不會為你帶來任何收益,它只是額外開銷,甚至更糟,它還會花費你的錢。

結論

我們現在擁有的應用程式已經接近使用者在真實環境中期望的“實際”應用程式,並且可能可以用作構建更具功能豐富應用程式的模板,採用該架構(帶有靜態內容和 JSON 資源的單伺服器)。我們使用 HttpSession 來儲存安全資料,依靠客戶端尊重並使用我們傳送的 cookie,我們對此感到滿意,因為它使我們可以專注於自己的業務領域。在下一篇文章中,我們將把架構擴充套件到一個獨立的認證和 UI 伺服器,以及一個獨立的 JSON 資源伺服器。這顯然很容易推廣到多個資源伺服器。我們還將把 Spring Session 引入技術棧,並展示如何使用它來共享認證資料。

獲取 Spring 新聞通訊

訂閱 Spring 新聞通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速發展。

瞭解更多

獲取支援

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

瞭解更多

近期活動

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

檢視全部