登入頁面: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能夠識別的方式,一旦我們用“路由”設定好它。

  • 所有內容都將作為“部分”新增到標記為“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注入到配置函式中(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>

這是一個非常標準的登入表單,包含用於使用者名稱和密碼的2個輸入框,以及一個用於透過ng-submit提交表單的按鈕。表單標籤不需要action屬性,所以最好根本不要寫。還有一個錯誤訊息,只有當Angular的$scope包含一個error時才會顯示。表單控制元件使用ng-model在HTML和Angular控制器之間傳遞資料,在這種情況下,我們使用一個credentials物件來儲存使用者名稱和密碼。根據我們定義的路由,登入表單與“navigation”控制器關聯,該控制器目前是空的,所以讓我們去那裡填補一些空白。

身份驗證過程

為了支援我們剛剛新增的登入表單,我們需要新增更多功能。在客戶端,這些將在“navigation”控制器中實現;在伺服器端,將是Spring Security配置。

提交登入表單

要提交表單,我們需要定義login()函式,該函式已透過ng-submit在表單中引用,以及透過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具有內建的CSRF支援(它稱之為“XSRF”),基於cookie。

因此,我們在伺服器上需要的是一個自定義過濾器,它將傳送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以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()
    ...
  ;
}

(我們剛剛在HttpSecurity配置構建器中添加了.logout())。

它是如何工作的?

如果您使用一些開發者工具(通常按 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 /使用者 401 未授權
GET /home.html 200 主頁
GET /resource 401 未授權
GET /login.html 200 Angular登入表單部分
GET /使用者 401 未授權
GET /使用者 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保護)。cookie的值在使用者透過身份驗證後(POST之後)會發生變化,這是另一個重要的安全功能(防止會話固定攻擊)。

注意:僅依賴cookie傳送回伺服器來提供CSRF保護是不夠的,因為即使您不在應用程式載入的頁面中,瀏覽器也會自動傳送它(跨站指令碼攻擊,也稱為XSS)。標頭不是自動傳送的,因此源是可控的。您可能會注意到,在我們的應用程式中,CSRF令牌作為cookie傳送到客戶端,所以我們會看到瀏覽器自動傳送它,但標頭才是提供保護的關鍵。

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

“但是等等…”您可能會說,“在一個單頁應用程式中使用會話狀態是否非常糟糕?”這個問題的答案將是“大部分是”,因為它確實使用會話進行身份驗證和CSRF保護是一個優點。必須將該狀態儲存在某處,如果您將其從會話中移除,您將不得不將其儲存在其他地方並手動管理,包括伺服器和客戶端。這隻會增加程式碼量,並且可能需要更多的維護,並且通常是在重新發明一個完美的輪子。

“但是,但是…”您會回應,“我現在如何水平擴充套件我的應用程式?”這就是您上面問的“真正”問題,但它往往會被縮短為“會話狀態很糟糕,我必須無狀態”。不要驚慌。需要掌握的主要一點是,安全性有狀態的。您無法擁有一個安全的、無狀態的應用程式。那麼您將在哪裡儲存狀態?這就是全部。 Rob WinchSpring Exchange 2014上發表了一個非常有益和深刻的演講,解釋了狀態的必要性(以及它的普遍性——TCP和SSL是有狀態的,所以無論您是否知道,您的系統都是有狀態的),如果您想更深入地研究這個主題,這可能值得一看。

好訊息是您有選擇。最簡單的選擇是將會話資料儲存在記憶體中,並依靠負載平衡器中的粘性會話將來自同一會話的請求路由回同一個JVM(它們都以某種方式支援這一點)。這足以讓您起步,並且適用於非常多的用例。另一種選擇是在應用程式例項之間共享會話資料。只要您嚴格遵守並僅儲存安全資料,它就不會很大且更改不頻繁(僅在使用者登入和登出時,或會話超時時),因此不應該有重大的基礎設施問題。使用Spring Session也非常容易。我們將在本系列的下一篇文章中使用Spring Session,因此這裡無需詳細介紹如何設定它,但它實際上只需要幾行程式碼和一個Redis伺服器,而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 社群所有即將舉行的活動。

檢視所有