模組化客戶端: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 “部分”檢視(“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) {
    ...
  })
});

有一些“配置”和 2 個控制器(“home”和“navigation”),控制器似乎與它們對應的“部分”檢視(分別為“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 中的“/#/login”。這是為了確保透過根路徑“/”載入的 index.html 中的 JavaScript 在所有路由上保持活動狀態。片段命名對使用者來說有些陌生,使用“自然”路由有時更方便,即 URL 路徑與 Angular 路由宣告相同,例如“/login”對應“/login”。如果你只有靜態資源,這是不可能的,因為 index.html 只能以一種方式載入。但如果你有棧中一些活動的元件(代理或伺服器端邏輯),那麼你就可以透過從所有 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 的頭部新增一個額外的 <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 中(許多人更喜歡為單頁應用程式保留靜態資源,所以他們只能使用靜態根路徑)。

提取身份驗證 concerns

當你上面模組化應用程式時,你應該發現程式碼只需將其拆分成模組就可以工作,但其中有一個小瑕疵,我們仍然使用 $rootScope 在控制器之間共享狀態。對於如此小的應用程式來說,這並沒有什麼大問題,它讓我們很快就得到了一個不錯的原型來玩,所以我們不必對此感到太遺憾,但現在我們可以藉此機會將所有身份驗證 concerns 提取到一個單獨的模組中。在 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 請求成功,因為我們仍然保留了原始“單檔案”應用程式的 CSRF 保護功能。如果你看到 403,請檢視錯誤訊息和伺服器日誌,然後檢查你是否已設定該過濾器以及是否正在傳送 XSRF cookie。

最後一步是修改 index.html,以便在使用者未認證時隱藏“登出”連結。

<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 攔截器之上。不幸的是,這在單頁應用程式中對我們幫助不大,但幸運的是,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),並在 https://:8080 訪問它以檢視結果。

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

結論

在本文中,我們學習瞭如何模組化 Angular 應用程式(以系列文章 《第二部分》 中的應用程式為起點)、如何使其重定向到登入頁面,以及如何使用使用者可以輕鬆輸入或收藏夾的“自然”路由。我們退後一步,回顧了系列文章的最後幾篇,更多地關注了客戶端程式碼,並暫時放棄了我們在第三至第六部分中構建的分散式架構。這並不意味著這裡的更改不能應用於那些其他應用程式(實際上它相當簡單),這只是為了在我們學習客戶端如何工作時簡化伺服器端程式碼。不過,我們確實使用或簡要討論了幾個伺服器端功能(例如,在 Spring MVC 中使用“forward”檢視來實現“自然”路由),因此我們繼續了 Angular 和 Spring 協同工作的這一主題,並表明它們透過這裡那裡的小調整可以很好地協同工作。

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

獲取 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

領先一步

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

瞭解更多

獲得支援

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案,只需一份簡單的訂閱。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視所有