模組化用戶端:Angular JS 與 Spring Security 第七部分

工程 | Dave Syer | 2015 年 5 月 13 日 | ...

在本文中,我們將繼續探討如何在「單頁應用程式」中使用 Spring SecurityAngular JS。 在此,我們將展示如何模組化用戶端程式碼,以及如何使用「友善」的 URL 路徑,而無需 Angular 預設使用但大多數使用者不喜歡的片段標記(例如 "/#/login")。 這是系列文章的第七篇,您可以閱讀第一篇文章來了解應用程式的基本建構區塊,或是從頭開始建構,或者直接前往 Github 中的原始程式碼。 我們將能夠整理本系列其餘部分 JavaScript 程式碼中的許多未完成事項,同時展示它如何與由 Spring Security 和 Spring Boot 建構的後端伺服器緊密結合。

拆解應用程式

到目前為止,在本系列中我們使用的範例應用程式非常簡單,我們可以將整個應用程式放在單個 JavaScript 原始檔中。 即使較大的應用程式一開始是這樣,但最終也不會是這樣,因此為了在範例中模擬現實生活,我們將拆解應用程式。 一個好的起點是採用第二部分中的「單一」應用程式,並查看其原始程式碼中的結構。 以下是靜態內容的目錄清單(不包括伺服器端的 "application.yml")

static/
 js/
   hello.js
 home.html
 login.html
 index.html

這存在一些問題。 一個問題很明顯:所有 JavaScript 都在一個檔案中(hello.js)。 另一個問題則比較細微:我們有用於應用程式中檢視的 HTML「partials」("login.html" 和 "home.html"),但它們都在一個平面結構中,並且未與使用它們的控制器程式碼相關聯。

讓我們仔細看看 JavaScript,我們將看到 Angular 讓我們可以輕鬆地將其分解成更易於管理的部分

angular.module('hello', [ 'ngRoute' ]).config(

  function($routeProvider, $httpProvider) {

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

    ...

}).controller('navigation',
    function($rootScope, $scope, $http, $location, $route) {
      ...
}).controller('home', function($scope, $http) {
    ...
  })
});

有一些 "config",並且有 2 個控制器("home" 和 "navigation"),並且控制器似乎可以很好地對應到 partials(分別是 "home.html" 和 "login.html")。 因此,讓我們將它們分解成這些部分

static/
  js/
    home/
      home.js
      home.html
    navigation/
      navigation.js
      login.html
    hello.js
  index.html

控制器定義已移動到它們自己的模組中,與它們需要運作的 HTML 並排 - 漂亮且模組化。 如果我們需要圖片或自訂樣式表,我們也會對它們做同樣的事情。

注意:所有用戶端程式碼都在一個目錄 "js" 下(除了 index.html,因為這是一個「歡迎」頁面,並且會自動從「static」目錄載入)。 這是故意的,因為它可以輕鬆地將單個 Spring Security 存取規則應用於所有靜態資源。 這些資源都是不安全的(因為在 Spring Boot 應用程式中預設 /js/** 是不安全的),但您可能需要其他應用程式的其他規則,在這種情況下,您可以選擇不同的路徑。

例如,這是 home.js

angular.module('home', []).controller('home', function($scope, $http) {
	$http.get('/user/').success(function(data) {
		$scope.user = data.name;
	});
});

這是新的 hello.js

angular
    .module('hello', [ 'ngRoute', 'home', 'navigation' ])
    .config(

        function($routeProvider, $httpProvider) {

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

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

        });

請注意,"hello" 模組依賴於其他兩個模組,方法是在初始宣告中列出它們以及 ngRoute。 為了使其正常運作,您只需要在 index.html 中以正確的順序載入模組定義

...
<script src="js/angular-bootstrap.js" type="text/javascript"></script>
<script src="js/home/home.js" type="text/javascript"></script>
<script src="js/navigation/navigation.js" type="text/javascript"></script>
<script src="js/hello.js" type="text/javascript"></script>
...

這是 Angular JS 依賴管理系統的運作方式。 其他框架具有類似(且可以說更優越)的功能。 此外,在較大的應用程式中,您可以使用建置時步驟將所有 JavaScript 捆綁在一起,以便瀏覽器可以有效率地載入它,但這幾乎是一個品味問題。

使用「自然」路由

預設情況下,Angular $routeProvider 使用 URL 路徑中的片段定位器,例如,登入頁面在 hello.js 中指定為路由 "/login",這會在實際 URL(您在瀏覽器視窗中看到的 URL)中轉換為 "/#/login"。 這是為了使透過根路徑 "/" 載入的 index.html 中的 JavaScript 在所有路由上保持作用中。 片段命名對使用者來說有點陌生,並且有時使用「自然」路由會更方便,其中 URL 路徑與 Angular 路由宣告相同,例如 "/login" 對應於 "/login"。 如果您只有靜態資源,則無法執行此操作,因為 index.html 只能透過一種方式載入,但是如果您在堆疊中有一些活動元件(Proxy 或一些伺服器端邏輯),則可以透過從所有 Angular 路由載入 index.html 來安排使其運作。

在本系列中,您有 Spring Boot,因此您當然有伺服器端邏輯,並且使用簡單的 Spring MVC 控制器,您可以使應用程式中的路由自然化。 您只需要一種方法來列舉伺服器中的 Angular 路由。 在此,我們選擇透過命名慣例來執行此操作:所有不包含句點(並且尚未明確對應)的路徑都是 Angular 路由,並且應轉發到首頁

@RequestMapping(value = "/{[path:[^\\.]*}")
public String redirect() {
  return "forward:/";
}

此方法只需要位於 Spring 應用程式中的某個 @Controller(而不是 @RestController)中。 我們使用「forward」(而不是「redirect」),以便瀏覽器記住「真實」路由,而這就是使用者在 URL 中看到的內容。 這也意味著 Spring Security 中任何圍繞驗證的已儲存請求機制都可以立即運作,儘管我們不會在此應用程式中利用這一點。

注意:範例程式碼在 github 中有一個額外的路由,因此您可以看到一個稍微更完整的功能,因此希望是更真實的應用程式("/home" 和 "/message" 是具有稍微不同檢視的不同模組)。

若要使用「自然」路由完成應用程式,您需要告訴 Angular 這件事。 有兩個步驟。 首先,在 hello.js 中,您新增一行到 config 函式,以在 $locationProvider 中設定「HTML5 模式」

angular.module('hello', [ 'ngRoute', 'home', 'navigation' ]).config(

  function($locationProvider, $routeProvider, $httpProvider) {

    $locationProvider.html5Mode(true);
    ...
});

與此相關聯的是,您需要在 index.html 中 HTML 的標頭中新增一個 <base/> 元素,並且您需要變更選單列中的連結以移除片段 ("#")

<html>
<head>
<base href="/" />
...
</head>
<body ng-app="hello" ng-cloak class="ng-cloak">
	<div ng-controller="navigation" class="container">
		<ul class="nav nav-pills" role="tablist">
			<li><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>
...
</html>

Angular 使用 <base/> 元素來錨定路由並寫入顯示在瀏覽器中的 URL。 您正在 Spring Boot 應用程式中執行,因此預設設定是從根路徑 "/"(在 8080 埠上)提供服務。 如果您需要能夠使用相同的應用程式從不同的根路徑提供服務,則需要使用伺服器端範本將該路徑呈現到 HTML 中(許多人更喜歡對單頁應用程式使用靜態資源,因此他們只能使用靜態根路徑)。

提取驗證考量

當您在上面模組化應用程式時,您應該發現程式碼只需將其分割成模組即可運作,但是有一個小問題是我們仍然使用 $rootScope 在控制器之間共用狀態。 對於這樣一個小型應用程式來說,這沒有什麼可怕的錯誤,而且它讓我們很快地獲得了一個體面的原型來玩,所以讓我們不要太難過,但是現在我們可以藉此機會將所有驗證考量提取到一個單獨的模組中。 在 Angular 術語中,您需要的是一個「服務」,因此在您的 "home" 和 "navigation" 模組旁邊建立一個新的模組 ("auth")

static/
  js/
    auth/
      auth.js
    home/
      home.js
      home.html
    navigation/
      navigation.js
      login.html
    hello.js
  index.html

在撰寫 auth.js 程式碼之前,我們可以預期其他模組的變更。首先,在 navigation.js 中,您應該讓 "navigation" 模組依賴新的 "auth" 模組,並將 "auth" 服務注入到控制器中(當然,不再需要 $rootScope)。

angular.module('navigation', ['auth']).controller(
		'navigation',

		function($scope, auth) {

			$scope.credentials = {};

			$scope.authenticated = function() {
				return auth.authenticated;
			}

			$scope.login = function() {
				auth.authenticate($scope.credentials, function(authenticated) {
					if (authenticated) {
						console.log("Login succeeded")
						$scope.error = false;
					} else {
						console.log("Login failed")
						$scope.error = true;
					}
				})
			};

			$scope.logout = function() {
              auth.clear();
            }

		});

它與舊的控制器沒有太大的不同(仍然需要用於使用者操作、登入和登出的函式,以及一個用於保存登入憑證的物件),但它已經將實作抽象化到新的 "auth" 服務。 "auth" 服務將需要一個 authenticate() 函式來支援 login(),以及一個 clear() 函式來支援 logout()。它還有一個 authenticated 標誌,取代了舊控制器中的 $rootScope.authenticated。 我們在一個與控制器 $scope 相關聯的同名函式中使用 authenticated 標誌,以便 Angular 持續檢查其值,並在使用者登入時更新 UI。

假設您想要使 "auth" 模組可重複使用,因此您不希望在其中有任何硬式編碼的路徑。 這不是問題,但您需要在 hello.js 模組中初始化或配置路徑,因此您可以新增一個 run() 函式。

angular
  .module('hello', [ 'ngRoute', 'auth', 'home', 'navigation' ])
  .config(
	...
  }).run(function(auth) {

    auth.init('/', '/login', '/logout');

});

run() 函式可以呼叫 "hello" 所依賴的任何模組,在本例中,注入一個 auth 服務,並使用首頁、登入和登出端點的路徑來初始化它。

現在您需要在 index.html 中載入 "auth" 模組,以及其他模組(並且在 "login" 模組之前,因為它依賴於 "auth")。

...
<script src="js/auth/auth.js" type="text/javascript"></script>
...
<script src="js/hello.js" type="text/javascript"></script>
...

然後,您最終可以編寫您在上面草擬的三個函式的程式碼(authenticate()clear()init())。 這是大部分程式碼:

angular.module('auth', []).factory(
    'auth',

    function($http, $location) {

      var auth = {

        authenticated : false,

        loginPath : '/login',
        logoutPath : '/logout',
        homePath : '/',

        authenticate : function(credentials, callback) {

          var headers = credentials && credentials.username ? {
            authorization : "Basic "
                + btoa(credentials.username + ":"
                    + credentials.password)
          } : {};

          $http.get('user', {
            headers : headers
          }).success(function(data) {
            if (data.name) {
              auth.authenticated = true;
            } else {
              auth.authenticated = false;
            }
            $location.path(auth.homePath);
            callback && callback(auth.authenticated);
          }).error(function() {
            auth.authenticated = false;
            callback && callback(false);
          });

        },
        
        clear : function() { ... },
        
        init : function(homePath, loginPath, logoutPath) { ... }

      };

      return auth;

    });

"auth" 模組為 auth 服務建立一個工廠(您已經將其注入到 "navigation" 控制器中,例如)。 工廠只是一個返回物件(auth)的函式,並且該物件必須具有我們在上面預期的三個函式和標誌。 上面,我們展示了 authenticate() 函式的實作,它與 "navigation" 控制器中的舊函式基本相同,它呼叫 "/user" 上的後端資源,設定一個 authenticated 標誌,並使用該標誌的值呼叫一個可選的回呼。 如果成功,它還使用 $location 服務將使用者傳送到 homePath(我們將在一分鐘內改進這一點)。

這是一個 init() 函式的基本實作,它只是設定您不想在 "auth" 模組中硬式編碼的各種路徑。

init : function(homePath, loginPath, logoutPath) {
  auth.homePath = homePath;
  auth.loginPath = loginPath;
  auth.logoutPath = logoutPath;
}

接下來是 clear() 函式的實作,但它相當簡單。

clear : function() {
  auth.authenticated = false;
  $location.path(auth.loginPath);
  $http.post(auth.logoutPath, {});
}

它取消設定 authenticated 標誌,將使用者送回登入頁面,然後向登出路徑發送 HTTP POST 請求。 POST 成功,因為我們仍然具有來自原始 "single" 應用程式的 CSRF 保護功能。 如果您看到 403,請查看錯誤訊息和伺服器日誌,然後檢查您是否已安裝該過濾器並且正在發送 XSRF cookie。

最後一個變更是對 index.html 的變更,以便在使用者未通過身份驗證時隱藏 "logout" 連結。

<html>
...
<body ng-app="hello" ng-cloak class="ng-cloak">
  <div ng-controller="navigation" class="container">
    <ul class="nav nav-pills" role="tablist">
          ...
      <li ng-show="authenticated()"><a href="" ng-click="logout()">logout</a></li>
    </ul>
  </div>
...
</html>

您只需要將標誌 authenticated 轉換為函式呼叫 authenticated(),以便 "navigation" 控制器可以進入 "auth" 服務並找到標誌的值,因為它現在不在 $rootScope 中。

重新導向到登入頁面

到目前為止,我們實作首頁的方式是,當使用者通過身份驗證時,它可以顯示一些內容(它只是邀請他們登入)。 有些應用程式是這樣工作的,有些則不是。 有些提供不同的使用者體驗,使用者在通過身份驗證之前永遠看不到登入頁面以外的任何內容,因此讓我們看看如何將我們的應用程式轉換為這種模式。

使用登入頁面隱藏所有內容是一個典型的跨領域問題:您不希望將顯示登入頁面的所有邏輯都卡在所有 UI 模組中(它將在任何地方重複,使程式碼更難以閱讀和更難以維護)。 Spring Security 完全是關於伺服器中的跨領域問題,因為它建立在 Filters 和 AOP 攔截器之上。 不幸的是,這對 Single Page Application 沒有太大幫助,但幸運的是,Angular 也有一些功能可以輕鬆地實現我們想要的模式。 在這裡幫助我們的是,您可以為 "路由變更" 安裝一個監聽器,因此每次使用者移動到新路由時(即,點擊功能表列或任何東西),或首次載入頁面時,您都可以檢查路由,如果需要,可以變更它。

若要安裝監聽器,您可以在 auth.init() 函式中編寫一小段額外的程式碼(因為它已經安排在主 "hello" 模組載入時執行)。

angular.module('auth', []).factory(
    'auth',

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

      var auth = {
      
        ...

        init : function(homePath, loginPath, logoutPath) {
          ...
          $rootScope.$on('$routeChangeStart', function() {
            enter();
          });
        }

      };

      return auth;

    });

我們註冊了一個簡單的監聽器,它只是委派給一個新的 enter() 函式,因此現在您也需要在 "auth" 模組工廠函式中實作它(它可以在其中存取工廠物件本身)。

enter = function() {
  if ($location.path() != auth.loginPath) {
    auth.path = $location.path();
    if (!auth.authenticated) {
      $location.path(auth.loginPath);
    }
  }          
}

邏輯很簡單:如果路徑剛變更為登入頁面以外的其他內容,則記錄路徑值,然後,如果使用者未通過身份驗證,則轉到登入頁面。 我們儲存路徑值的原因是,以便我們可以在成功進行身份驗證後返回到該路徑(Spring Security 在伺服器端具有此功能,並且對使用者來說非常好)。 您可以透過將一些程式碼新增至成功處理常式中的 authenticate() 函式來完成此操作。

authenticate : function(credentials, callback) {
 ...
 $http.get('user', {
  headers : headers
  }).success(function(data) {
      ...
      $location.path(auth.path==auth.loginPath ? auth.homePath : auth.path);
  }).error(...);

},

成功進行身份驗證後,我們只需將位置設定為首頁或最近選取的路徑(只要它不是登入頁面)。

還有一個最終的變更可以使使用者體驗更一致:我們希望在應用程式首次啟動時顯示登入頁面,而不是首頁。 您已經在 authenticate() 函式中具有該邏輯(重新導向到登入頁面),因此您需要做的就是在 init() 函式中新增一些程式碼,以便使用空的憑證進行身份驗證(除非使用者已經有 cookie,否則會失敗)。

init : function(homePath, loginPath, logoutPath) {
  ...
  auth.authenticate({}, function(authenticated) {
    if (authenticated) {
      $location.path(auth.path);
    }
  });
  ...
}

只要 auth.path 使用 $location.path() 初始化,即使使用者在瀏覽器中顯式輸入路由(即,不想先載入首頁),這也有效。

啟動應用程式(使用您的 IDE 和 main() 方法,或在命令列中使用 mvn spring-boot:run)並在 http://localhost:8080 訪問它以查看結果。

提醒:請務必清除瀏覽器快取中的 cookie 和 HTTP Basic 憑證。 在 Chrome 中,最好的方法是開啟一個新的無痕視窗。

結論

在本文中,我們已經了解如何模組化 Angular 應用程式(以 系列的第二篇文章中的應用程式作為起點,該系列位於 該系列的第一篇文章),如何將其重新導向到登入頁面,以及如何使用可以由使用者輕鬆輸入或加入書籤的 "自然" 路由。 我們從該系列的最後幾篇文章中退後一步,更多地關注客戶端程式碼,並暫時放棄了我們在第 III-VI 部分中構建的分散式架構。 這並不意味著此處的變更不能應用於其他那些應用程式(實際上它非常簡單) - 這只是為了簡化伺服器端程式碼,同時我們正在學習如何在客戶端上做事。 但是,一些我們使用或簡要討論的伺服器端功能(例如,在 Spring MVC 中使用 "forward" 視圖來啟用 "自然" 路由),因此我們延續了 Angular 和 Spring 協同工作的主題,並表明他們透過這裡和那裡的小調整做得很好。

該系列的下一部分是關於測試客戶端程式碼。

取得 Spring 電子報

與 Spring 電子報保持聯繫

訂閱

領先一步

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

了解更多

取得支援

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

了解更多

即將到來的活動

查看 Spring 社群中所有即將到來的活動。

查看全部