客户端模块化:Angular JS 和 Spring Security 第七部分

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

在这篇文章中,我们继续讨论如何在“单页应用程序”中将Spring SecurityAngular JS一起使用。在这里,我们将展示如何模块化客户端代码,以及如何在没有片段表示法(例如“/#/login”,Angular 默认使用,但大多数用户不喜欢)的情况下使用“友好”的 URL 路径。这是本系列文章的第七篇,您可以了解应用程序的基本构建块,或者通过阅读第一篇文章从头开始构建它,或者您可以直接转到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”(您在浏览器窗口中看到的 URL)。这样,通过根路径“/”加载的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)中。我们使用“转发”(而不是“重定向”),以便浏览器记住“真实”路由,这就是用户在 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。我们在一个名为authenticated的函数中使用此标志,该函数附加到控制器的$scope上,以便 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 拦截器之上。不幸的是,这在单页应用程序中对我们帮助不大,但幸运的是,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://127.0.0.1:8080 以查看结果。

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

结论

在本文中,我们了解了如何将 Angular 应用程序模块化(以 系列文章第二部分 中的应用程序为起点),如何使其重定向到登录页面,以及如何使用用户可以轻松键入或添加书签的 "自然" 路由。我们从该系列的最后几篇文章中退后一步,更多地关注客户端代码,并暂时放弃了我们在第三到第六部分中构建的分布式架构。这并不意味着这里的更改不能应用于其他应用程序(实际上这相当简单)——只是为了在学习如何在客户端执行操作时简化服务器端代码。不过,我们确实使用或简要讨论了一些服务器端功能(例如,在 Spring MVC 中使用 "forward" 视图来启用 "自然" 路由),因此我们继续关注 Angular 和 Spring 的协同工作,并表明它们在进行一些小的调整后确实可以很好地协同工作。

该系列的下一部分 是关于测试客户端代码。

获取 Spring 时事通讯

与 Spring 时事通讯保持联系

订阅

领先一步

VMware 提供培训和认证,以加速您的进步。

了解更多

获得支持

Tanzu Spring 在一个简单的订阅中提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区中所有即将举行的活动。

查看全部