登录页面: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 设置路由后会识别的方式。

  • 所有内容都将作为“部分”添加到标记为“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对象之外,它还定义了两个函数,即表单中需要的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 问候语

上面标记为 "忽略" 的响应是由 Angular 在 XHR 调用中接收到的 HTML 响应,由于我们没有处理这些数据,因此 HTML 会被丢弃。在 "/user" 资源的情况下,我们确实会查找已认证的用户,但由于在第一次调用中不存在,因此该响应会被丢弃。

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

注意:仅仅依靠 cookie 发送回服务器来进行 CSRF 保护是不够的,因为即使您不在应用程序加载的页面中,浏览器也会自动发送它(跨站脚本攻击,也称为 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 之上运行得非常好(反过来,它是您正在使用的容器的一部分,并且从一开始就烘焙到规范中)?即使你决定不需要 CSRF,并且有一个完全“无状态”(基于非会话)的令牌实现,你仍然必须在客户端编写额外的代码来使用它,而你本可以将其委托给浏览器和服务器自己的内置功能:浏览器总是发送 Cookie,服务器总是存在会话(除非你关闭它)。该代码不是业务逻辑,也不会让你赚钱,它只是一个开销,所以更糟糕的是,它会让你损失金钱。

结论

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

获取 Spring 新闻通讯

与 Spring 新闻通讯保持联系

订阅

领先一步

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

了解更多

获取支持

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

了解更多

即将举行的活动

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

查看全部