登录页面:Angular JS 和 Spring Security 第二部分

工程 | Dave Syer | 2015年1月12日 | ...

注意:此博客的源代码和测试仍在不断演变,但文本的更改未在此处维护。请参阅教程版本以获取最新内容。

在本文中,我们继续讨论如何在“单页应用”中使用Spring SecurityAngular JS。本文将展示如何使用 Angular JS 通过表单进行用户身份验证并获取安全资源以在 UI 中渲染。这是系列文章的第二篇,您可以通过阅读第一篇文章来了解应用程序的基本构建块或从头开始构建,或者直接查看Github 中的源代码。在第一篇文章中,我们构建了一个使用 HTTP 基本身份验证来保护后端资源的简单应用程序。在本文中,我们将添加一个登录表单,让用户控制是否进行身份验证,并修复第一次迭代中的问题(主要是缺少 CSRF 保护)。

提醒:如果您正在使用示例应用程序阅读本文,请务必清除浏览器的 cookie 和 HTTP 基本凭据缓存。在 Chrome 中,对于单个服务器,最好的方法是打开新的隐身窗口。

添加导航到主页

单页应用的核心是一个静态的“index.html”。我们已经有一个非常基础的页面,但对于这个应用程序,我们需要提供一些导航功能(登录、注销、主页),因此让我们修改它(在“src/main/resources/static”中)

<!doctype html>
<html>
<head>
<title>Hello AngularJS</title>
<link
	href="css/angular-bootstrap.css"
	rel="stylesheet">
<style type="text/css">
[ng\:cloak], [ng-cloak], .ng-cloak {
	display: none !important;
}
</style>
</head>

<body ng-app="hello" ng-cloak class="ng-cloak">
	<div ng-controller="navigation" class="container">
		<ul class="nav nav-pills" role="tablist">
			<li class="active"><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>
	<div ng-view class="container"></div>
	<script src="js/angular-bootstrap.js" type="text/javascript"></script>
	<script src="js/hello.js"></script>
</body>
</html>

实际上与原始页面差异不大。主要特点如下:

  • 导航栏有一个 <ul> 标签。所有链接都直接指向主页,但当我们使用“路由”设置好后,Angular 将能识别它们。

  • 所有内容将作为“局部视图”(partials)添加到标记为“ng-view”的 <div> 中。

  • “ng-cloak”已被移至 body 标签,因为我们希望在 Angular 确定要渲染哪些部分之前隐藏整个页面。否则,加载页面时,菜单和内容可能会在移动时“闪烁”。

  • 第一篇文章中一样,前端资源文件“angular-bootstrap.css”和“angular-bootstrap.js”是在构建时从 JAR 库生成的。

为 Angular 应用添加导航

让我们修改“hello”应用程序(在“src/main/resources/public/js/hello.js”中)以添加一些导航功能。我们可以从添加一些路由配置开始,这样主页中的链接实际上会起作用。例如:

angular.module('hello', [ 'ngRoute' ])
  .config(function($routeProvider, $httpProvider) {

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

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

  })
  .controller('home', function($scope, $http) {
    $http.get('/resource/').success(function(data) {
      $scope.greeting = data;
    })
  })
  .controller('navigation', function() {});

我们添加了对名为“ngRoute”的 Angular 模块的依赖,这使我们能够将神奇的 $routeProvider 注入到 config 函数中(Angular 通过命名约定进行依赖注入,并识别函数参数的名称)。然后,在该函数内部使用 $routeProvider 设置指向“/”(“home”控制器)和“/login”(“login”控制器)的链接。“templateUrls”是从路由根目录(即“/”)到“局部”视图的相对路径,这些视图将用于渲染由每个控制器创建的模型。

自定义的“X-Requested-With”是浏览器客户端发送的传统头部信息,它曾经是 Angular 的默认设置,但他们在 1.3.0 版本中将其移除。Spring Security 对此的响应是在 401 响应中不发送“WWW-Authenticate”头部,因此浏览器不会弹出身份验证对话框(这在我们的应用程序中是可取的,因为我们希望控制身份验证)。

为了使用“ngRoute”模块,我们需要在构建静态资源的“wro.xml”配置文件(在“src/main/wro”中)中添加一行代码

<groups xmlns="http://www.isdc.ro/wro">
  <group name="angular-bootstrap">
    ...
    <js>webjar:angularjs/1.3.8/angular-route.min.js</js>
   </group>
</groups>

问候语

旧主页中的问候语内容可以放入“home.html”中(紧挨着“src/main/resources/static”中的“index.html”)

<h1>Greeting</h1>
<div ng-show="authenticated">
	<p>The ID is {{greeting.id}}</p>
	<p>The content is {{greeting.content}}</p>
</div>
<div  ng-show="!authenticated">
	<p>Login to see your greeting</p>
</div>

由于用户现在可以选择是否登录(以前全部由浏览器控制),我们需要在 UI 中区分安全内容和非安全内容。我们通过添加对(尚未存在的)authenticated 变量的引用来预见到这一点。

登录表单

登录表单放在“login.html”中

<div class="alert alert-danger" ng-show="error">
	There was a problem logging in. Please try again.
</div>
<form role="form" ng-submit="login()">
	<div class="form-group">
		<label for="username">Username:</label> <input type="text"
			class="form-control" id="username" name="username" ng-model="credentials.username"/>
	</div>
	<div class="form-group">
		<label for="password">Password:</label> <input type="password"
			class="form-control" id="password" name="password" ng-model="credentials.password"/>
	</div>
	<button type="submit" class="btn btn-primary">Submit</button>
</form>

这是一个非常标准的登录表单,包含用于输入用户名和密码的两个输入框以及一个通过ng-submit提交表单的按钮。表单标签上不需要 action 属性,所以最好根本不添加。还有一个错误消息,仅当 Angular 的 $scope 包含 error 时显示。表单控件使用ng-model在 HTML 和 Angular 控制器之间传递数据,在这种情况下,我们使用一个 credentials 对象来保存用户名和密码。根据我们定义的路由,登录表单与“navigation”控制器相关联,该控制器目前是空的,所以让我们过去填补一些空白。

认证过程

为了支持我们刚刚添加的登录表单,我们需要添加更多功能。在客户端,这些将在“navigation”控制器中实现,而在服务器端,这将是 Spring Security 配置。

提交登录表单

要提交表单,我们需要定义已经在表单中通过 ng-submit 引用的 login() 函数,以及通过 ng-model 引用的 credentials 对象。让我们完善“hello.js”中的“navigation”控制器(省略路由配置和“home”控制器)

angular.module('hello', [ 'ngRoute' ]) // ... omitted code
.controller('navigation',

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

  var authenticate = function(credentials, callback) {

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

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

  }

  authenticate();
  $scope.credentials = {};
  $scope.login = function() {
      authenticate($scope.credentials, function() {
        if ($rootScope.authenticated) {
          $location.path("/");
          $scope.error = false;
        } else {
          $location.path("/login");
          $scope.error = true;
        }
      });
  };
});

“navigation”控制器中的所有代码将在页面加载时执行,因为包含菜单栏的 <div> 是可见的,并且带有 ng-controller="navigation" 修饰。除了初始化 credentials 对象外,它还定义了 2 个函数:我们在表单中需要的 login() 函数,以及一个本地辅助函数 authenticate(),它尝试从后端加载“user”资源。控制器加载时会调用 authenticate() 函数,以查看用户是否已实际通过身份验证(例如,如果他在会话中途刷新了浏览器)。我们需要 authenticate() 函数进行远程调用,因为实际的身份验证由服务器完成,我们不希望信任浏览器来跟踪它。

authenticate() 函数设置了一个应用程序范围的标志 authenticated,我们已在“home.html”中使用它来控制页面的哪些部分被渲染。我们使用$rootScope来完成此操作,因为它方便且易于理解,并且我们需要在“navigation”和“home”控制器之间共享 authenticated 标志。Angular 专家可能更喜欢通过共享的用户定义服务来共享数据(但这最终是相同的机制)。

authenticate() 对相对资源(相对于应用程序的部署根目录)“/user”发出 GET 请求。当从 login() 函数调用时,它在头部中添加 Base64 编码的凭据,这样服务器会进行身份验证并返回一个 cookie。login() 函数还会根据身份验证结果相应地设置一个本地 $scope.error 标志,用于控制登录表单上方错误消息的显示。

当前已认证用户

为了为 authenticate() 函数提供服务,我们需要在后端添加一个新的端点

@SpringBootApplication
@RestController
public class UiApplication {
  
  @RequestMapping("/user")
  public Principal user(Principal user) {
    return user;
  }

  ...

}

这在 Spring Security 应用程序中是一个有用的技巧。如果可以访问“/user”资源,它将返回当前已认证的用户(一个Authentication对象),否则 Spring Security 会拦截请求并通过AuthenticationEntryPoint发送 401 响应。

在服务器端处理登录请求

Spring Security 使处理登录请求变得容易。我们只需在主应用程序类中添加一些配置(例如,作为内部类)

@SpringBootApplication
@RestController
public class UiApplication {

  ...

  @Configuration
  @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
  protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
      http
        .httpBasic()
      .and()
        .authorizeRequests()
          .antMatchers("/index.html", "/home.html", "/login.html", "/").permitAll()
          .anyRequest().authenticated();
    }
  }

}

这是一个标准的 Spring Boot 应用程序,带有 Spring Security 自定义,仅允许匿名访问静态 (HTML) 资源(CSS 和 JS 资源默认已可访问)。HTML 资源需要对匿名用户可用,而不仅仅是被 Spring Security 忽略,原因稍后会阐明。

CSRF 保护

应用程序几乎可以使用了,但如果你尝试运行它,你会发现登录表单不起作用。查看浏览器中的响应,你就会明白原因了

POST /login HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded

username=user&password=password

HTTP/1.1 403 Forbidden
Set-Cookie: JSESSIONID=3941352C51ABB941781E1DF312DA474E; Path=/; HttpOnly
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
...

{"timestamp":1420467113764,"status":403,"error":"Forbidden","message":"Expected CSRF token not found. Has your session expired?","path":"/login"}

这很好,因为这意味着 Spring Security 内置的 CSRF 保护已生效,防止我们自毁。它只需要在名为“X-CSRF”的头部中发送一个令牌。CSRF 令牌的值在服务器端通过加载主页的初始请求中的 HttpRequest 属性可用。要将其发送到客户端,我们可以使用服务器上的动态 HTML 页面进行渲染,或者通过自定义端点暴露,或者将其作为 cookie 发送。最后一个选择是最好的,因为 Angular 内置了基于 cookie 的 CSRF 支持(它称之为“XSRF”)。

所以我们在服务器上只需要一个自定义过滤器来发送 cookie。Angular 要求 cookie 名称为“XSRF-TOKEN”,而 Spring Security 将其作为请求属性提供,因此我们只需将值从请求属性转移到 cookie

public class CsrfHeaderFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(HttpServletRequest request,
      HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class
        .getName());
    if (csrf != null) {
      Cookie cookie = WebUtils.getCookie(request, "XSRF-TOKEN");
      String token = csrf.getToken();
      if (cookie==null || token!=null && !token.equals(cookie.getValue())) {
        cookie = new Cookie("XSRF-TOKEN", token);
        cookie.setPath("/");
        response.addCookie(cookie);
      }
    }
    filterChain.doFilter(request, response);
  }
}

为了完成这项工作并使其完全通用,我们应该注意将 cookie 路径设置为应用程序的上下文路径(而不是硬编码为“/”),但这对于我们正在开发的应用程序来说已经足够了。

我们需要在应用程序中的某个地方安装此过滤器,并且它需要放在 Spring Security CsrfFilter 之后,以便请求属性可用。由于我们有 Spring Security 保护这些资源,没有比 Spring Security 过滤器链更好的地方了,例如扩展上面的 SecurityConfiguration

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .httpBasic().and()
      .authorizeRequests()
        .antMatchers("/index.html", "/home.html", "/login.html", "/").permitAll().anyRequest()
        .authenticated().and()
      .addFilterAfter(new CsrfHeaderFilter(), CsrfFilter.class);
  }
}

我们在服务器上还需要做的另一件事是告诉 Spring Security 期望 CSRF 令牌的格式与 Angular 希望发送回来的格式一致(一个名为“X-XRSF-TOKEN”的头部,而不是默认的“X-CSRF-TOKEN”)。我们通过自定义 CSRF 过滤器来完成此操作

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
    .httpBasic().and()
    ...
    .csrf().csrfTokenRepository(csrfTokenRepository());
}

private CsrfTokenRepository csrfTokenRepository() {
  HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
  repository.setHeaderName("X-XSRF-TOKEN");
  return repository;
}

进行了这些更改后,我们在客户端不需要做任何事情,登录表单现在可以工作了。

注销

应用程序的功能几乎完成了。我们需要做的最后一件事是实现在主页中勾勒的注销功能。以下是导航栏的样子:

<div ng-controller="navigation" class="container">
  <ul class="nav nav-pills" role="tablist">
    <li class="active"><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>

如果用户已认证,则显示“注销”链接并将其连接到“navigation”控制器中的 logout() 函数。该函数的实现相对简单

angular.module('hello', [ 'ngRoute' ]). 
// ...
.controller('navigation', function(...) {

...

$scope.logout = function() {
  $http.post('logout', {}).success(function() {
    $rootScope.authenticated = false;
    $location.path("/");
  }).error(function(data) {
    $rootScope.authenticated = false;
  });
}

...

});

它向“/logout”发送一个 HTTP POST 请求,现在我们需要在服务器上实现它。这很简单

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
    ...
  .and()
    .logout()
    ...
  ;
}

(我们只是将 .logout() 添加到 HttpSecurity 配置构建器中)。

它是如何工作的?

如果你使用一些开发者工具(通常按 F12 键打开,Chrome 默认支持,Firefox 需要插件),可以在浏览器中看到浏览器和后端之间的交互。以下是摘要:

动词 路径 状态 响应
GET / 200 index.html
GET /css/angular-bootstrap.css 200 Twitter bootstrap CSS
GET /js/angular-bootstrap.js 200 Bootstrap 和 Angular JS
GET /js/hello.js 200 应用逻辑
GET /user 401 未授权
GET /home.html 200 主页
GET /resource 401 未授权
GET /login.html 200 Angular 登录表单局部视图
GET /user 401 未授权
GET /user 200 发送凭据并获取 JSON
GET /resource 200 JSON 问候语

上面标记为“ignored”的响应是 Angular 在 XHR 调用中接收到的 HTML 响应,由于我们不对这些数据进行处理,HTML 被丢弃了。对于“/user”资源,我们确实查找已认证用户,但由于第一次调用时不存在,该响应被丢弃了。

仔细查看这些请求,你会发现它们都带有 cookie。如果你从一个干净的浏览器开始(例如 Chrome 的隐身模式),第一次发送到服务器的请求不带任何 cookie,但服务器会返回“Set-Cookie”,设置“JSESSIONID”(常规的 HttpSession)和“X-XSRF-TOKEN”(我们上面设置的 CRSF cookie)。随后的请求都带有这些 cookie,它们非常重要:没有它们应用程序将无法工作,并且它们提供了一些非常基本的安全功能(身份验证和 CSRF 保护)。用户认证后(POST 请求之后),cookie 的值会发生变化,这是另一个重要的安全功能(防止会话固定攻击)。

注意:仅依靠将 cookie 发送回服务器不足以提供 CSRF 保护,因为即使你不在从应用程序加载的页面中,浏览器也会自动发送 cookie(这是跨站脚本攻击,也称为XSS)。头部不会自动发送,因此来源是可控的。你可能会看到在我们的应用程序中,CSRF 令牌作为 cookie 发送给客户端,因此浏览器会自动将其发送回去,但提供保护的是头部信息。

求助,我的应用程序如何扩展?

“等等……”你可能会说,“在单页应用中使用会话状态不是非常糟糕吗?”这个问题的答案“大部分”是肯定的,因为使用会话进行身份验证和 CSRF 保护绝对是一件好事。这个状态必须存储在某个地方,如果你不使用会话,你就必须将它放在别处,并在服务器和客户端自己手动管理。这只会增加代码量,可能还会增加维护工作,基本上是重复造轮子。

“但是,但是……”你可能会反驳说,“我的应用程序现在如何横向扩展?”这是你前面提出的“真正”问题,但它往往被简化为“会话状态不好,我必须是无状态的”。不要惊慌。这里需要理解的重点是安全性有状态的。你不能有一个安全的、无状态的应用程序。所以你要把状态存储在哪里呢?事情就是这么简单。Rob WinchSpring Exchange 2014 上做了一个非常有用且富有洞察力的演讲,解释了状态的必要性(以及它的普遍性——TCP 和 SSL 都是有状态的,所以无论你知不知道,你的系统都是有状态的),如果你想深入探讨这个话题,这个演讲可能值得一看。

好消息是你有选择。最简单的选择是将会议数据存储在内存中,并依靠负载均衡器中的粘性会话将同一会话的请求路由回同一个 JVM(它们都以某种方式支持这一点)。这足以让你起步,并且适用于非常多的用例。另一个选择是在应用程序的多个实例之间共享会议数据。只要你严格只存储安全数据,它就很小且不经常变化(仅在用户登录和注销或会话超时时),因此不应该出现主要的インフラストラクチャ问题。使用Spring Session也很容易实现。在本系列的下一篇文章中,我们将使用 Spring Session,所以在此无需详细介绍如何设置它,但它实际上只需几行代码和一个 Redis 服务器,速度非常快。

提示:设置共享会话状态的另一种简单方法是将您的应用程序作为 WAR 文件部署到 Cloud Foundry Pivotal Web Services 并将其绑定到 Redis 服务。

但是,我的自定义令牌实现呢(看,它是无状态的)?

如果你对上一节的反应是这样,那么请再读一遍,也许你第一次没有理解。如果你将令牌存储在某个地方,它可能不是无状态的,但即使你没有(例如,你使用 JWT 编码的令牌),你如何提供 CSRF 保护?这很重要。这里有一个经验法则(归功于 Rob Winch):如果你的应用程序或 API 将被浏览器访问,你需要 CSRF 保护。并非没有会话就无法实现,只是你必须自己编写所有这些代码,这样做有什么意义呢?因为它已经在 HttpSession 之上完美实现了(而 HttpSession 本身就是你使用的容器的一部分,并且从一开始就内置在规范中)。即使你决定不需要 CSRF,并且有一个完全“无状态”(非基于会话)的令牌实现,你仍然必须在客户端编写额外的代码来消费和使用它,而你本可以委托给浏览器和服务器自身的内置功能:浏览器总是发送 cookie,服务器总是拥有会话(除非你将其关闭)。这些代码不是业务逻辑,它们不会为你带来任何收益,它只是额外开销,甚至更糟,它还会花费你的钱。

结论

我们现在拥有的应用程序已经接近用户在真实环境中期望的“实际”应用程序,并且可能可以用作构建更具功能丰富应用程序的模板,采用该架构(带有静态内容和 JSON 资源的单服务器)。我们使用 HttpSession 来存储安全数据,依靠客户端尊重并使用我们发送的 cookie,我们对此感到满意,因为它使我们可以专注于自己的业务领域。在下一篇文章中,我们将把架构扩展到一个独立的认证和 UI 服务器,以及一个独立的 JSON 资源服务器。这显然很容易推广到多个资源服务器。我们还将把 Spring Session 引入技术栈,并展示如何使用它来共享认证数据。

获取 Spring 新闻通讯

订阅 Spring 新闻通讯保持联系

订阅

领先一步

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

了解更多

获取支持

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

了解更多

近期活动

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

查看全部