客户端模块化: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) {
    ...
  })
});

有一些“配置”和 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(浏览器窗口中看到的)中会转换为“/#/login”。这样做的目的是确保通过根路径“/”加载的 index.html 中的 JavaScript 在所有路由上都保持活动状态。片段命名方式对用户来说有点不熟悉,有时使用“自然”路由会更方便,即 URL 路径与 Angular 路由声明相同,例如,对于“/login”路由,URL 也显示为“/login”。如果只有静态资源,您无法做到这一点,因为 index.html 只能通过一种方式加载,但如果您在技术栈中有一些活跃的组件(代理或一些服务器端逻辑),则可以通过从所有 Angular 路由加载 index.html 来使其工作。

在本系列中,您使用的是 Spring Boot,因此自然会有服务器端逻辑,使用一个简单的 Spring MVC 控制器,您可以使应用程序中的路由“自然化”。您只需要一种在服务器中列举 Angular 路由的方法。在这里,我们选择通过命名约定来实现:所有不包含点(并且尚未明确映射)的路径都是 Angular 路由,应该转发到主页:

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

这个方法只需放在 Spring 应用程序中的某个 @Controller(而不是 @RestController)中。我们使用“转发”(而不是“重定向”),这样浏览器会记住“真实”的路由,用户在 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 成功是因为我们仍然保留了原始“单页”应用程序中的 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),然后访问 http://localhost:8080 查看结果。

提醒:请务必清除浏览器的 cookie 和 HTTP 基本认证凭据缓存。在 Chrome 中,最好的方法是打开一个新无痕窗口。

结论

在本文中,我们探讨了如何模块化一个 Angular 应用程序(以系列文章的第二部分中的应用程序为起点),如何使其重定向到登录页面,以及如何使用用户可以轻松输入或书签的“自然”路由。我们回顾了本系列的最后几篇文章,更专注于客户端代码,并暂时放弃了我们在第三到第六部分中构建的分布式架构。但这并不意味着这里的更改无法应用于其他应用程序(实际上非常简单)——我们只是为了在学习客户端操作时简化服务器端代码。尽管如此,我们确实使用或简要讨论了一些服务器端功能(例如在 Spring MVC 中使用“forward”视图来实现“自然”路由),因此我们继续了 Angular 和 Spring 协同工作的主题,并展示了它们通过一些小的调整就可以很好地配合。

系列下一部分将介绍如何测试客户端代码。

订阅 Spring 新闻简报

通过 Spring 新闻简报保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速发展。

了解更多

获取支持

Tanzu Spring 通过一项简单的订阅,为 OpenJDK™、Spring 和 Apache Tomcat® 提供支持和二进制文件。

了解更多

近期活动

查看 Spring 社区所有近期活动。

查看全部