模块化客户端: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 社区所有即将举行的活动。

查看所有