Spring Security 和 Angular

一个安全的单页应用

在本教程中,我们将展示 Spring Security、Spring Boot 和 Angular 协同工作的一些出色特性,以提供愉快且安全的用户体验。它应该对 Spring 和 Angular 的初学者来说易于理解,但对于其中任何一个领域的专家来说,也有大量有用的细节。这实际上是 Spring Security 和 Angular 系列文章的第一部分,每一部分都将陆续介绍新功能。我们将在第二部分及后续部分改进应用程序,但在此之后的主要变化是架构上的,而非功能上的。

Spring 与单页应用

HTML5、丰富的基于浏览器的特性以及“单页应用”是现代开发人员极其宝贵的工具,但任何有意义的交互都将涉及后端服务器,因此除了静态内容(HTML、CSS 和 JavaScript)之外,我们还需要一个后端服务器。后端服务器可以扮演以下一个或多个角色:提供静态内容,有时(但现在不那么频繁)渲染动态 HTML,认证用户,保护受保护资源的访问,以及(最后但并非最不重要)通过 HTTP 和 JSON(有时称为 REST API)与浏览器中的 JavaScript 进行交互。

Spring 一直是构建后端特性(尤其是在企业中)的流行技术,而随着 Spring Boot 的出现,一切都变得前所未有的简单。让我们看看如何使用 Spring Boot、Angular 和 Twitter Bootstrap 从零开始构建一个新的单页应用。选择这个特定的技术栈没有特别的原因,但它非常流行,尤其是在企业 Java 商店的核心 Spring 用户群中,因此是一个值得关注的起点。

创建新项目

我们将详细介绍创建这个应用程序的步骤,以便任何对 Spring 和 Angular 不完全熟悉的人都能理解正在发生的事情。如果你想直接看结果,可以跳到最后,那里应用程序已经可以运行,并查看它们是如何协同工作的。创建新项目有多种选择

我们要构建的完整项目的源代码在这里的 Github 上,所以你可以直接克隆项目并在那里工作。然后跳到下一节

使用 Curl

开始创建一个新项目最简单的方法是通过 Spring Boot Initializr。例如,在类 UN*X 系统上使用 curl

$ mkdir ui && cd ui
$ curl https://start.spring.io/starter.tgz -d dependencies=web,security -d name=ui | tar -xzvf -

然后你可以将该项目(默认是一个普通的 Maven Java 项目)导入到你喜欢的 IDE 中,或者只在命令行中使用文件和 "mvn"。然后跳到下一节

使用 Spring Boot CLI

你可以使用 Spring Boot CLI 创建相同的项目,像这样

$ spring init --dependencies web,security ui/ && cd ui

然后跳到下一节

使用 Initializr 网站

如果你愿意,也可以直接从 Spring Boot Initializr 获取相同的代码作为 .zip 文件。只需在浏览器中打开它,选择依赖项 "Web" 和 "Security",然后点击 "Generate Project"。该 .zip 文件在根目录中包含一个标准的 Maven 或 Gradle 项目,所以在解压之前你可能想创建一个空目录。然后跳到下一节

使用 Spring Tool Suite

Spring Tool Suite (一套 Eclipse 插件) 中,你也可以使用向导在 File->New->Spring Starter Project 创建和导入项目。然后跳到下一节。IntelliJ IDEA 和 NetBeans 也有类似的功能。

添加 Angular 应用

如今,Angular(或任何现代前端框架)单页应用的核心将是一个 Node.js 构建。Angular 提供了一些工具来快速设置它,所以让我们使用这些工具,并保留使用 Maven 构建的选项,就像任何其他 Spring Boot 应用一样。关于如何设置 Angular 应用的详细信息在其他地方有介绍,或者你可以直接从 github checkout 本教程的代码。

运行应用程序

Angular 应用准备好后,你的应用程序就可以在浏览器中加载了(尽管它还没做什么)。在命令行中你可以这样做

$ mvn spring-boot:run

并在浏览器中访问 http://localhost:8080。加载主页时,你应该会看到一个浏览器对话框要求输入用户名和密码(用户名是 "user",密码会在启动时的控制台日志中打印出来)。实际上还没有任何内容(或者可能是 ng CLI 的默认“hero”教程内容),所以你应该看到一个基本上空白的页面。

如果你不想在控制台日志中查找密码,只需将此内容添加到 "application.properties"(在 "src/main/resources" 中):security.user.password=password(并选择你自己的密码)。我们在示例代码中使用了 "application.yml" 来做到这一点。

在 IDE 中,只需运行应用程序类中的 main() 方法(只有一个类,如果你使用了上面的 "curl" 命令,它叫 UiApplication)。

要打包并作为独立 JAR 运行,你可以这样做

$ mvn package
$ java -jar target/*.jar

定制 Angular 应用程序

让我们定制 "app-root" 组件(在 "src/app/app.component.ts" 中)。

一个最小的 Angular 应用程序看起来像这样

app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'Demo';
  greeting = {'id': 'XXX', 'content': 'Hello World'};
}

这段 TypeScript 中的大部分代码都是样板代码。有趣的部分都在 AppComponent 中,我们在其中定义了 "selector"(HTML 元素的名称)并通过 @Component 注解渲染一段 HTML 代码。我们还需要编辑 HTML 模板("app.component.html")

app.component.html
<div style="text-align:center"class="container">
  <h1>
    Welcome {{title}}!
  </h1>
  <div class="container">
    <p>Id: <span>{{greeting.id}}</span></p>
    <p>Message: <span>{{greeting.content}}!</span></p>
  </div>
</div>

如果你将这些文件添加到 "src/app" 下并重新构建你的应用,它现在应该是安全且可用的,并且会显示 "Hello World!"。greeting 由 Angular 在 HTML 中使用 handlebar 占位符 {{greeting.id}}{{greeting.content}} 渲染。

添加动态内容

到目前为止,我们有一个应用程序,其中的问候语是硬编码的。这对于学习如何将各个部分组合在一起很有用,但我们实际上期望内容来自后端服务器,所以让我们创建一个 HTTP 端点,我们可以用它来获取问候语。在你的应用程序类(在 "src/main/java/demo" 中)中,添加 @RestController 注解并定义一个新的 @RequestMapping

UiApplication.java
@SpringBootApplication
@RestController
public class UiApplication {

  @RequestMapping("/resource")
  public Map<String,Object> home() {
    Map<String,Object> model = new HashMap<String,Object>();
    model.put("id", UUID.randomUUID().toString());
    model.put("content", "Hello World");
    return model;
  }

  public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

}
根据你创建新项目的方式,它可能不叫 UiApplication

运行该应用程序并尝试使用 curl 访问 "/resource" 端点,你会发现它默认是安全的

$ curl localhost:8080/resource
{"timestamp":1420442772928,"status":401,"error":"Unauthorized","message":"Full authentication is required to access this resource","path":"/resource"}

从 Angular 加载动态资源

所以让我们在浏览器中获取该消息。修改 AppComponent 以使用 XHR 加载受保护的资源

app.component.ts
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'Demo';
  greeting = {};
  constructor(private http: HttpClient) {
    http.get('resource').subscribe(data => this.greeting = data);
  }
}

我们注入了一个由 Angular 通过 http 模块提供的http 服务,并使用它来 GET 我们的资源。Angular 将响应传递给我们,我们从中提取 JSON 并将其赋给 greeting。

为了使 http 服务能够注入到我们的自定义组件中,我们需要在包含该组件的 AppModule 中声明它(与初始草案相比,这只是在 imports 中多加了一行)

app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

再次运行应用程序(或只需在浏览器中重新加载主页),你将看到带有唯一 ID 的动态消息。因此,即使资源受到保护且无法直接使用 curl 访问,浏览器也能够访问其内容。我们在不到一百行代码中构建了一个安全的单页应用程序!

更改静态资源后,你可能需要强制浏览器重新加载它们。在 Chrome(和带有插件的 Firefox)中,你可以使用“开发者工具”(F12),这可能就足够了。或者你可能需要使用 CTRL+F5。

它是如何工作的?

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

动词 路径 状态 响应

GET

/

401

浏览器提示进行身份验证

GET

/

200

index.html

GET

/*.js

200

加载了大量的 Angular 第三方资源

GET

/main.bundle.js

200

应用程序逻辑

GET

/resource

200

JSON 问候语

你可能看不到 401 响应,因为浏览器将主页加载视为一次单独的交互;你可能会看到对 "/resource" 的两次请求,因为存在一个 CORS 协商。

更仔细地查看请求,你会发现所有请求都带有一个 "Authorization" 头,类似这样

Authorization: Basic dXNlcjpwYXNzd29yZA==

浏览器在每次请求时都发送用户名和密码(所以在生产环境中务必只使用 HTTPS)。这与 "Angular" 没有任何关系,因此它适用于你选择的任何 JavaScript 框架或非框架。

有什么问题吗?

从表面上看,我们似乎做得很好,它简洁明了,易于实现,所有数据都通过秘密密码保护,即使我们更改前端或后端技术也能正常工作。但还存在一些问题。

  • Basic 认证仅限于用户名和密码认证。

  • 认证 UI 随处可见但很丑陋(浏览器对话框)。

  • 没有针对跨站请求伪造 (CSRF) 的保护。

就目前我们的应用程序而言,CSRF 并不是真正的问题,因为它只需要 GET 后端资源(即服务器上的状态没有改变)。一旦你的应用程序中有 POST、PUT 或 DELETE 请求,按照任何合理的现代标准,它就不再安全了。

在本系列的下一节中,我们将扩展应用程序以使用基于表单的认证,这比 HTTP Basic 灵活得多。一旦有了表单,我们就需要 CSRF 保护,而 Spring Security 和 Angular 都提供了一些开箱即用的特性来帮助解决这个问题。剧透一下:我们需要使用 HttpSession

致谢:感谢所有帮助我开发本系列文章的人,特别是Rob WinchThorsten Spaeth 对文本和源代码的认真审阅,并教会了我一些我即使在我认为最熟悉的领域也不知道的技巧。

登录页面

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

提醒:如果你正在使用示例应用程序学习本节内容,请务必清除浏览器的 cookie 和 HTTP Basic 凭据缓存。在 Chrome 中,针对单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。

为主页添加导航

Angular 应用程序的核心是一个用于基本页面布局的 HTML 模板。我们之前已经有一个非常基本的模板,但对于此应用程序,我们需要提供一些导航功能(登录、退出、主页),所以让我们修改它(在 src/app 中)

app.component.html
<div class="container">
  <ul class="nav nav-pills">
    <li><a routerLinkActive="active" routerLink="/home">Home</a></li>
    <li><a routerLinkActive="active" routerLink="/login">Login</a></li>
    <li><a (click)="logout()">Logout</a></li>
  </ul>
</div>
<div class="container">
  <router-outlet></router-outlet>
</div>

主要内容是一个 <router-outlet/>,还有一个带有登录和退出链接的导航栏。

<router-outlet/> 选择器由 Angular 提供,需要将其与主模块中的组件连接起来。每个路由(每个菜单链接)将对应一个组件,并有一个辅助服务(AppService)将它们连接起来并共享一些状态。以下是将所有部分组合在一起的模块实现

app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule, Routes } from '@angular/router';
import { AppService } from './app.service';
import { HomeComponent } from './home.component';
import { LoginComponent } from './login.component';
import { AppComponent } from './app.component';

const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'home'},
  { path: 'home', component: HomeComponent},
  { path: 'login', component: LoginComponent}
];

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    LoginComponent
  ],
  imports: [
    RouterModule.forRoot(routes),
    BrowserModule,
    HttpClientModule,
    FormsModule
  ],
  providers: [AppService]
  bootstrap: [AppComponent]
})
export class AppModule { }

我们添加了对名为"RouterModule" 的 Angular 模块的依赖,这使我们能够将一个神奇的 router 注入到 AppComponent 的构造函数中。routesAppModule 的 imports 中用于设置指向 "/"("home" 控制器)和 "/login"("login" 控制器)的链接。

我们还在其中偷偷加入了 FormsModule,因为稍后它将用于将数据绑定到用户登录时要提交的表单。

UI 组件都是“声明 (declarations)”,服务粘合层是“提供者 (provider)”。AppComponent 实际上并没有做太多事情。与应用根组件对应的 TypeScript 组件在这里

app.component.ts
import { Component } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import 'rxjs/add/operator/finally';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  constructor(private app: AppService, private http: HttpClient, private router: Router) {
      this.app.authenticate(undefined, undefined);
    }
    logout() {
      this.http.post('logout', {}).finally(() => {
          this.app.authenticated = false;
          this.router.navigateByUrl('/login');
      }).subscribe();
    }

}

主要特点

  • 这里还有一些依赖注入,这次是 AppService

  • 组件中暴露了一个 logout 函数作为属性,我们可以稍后用它向后端发送一个退出请求。它在 app 服务中设置一个标志,并将用户发送回登录屏幕(并且通过 finally() 回调无条件执行此操作)。

  • 我们使用 templateUrl 将模板 HTML 外部化到一个单独的文件中。

  • 控制器加载时会调用 authenticate() 函数,以检查用户是否已经认证(例如,如果他在会话中刷新了浏览器)。我们需要 authenticate() 函数来进行远程调用,因为实际的认证是在服务器端完成的,我们不信任浏览器来跟踪它。

我们上面注入的 app 服务需要一个布尔标志来判断用户当前是否已认证,以及一个 authenticate() 函数,该函数可用于与后端服务器进行认证,或仅查询用户详情

app.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';

@Injectable()
export class AppService {

  authenticated = false;

  constructor(private http: HttpClient) {
  }

  authenticate(credentials, callback) {

        const headers = new HttpHeaders(credentials ? {
            authorization : 'Basic ' + btoa(credentials.username + ':' + credentials.password)
        } : {});

        this.http.get('user', {headers: headers}).subscribe(response => {
            if (response['name']) {
                this.authenticated = true;
            } else {
                this.authenticated = false;
            }
            return callback && callback();
        });

    }

}

authenticated 标志很简单。authenticate() 函数在提供了 HTTP Basic 认证凭据时发送它们,否则不发送。它还有一个可选的 callback 参数,如果认证成功,我们可以使用它来执行一些代码。

问候语

旧主页的问候语内容可以放在 "src/app" 中的 "app.component.html" 旁边

home.component.html
<h1>Greeting</h1>
<div [hidden]="!authenticated()">
	<p>The ID is {{greeting.id}}</p>
	<p>The content is {{greeting.content}}</p>
</div>
<div [hidden]="authenticated()">
	<p>Login to see your greeting</p>
</div>

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

HomeComponent 必须获取问候语,并提供一个 authenticated() 工具函数,该函数从 AppService 中获取标志。

home.component.ts
import { Component, OnInit } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';

@Component({
  templateUrl: './home.component.html'
})
export class HomeComponent {

  title = 'Demo';
  greeting = {};

  constructor(private app: AppService, private http: HttpClient) {
    http.get('resource').subscribe(data => this.greeting = data);
  }

  authenticated() { return this.app.authenticated; }

}

登录表单

登录表单也有它自己的组件

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

这是一个非常标准的登录表单,带有两个用于输入用户名和密码的输入框,以及一个通过 Angular 事件处理器 (submit) 提交表单的按钮。表单标签上不需要 action,所以最好完全不设置它。还有一个错误消息,只有在 Angular 模型中包含 error 时才会显示。表单控件使用 Angular Forms 中的 ngModel 在 HTML 和 Angular 控制器之间传递数据,在这种情况下,我们使用一个 credentials 对象来保存用户名和密码。

认证过程

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

提交登录表单

要提交表单,我们需要定义已经在表单中通过 ng-submit 引用的 login() 函数,以及通过 ng-model 引用的 credentials 对象。让我们充实一下“登录”组件

login.component.ts
import { Component, OnInit } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';

@Component({
  templateUrl: './login.component.html'
})
export class LoginComponent {

  credentials = {username: '', password: ''};

  constructor(private app: AppService, private http: HttpClient, private router: Router) {
  }

  login() {
    this.app.authenticate(this.credentials, () => {
        this.router.navigateByUrl('/');
    });
    return false;
  }

}

除了初始化 credentials 对象外,它还定义了我们在表单中需要的 login() 函数。

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

当前认证用户

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

UiApplication.java
@SpringBootApplication
@RestController
public class UiApplication {

  @RequestMapping("/user")
  public Principal user(Principal user) {
    return user;
  }

  ...

}

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

在服务器端处理登录请求

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

UiApplication.java
@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", "/login").permitAll()
          .anyRequest().authenticated();
    }
  }

}

这是一个标准的 Spring Boot 应用程序,带有 Spring Security 定制配置,只允许匿名访问静态(HTML)资源。出于某些原因,HTML 资源需要对匿名用户可用,而不仅仅是被 Spring Security 忽略,这些原因稍后会解释清楚。

我们需要记住的最后一件事是,使 Angular 提供的 JavaScript 组件对应用程序匿名可用。我们可以在上面的 HttpSecurity 配置中做到这一点,但由于它们是静态内容,最好直接忽略它们

application.yml
security:
  ignored:
  - "*.bundle.*"

添加默认 HTTP 请求头

如果你此时运行应用程序,你会发现浏览器会弹出一个 Basic 认证对话框(要求输入用户和密码)。这是因为它看到了来自 /user/resource 的 XHR 请求的 401 响应,并且带有一个 "WWW-Authenticate" 头。抑制此弹出窗口的方法是抑制该头部,该头部来自 Spring Security。而抑制响应头部的方法是发送一个特殊的、约定俗成的请求头部 "X-Requested-With=XMLHttpRequest"。这曾经是 Angular 的默认行为,但他们在 1.3.0 版本中将其移除。所以这里介绍如何在 Angular XHR 请求中设置默认头部。

首先扩展 Angular HTTP 模块提供的默认 RequestOptions

app.module.ts
@Injectable()
export class XhrInterceptor implements HttpInterceptor {

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    const xhr = req.clone({
      headers: req.headers.set('X-Requested-With', 'XMLHttpRequest')
    });
    return next.handle(xhr);
  }
}

这里的语法是样板代码。Classimplements 属性是它的基类,除了构造函数之外,我们真正需要做的就是覆盖总是由 Angular 调用的 intercept() 函数,该函数可用于添加额外的头部。

要安装这个新的 RequestOptions 工厂,我们需要在 AppModuleproviders 中声明它

app.module.ts
@NgModule({
  ...
  providers: [AppService, { provide: HTTP_INTERCEPTORS, useClass: XhrInterceptor, multi: true }],
  ...
})
export class AppModule { }

退出

应用程序的功能性部分几乎完成了。我们需要做的最后一件事是实现我们在主页中勾勒的退出功能。如果用户已认证,我们就显示一个“退出”链接并将其挂接到 AppComponentlogout() 函数。请记住,它会向 "/logout" 发送一个 HTTP POST 请求,我们现在需要在服务器上实现它。这很直接,因为 Spring Security 已经为我们添加了(即,对于这个简单的用例,我们不需要做任何事情)。如果需要更精细地控制退出行为,你可以在 WebSecurityAdapter 中使用 HttpSecurity 回调,例如在退出后执行一些业务逻辑。

CSRF 保护

应用程序几乎可以使用了,事实上,如果你运行它,你会发现到目前为止我们构建的一切都正常工作,除了退出链接。尝试使用它并查看浏览器中的响应,你就会明白原因

POST /logout 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。幸运的是,Spring Security(自 4.1.0 版本起)提供了一个特殊的 CsrfTokenRepository,它可以精确地实现这一点

UiApplication.java
@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      ...
      .and().csrf()
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
  }
}

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

它是如何工作的?

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

动词 路径 状态 响应

GET

/

200

index.html

GET

/*.js

200

来自 Angular 的资源

GET

/user

401

未授权(已忽略)

GET

/home

200

主页

GET

/user

401

未授权(已忽略)

GET

/resource

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 都是有状态的,所以无论你知不知道,你的系统都是有状态的),如果你想更深入地研究这个话题,可能值得一看。

好消息是你有一个选择。最简单的选择是将会话数据存储在内存中,并依赖负载均衡器中的粘性会话(Sticky Sessions)将来自同一会话的请求路由回同一 JVM(它们或多或少都支持这一点)。这足以让你起步,并且适用于非常多的使用场景。另一个选择是在你的应用程序实例之间共享会话数据。只要你严格只存储安全数据,这些数据就会很小且不经常变化(仅在用户登录、注销或会话超时时发生),因此不应该引起任何重大的基础设施问题。使用Spring Session实现这一点也非常容易。在本系列的下一节中,我们将使用 Spring Session,因此这里无需详细介绍如何设置它,但它实际上只需要几行代码和一个 Redis 服务器,后者速度非常快。

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

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

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

结论

我们现在的应用程序已经接近用户在真实生产环境中期望的“真实”应用程序,并且很可能可以作为模板,基于这种架构(单服务器提供静态内容和 JSON 资源)构建出功能更丰富的应用程序。我们使用 `HttpSession` 存储安全数据,依赖客户端遵守和使用我们发送的 cookies,我们对此感到满意,因为它让我们能够专注于自己的业务领域。在下一节中,我们将架构扩展为一个独立的认证和 UI 服务器,再加上一个独立的 JSON 资源服务器。这显然可以很容易地推广到多个资源服务器。我们还将引入 Spring Session 到技术栈中,并展示如何使用它来共享认证数据。

资源服务器

在本节中,我们继续讨论如何在“单页应用程序”中使用Spring SecurityAngular。这里,我们首先将应用程序中用作动态内容的“greeting”资源拆分到一个单独的服务器中,先作为非保护资源,然后通过不透明令牌进行保护。这是本系列文章的第三部分,你可以通过阅读第一部分来了解应用程序的基本构建模块或从头开始构建,或者你也可以直接查看 Github 上的源代码,它分为两部分:一部分是资源未受保护的代码,另一部分是通过令牌保护的代码。

如果你正在使用示例应用程序学习本节内容,请务必清除浏览器缓存中的 cookies 和 HTTP Basic 凭据。在 Chrome 浏览器中,对于单个服务器来说,最好的方法是打开一个新的无痕窗口。

独立的资源服务器

客户端的修改

在客户端,将资源迁移到不同的后端不需要做太多改动。这是上一节中的“home”组件:

home.component.ts
@Component({
  templateUrl: './home.component.html'
})
export class HomeComponent {

  title = 'Demo';
  greeting = {};

  constructor(private app: AppService, private http: HttpClient) {
    http.get('resource').subscribe(data => this.greeting = data);
  }

  authenticated() { return this.app.authenticated; }

}

我们只需要修改 URL。例如,如果我们在 localhost 上运行新的资源,它看起来会像这样:

home.component.ts
        http.get('http://localhost:9000').subscribe(data => this.greeting = data);

服务器端的修改

UI 服务器的修改非常简单:我们只需要移除 greeting 资源的 `@RequestMapping`(它原来是 "/resource")。然后我们需要创建一个新的资源服务器,我们可以像在第一节中使用Spring Boot Initializr那样做。例如,在类 UN*X 系统上使用 curl:

$ mkdir resource && cd resource
$ curl https://start.spring.io/starter.tgz -d dependencies=web -d name=resource | tar -xzvf -

然后你可以将该项目(默认情况下是一个普通的 Maven Java 项目)导入到你喜欢的 IDE 中,或者直接在命令行中使用文件和 "mvn" 命令。

只需向主应用程序类添加一个 `@RequestMapping`,复制旧的 UI 中的实现即可:

ResourceApplication.java
@SpringBootApplication
@RestController
class ResourceApplication {

  @RequestMapping("/")
  public Message home() {
    return new Message("Hello World");
  }

  public static void main(String[] args) {
    SpringApplication.run(ResourceApplication.class, args);
  }

}

class Message {
  private String id = UUID.randomUUID().toString();
  private String content;
  public Message(String content) {
    this.content = content;
  }
  // ... getters and setters and default constructor
}

完成后,你的应用程序就可以在浏览器中加载了。在命令行中,你可以这样做:

$ mvn spring-boot:run -Dserver.port=9000

然后在浏览器中访问 http://localhost:9000,你应该会看到包含 greeting 的 JSON。你可以在 `application.properties`(位于 "src/main/resources" 中)中固定端口号的修改:

application.properties
server.port: 9000

如果你尝试在浏览器中从 UI(端口 8080)加载该资源,你会发现它不起作用,因为浏览器不允许该 XHR 请求。

CORS 协商

浏览器尝试与我们的资源服务器进行协商,以根据跨域资源共享(Cross Origin Resource Sharing)协议确定是否允许访问。这不是 Angular 的职责,因此就像 cookie 契约一样,它对浏览器中的所有 JavaScript 都以这种方式工作。这两个服务器没有声明它们拥有共同的源(origin),因此浏览器拒绝发送请求,UI 功能失效。

为了解决这个问题,我们需要支持 CORS 协议,该协议涉及一个“预检”(pre-flight)OPTIONS 请求和一些用于列出调用方允许行为的头信息。Spring 4.2 有一些很好的细粒度 CORS 支持,因此我们只需在控制器映射上添加一个注解即可,例如:

ResourceApplication.java
@RequestMapping("/")
@CrossOrigin(origins="*", maxAge=3600)
public Message home() {
  return new Message("Hello World");
}
轻率地使用 `origins=*` 是一种快捷但不安全的方法,虽然它有效,但它不安全,并且绝对不推荐。

保护资源服务器

太好了!我们有了一个采用新架构且能正常工作的应用程序。唯一的问题是资源服务器没有安全性。

添加 Spring Security

我们还可以看看如何像在 UI 服务器中那样,将安全性作为过滤层添加到资源服务器。第一步非常简单:只需在 Maven POM 中将 Spring Security 添加到 classpath 中即可:

pom.xml
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  ...
</dependencies>

重新启动资源服务器,瞧!它已经受到保护了:

$ curl -v localhost:9000
< HTTP/1.1 302 Found
< Location: http://localhost:9000/login
...

我们被重定向到了一个(默认)登录页面,因为 curl 没有发送与我们的 Angular 客户端相同的头信息。修改命令使其发送更相似的头信息:

$ curl -v -H "Accept: application/json" \
    -H "X-Requested-With: XMLHttpRequest" localhost:9000
< HTTP/1.1 401 Unauthorized
...

所以我们只需要教导客户端在每次请求时发送凭据。

令牌认证

互联网以及人们的 Spring 后端项目中充斥着各种自定义的基于令牌的认证解决方案。Spring Security 提供了一个基本的 `Filter` 实现,帮助你开始构建自己的解决方案(例如,参阅 `AbstractPreAuthenticatedProcessingFilter``TokenService`)。不过,Spring Security 中没有一个规范的实现,原因之一可能是有更简单的方法。

回顾本系列第二部分的内容,Spring Security 默认使用 `HttpSession` 来存储认证数据。不过,它并不直接与会话交互:中间有一个抽象层(`SecurityContextRepository`),你可以使用它来改变存储后端。如果我们能将资源服务器中的该存储库指向一个存储了由我们的 UI 验证过的认证信息的地方,那么我们就有了在两个服务器之间共享认证的方法。UI 服务器已经有这样的存储(`HttpSession`),所以如果我们能将该存储分发并开放给资源服务器,我们就有了解决方案的大部分。

Spring Session

使用Spring Session来实现解决方案的这部分非常简单。我们只需要一个共享数据存储(Redis 和 JDBC 开箱即用),以及在服务器中配置几行代码来设置一个 `Filter`。

在 UI 应用程序中,我们需要向我们的POM添加一些依赖:

pom.xml
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Spring Boot 和 Spring Session 协同工作,连接到 Redis 并集中存储会话数据。

仅需这一行代码,并且本地运行着 Redis 服务器,你就可以运行 UI 应用程序,使用一些有效的用户凭据登录,会话数据(认证信息)就会存储在 Redis 中。

如果你的本地没有运行 Redis 服务器,你可以轻松地使用Docker启动一个(在 Windows 或 MacOS 上这需要一个虚拟机)。在Github 上的源代码中有一个`docker-compose.yml`文件,你可以在命令行中使用 `docker-compose up` 轻松运行它。如果你在虚拟机中这样做,Redis 服务器将运行在与 localhost 不同的主机上,因此你需要将其隧道到 localhost,或者在 `application.properties` 中配置应用程序指向正确的 `spring.redis.host`。

从 UI 发送自定义令牌

唯一缺失的部分是将存储中数据对应的 key 进行传输的机制。这个 key 就是 `HttpSession` ID,所以如果我们在 UI 客户端中能够获取到这个 key,我们就可以将其作为自定义头信息发送给资源服务器。因此,“home”控制器需要修改,使其在为 greeting 资源发送 HTTP 请求时包含该头信息。例如:

home.component.ts
  constructor(private app: AppService, private http: HttpClient) {
    http.get('token').subscribe(data => {
      const token = data['token'];
      http.get('http://localhost:9000', {headers : new HttpHeaders().set('X-Auth-Token', token)})
        .subscribe(response => this.greeting = response);
    }, () => {});
  }

(一个更优雅的解决方案可能是在需要时获取令牌,并使用我们的 `RequestOptionsService` 将头信息添加到每个发送到资源服务器的请求中。)

我们没有直接访问 "http://localhost:9000",而是将该调用封装在了 UI 服务器上的一个新的自定义端点 "/token" 的成功回调中。其实现非常简单:

UiApplication.java
@SpringBootApplication
@RestController
public class UiApplication {

  public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

  ...

  @RequestMapping("/token")
  public Map<String,String> token(HttpSession session) {
    return Collections.singletonMap("token", session.getId());
  }

}

因此,UI 应用程序已准备就绪,并将会在所有对后端的调用中包含名为 "X-Auth-Token" 的头信息中的会话 ID。

资源服务器中的认证

为了让资源服务器能够接受自定义头信息,需要对其进行一个微小的修改。CORS 配置必须将该头信息指定为远程客户端允许的头信息,例如:

ResourceApplication.java
@RequestMapping("/")
@CrossOrigin(origins = "*", maxAge = 3600,
    allowedHeaders={"x-auth-token", "x-requested-with", "x-xsrf-token"})
public Message home() {
  return new Message("Hello World");
}

浏览器发出的预检检查现在将由 Spring MVC 处理,但我们需要告诉 Spring Security 允许它通过:

ResourceApplication.java
public class ResourceApplication extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.cors().and().authorizeRequests()
      .anyRequest().authenticated();
  }

  ...
没有必要对所有资源都进行 `permitAll()` 访问,因为可能存在无意中发送敏感数据的处理程序,因为它不知道该请求是预检请求。`cors()` 配置工具通过在过滤层处理所有预检请求来缓解此问题。

剩下的就是获取资源服务器中的自定义令牌,并用它来认证我们的用户。这相当直接,因为我们只需告诉 Spring Security 会话存储库在哪里,以及在传入请求中去哪里查找令牌(会话 ID)。首先我们需要添加 Spring Session 和 Redis 依赖,然后我们可以设置 `Filter`:

ResourceApplication.java
@SpringBootApplication
@RestController
class ResourceApplication {

  ...

  @Bean
  HeaderHttpSessionStrategy sessionStrategy() {
    return new HeaderHttpSessionStrategy();
  }

}

创建的这个 `Filter` 与 UI 服务器中的那个是镜像关系,它将 Redis 建立为会话存储。唯一的区别在于它使用了一个自定义的 `HttpSessionStrategy`,该策略会在头信息中查找(默认为 "X-Auth-Token"),而不是默认的(名为 "JSESSIONID" 的 cookie)。我们还需要阻止浏览器在未经认证的客户端弹出对话框 - 应用程序是安全的,但默认会发送一个带有 `WWW-Authenticate: Basic` 的 401 响应,因此浏览器会弹出一个要求输入用户名和密码的对话框。有不止一种方法可以实现这一点,但我们已经让 Angular 发送了 "X-Requested-With" 头信息,因此 Spring Security 默认会为我们处理。

为了使资源服务器与我们的新认证方案协同工作,还需要对其进行最后一个修改。Spring Boot 默认的安全是无状态的,而我们希望它将认证信息存储在会话中,因此我们需要在 `application.yml`(或 `application.properties`)中明确指定:

application.yml
security:
  sessions: NEVER

这告诉 Spring Security “永不创建会话,但如果存在就使用”(由于在 UI 中进行了认证,会话将已经存在)。

重新启动资源服务器,并在新的浏览器窗口中打开 UI。

为什么不能完全使用 Cookies?

我们必须使用自定义头信息并在客户端编写代码来填充该头信息,这虽然不太复杂,但这似乎与第二部分中关于尽可能使用 cookies 和 sessions 的建议相矛盾。那里的论点是,不这样做会引入不必要的额外复杂性,而且可以肯定的是,我们现在的实现是迄今为止我们所见过的最复杂的:解决方案的技术部分远远超过了业务逻辑(尽管业务逻辑确实很小)。这无疑是一个合理的批评(也是我们计划在本系列下一节中解决的问题),但让我们简单地看看为什么不能简单地对所有事情都使用 cookies 和 sessions。

至少我们仍然在使用会话,这是有道理的,因为 Spring Security 和 Servlet 容器知道如何毫不费力地做到这一点。但我们难道不能继续使用 cookies 来传输认证令牌吗?那会很不错,但它不起作用是有原因的,那就是浏览器不允许我们这样做。你当然可以从 JavaScript 客户端在浏览器的 cookie 存储中查看,但有一些限制,而且这是有充分理由的。特别是,你无法访问服务器设置为 "HttpOnly" 的 cookies(你会发现会话 cookie 默认就是这样)。你也不能在发出的请求中设置 cookies,所以我们无法设置 "SESSION" cookie(这是 Spring Session 默认的 cookie 名称),我们必须使用自定义的 "X-Session" 头信息。这两项限制都是为了保护你自己,防止恶意脚本未经授权访问你的资源。

TL;DR(太长不看):UI 和资源服务器没有共同的源(origin),所以它们不能共享 cookies(尽管我们可以使用 Spring Session 强制它们共享 sessions)。

结论

我们已经复制了本系列第二部分中应用程序的功能:一个主页,其中包含从远程后端获取的 greeting,以及导航栏中的登录和注销链接。不同之处在于 greeting 来自一个独立的资源服务器,而不是嵌入在 UI 服务器中。这给实现增加了显著的复杂性,但好消息是我们有一个大部分基于配置(并且实际上是 100% 声明式)的解决方案。我们甚至可以通过将所有新代码提取到库中(Spring 配置和 Angular 自定义指令)来使解决方案实现 100% 声明式。我们将这个有趣的后续任务推迟到接下来的几部分之后。在下一节中,我们将探讨另一种极好的方法来减少当前实现中的所有复杂性:API 网关模式(客户端将其所有请求发送到一个地方,并在那里处理认证)。

我们在这里使用 Spring Session 在两个逻辑上不是同一个应用程序的服务器之间共享会话。这是一个巧妙的技巧,使用“常规”JEE 分布式会话是无法实现的。

API 网关

在本节中,我们继续讨论如何在“单页应用程序”中使用Spring SecurityAngular。这里,我们展示如何使用Spring Cloud构建一个 API 网关来控制后端资源的认证和访问。这是本系列文章的第四部分,你可以通过阅读第一部分来了解应用程序的基本构建模块或从头开始构建,或者你也可以直接查看 Github 上的源代码。在上一节中,我们构建了一个简单的分布式应用程序,该应用程序使用Spring Session来认证后端资源。在本节中,我们将 UI 服务器变成后端资源服务器的反向代理,解决了上一个实现中的问题(由自定义令牌认证引入的技术复杂性),并为我们提供了许多新的选项来控制来自浏览器客户端的访问。

提醒:如果你正在使用示例应用程序学习本节内容,请务必清除浏览器的 cookie 和 HTTP Basic 凭据缓存。在 Chrome 中,针对单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。

创建 API 网关

API 网关是前端客户端(可以是基于浏览器的,如本节示例所示,也可以是移动端)的单一入口点(和控制点)。客户端只需要知道一个服务器的 URL,后端可以随意重构而无需改变,这是一个显著的优势。在集中化和控制方面还有其他优势:限流、认证、审计和日志记录。使用Spring Cloud实现一个简单的反向代理非常简单。

如果你一直在跟随代码实践,你会知道上一节结束时的应用程序实现有些复杂,所以这不是一个好的起点来继续迭代。然而,有一个中间状态我们可以更容易地开始,即后端资源尚未受到 Spring Security 的保护。它的源代码是 Github 上的一个独立项目在此,所以我们将从那里开始。它有一个 UI 服务器和一个资源服务器,并且它们之间可以相互通信。资源服务器还没有 Spring Security,所以我们可以先让系统工作起来,然后再添加那一层安全性。

一行代码实现的声明式反向代理

为了将其变成一个 API 网关,UI 服务器需要做一点小的调整。在 Spring 配置的某个地方,我们需要添加一个 `@EnableZuulProxy` 注解,例如在主(唯一)应用程序类中:

UiApplication.java
@SpringBootApplication
@RestController
@EnableZuulProxy
public class UiApplication {
  ...
}

并在一个外部配置文件中,我们需要将 UI 服务器中的本地资源映射到 Github 上的外部配置("application.yml")中的远程资源:

application.yml
security:
  ...
zuul:
  routes:
    resource:
      path: /resource/**
      url: http://localhost:9000

这表示“将此服务器中模式为 `/resource/**` 的路径映射到远程服务器 `localhost:9000` 中的相同路径”。简单而有效(好吧,如果算上 YAML 就是 6 行,但你并非总是需要 YAML 配置)!

要使这一切工作,我们只需要在 classpath 中包含正确的依赖。为此,我们在 Maven POM 中添加了几行新的依赖:

pom.xml
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>Dalston.SR4</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-zuul</artifactId>
  </dependency>
  ...
</dependencies>

注意“spring-cloud-starter-zuul”的使用——它是一个 starter POM,就像 Spring Boot 的那样,但它管理着我们这个 Zuul 代理所需的依赖。我们还使用了 ``,因为我们希望能够确保所有传递性依赖的版本都是正确的。

在客户端消费代理

完成这些修改后,我们的应用程序仍然可以工作,但在修改客户端之前,我们实际上还没有使用新的代理。幸运的是,这非常简单。我们只需要撤销在上一节中从“single”示例切换到“vanilla”示例时所做的修改:

home.component.ts
constructor(private app: AppService, private http: HttpClient) {
  http.get('resource').subscribe(data => this.greeting = data);
}

现在,当我们启动服务器时,一切都正常工作,请求正通过 UI(API 网关)代理到资源服务器。

进一步简化

更好的是:我们不再需要在资源服务器中添加 CORS 过滤器了。无论如何,我们都是匆忙地添加了那个过滤器,而且我们不得不手动进行如此技术性的修改(尤其是在涉及安全性的地方),这本应是一个警示。幸运的是,它现在是多余的了,所以我们可以把它扔掉,然后高枕无忧了!

保护资源服务器

你可能还记得,我们开始的中间状态下,资源服务器没有设置任何安全性。

旁注:如果你的网络架构与应用程序架构相匹配(你可以让资源服务器在物理上除了 UI 服务器之外无法被任何人访问),那么缺乏软件安全性甚至可能不是问题。作为一个简单的演示,我们可以让资源服务器只能在 localhost 上访问。只需将此添加到资源服务器的 `application.properties` 中:

application.properties
server.address: 127.0.0.1

哇,这太简单了!在你的数据中心中使用一个仅可见的网络地址来做这件事,你就获得了一个适用于所有资源服务器和所有用户桌面的安全解决方案。

假设我们决定确实需要在软件层面设置安全性(这很可能是出于多种原因)。这不会有问题,因为我们只需要将 Spring Security 添加为依赖即可(在资源服务器的 POM 中):

pom.xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

这足以让我们的资源服务器变得安全,但应用程序仍然无法正常工作,原因与在第三部分中相同:两个服务器之间没有共享的认证状态。

共享认证状态

我们可以使用与上一节相同的机制来共享认证(和 CSRF)状态,即使用Spring Session。我们像之前一样向两个服务器都添加依赖:

pom.xml
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

但这次配置要简单得多,因为我们只需在两者中添加相同的 `Filter` 声明即可。首先是 UI 服务器,明确声明我们希望转发所有头信息(即没有头信息是“敏感的”):

application.yml
zuul:
  routes:
    resource:
      sensitive-headers:

然后我们可以转向资源服务器。需要进行两项小修改:一项是明确禁用资源服务器中的 HTTP Basic 认证(以防止浏览器弹出认证对话框):

ResourceApplication.java
@SpringBootApplication
@RestController
class ResourceApplication extends WebSecurityConfigurerAdapter {

  ...

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.httpBasic().disable();
    http.authorizeRequests().anyRequest().authenticated();
  }

}

旁注:另一种方法,同样可以防止弹出认证对话框,是保留 HTTP Basic 但将 401 挑战改为“Basic”之外的其他内容。你可以通过在 `HttpSecurity` 配置回调中实现一个一行的 `AuthenticationEntryPoint` 来做到这一点。

另一项是明确要求在 `application.properties` 中设置非无状态的会话创建策略:

application.properties
security.sessions: NEVER

只要 Redis 在后台仍然运行(如果你喜欢,可以使用`docker-compose.yml`来启动它),系统就可以工作。在 http://localhost:8080 加载 UI 的主页,然后登录,你就会看到后端在主页上渲染的消息。

它是如何工作的?

现在幕后发生了什么?首先我们可以看看 UI 服务器(和 API 网关)中的 HTTP 请求:

动词 路径 状态 响应

GET

/

200

index.html

GET

/*.js

200

来自 Angular 的资产

GET

/user

401

未授权(已忽略)

GET

/resource

401

对资源的未认证访问

GET

/user

200

JSON 认证用户

GET

/resource

200

(代理)JSON greeting

这与第二部分末尾的序列相同,只是 cookie 名称略有不同("SESSION" 而不是 "JSESSIONID"),因为我们使用了 Spring Session。但架构是不同的,最后一个对 "/resource" 的请求是特殊的,因为它被代理到了资源服务器。

我们可以通过查看 UI 服务器(来自 Spring Boot Actuator,我们通过 Spring Cloud 依赖添加了它)中的 "/trace" 端点来查看反向代理的运行情况。在新的浏览器中访问 http://localhost:8080/trace(如果你还没有,可以为浏览器安装一个 JSON 插件,使其更易读)。你需要使用 HTTP Basic 进行认证(浏览器弹出窗口),但凭据与你的登录表单相同。在开始或接近开始的地方,你应该会看到一对请求,大致如下:

尝试使用不同的浏览器,以避免认证交叉(例如,如果你使用 Chrome 测试 UI,则使用 Firefox)——这不会阻止应用程序工作,但如果跟踪信息包含来自同一浏览器的混合认证,则会更难阅读。
/trace
{
  "timestamp": 1420558194546,
  "info": {
    "method": "GET",
    "path": "/",
    "query": ""
    "remote": true,
    "proxy": "resource",
    "headers": {
      "request": {
        "accept": "application/json, text/plain, */*",
        "x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
        "cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260",
        "x-forwarded-prefix": "/resource",
        "x-forwarded-host": "localhost:8080"
      },
      "response": {
        "Content-Type": "application/json;charset=UTF-8",
        "status": "200"
      }
    },
  }
},
{
  "timestamp": 1420558200232,
  "info": {
    "method": "GET",
    "path": "/resource/",
    "headers": {
      "request": {
        "host": "localhost:8080",
        "accept": "application/json, text/plain, */*",
        "x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
        "cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260"
      },
      "response": {
        "Content-Type": "application/json;charset=UTF-8",
        "status": "200"
      }
    }
  }
},

第二个条目是客户端向网关发出的 "/resource" 请求,你可以看到 cookies(由浏览器添加)和 CSRF 头信息(由 Angular 添加,如第二部分所讨论)。第一个条目有 `remote: true`,这意味着它跟踪的是对资源服务器的调用。你可以看到它发出了对 uri 路径 "/" 的请求,你也可以看到(至关重要地)cookies 和 CSRF 头信息也被发送了。没有 Spring Session,这些头信息对资源服务器将毫无意义,但通过我们的设置,它现在可以使用这些头信息重新构建包含认证和 CSRF 令牌数据的会话。因此,请求被允许,一切正常!

结论

在本节中我们讨论了相当多内容,但我们达到了一个非常好的状态,两个服务器中的样板代码量最少,它们都得到了很好的保护,并且用户体验没有受到影响。仅凭这一点,就已经足够成为使用 API 网关模式的理由了,但实际上我们只触及了它可以用于哪些方面的皮毛(Netflix 使用它做很多事情)。阅读Spring Cloud,了解更多关于如何轻松地向网关添加更多功能的信息。本系列的下一节将通过将认证职责提取到一个独立的服务器(单点登录模式)来稍微扩展应用程序架构。

使用 OAuth2 实现单点登录

在本节中,我们继续讨论如何在“单页应用程序”中使用Spring SecurityAngular。这里,我们展示如何结合使用Spring Security OAuthSpring Cloud来扩展我们的 API 网关,实现单点登录(Single Sign On)和对后端资源的 OAuth2 令牌认证。这是本系列文章的第五部分,你可以通过阅读第一部分来了解应用程序的基本构建模块或从头开始构建,或者你也可以直接查看 Github 上的源代码。在上一节中,我们构建了一个小型分布式应用程序,该应用程序使用Spring Session来认证后端资源,并使用Spring Cloud在 UI 服务器中实现了嵌入式 API 网关。在本节中,我们将认证职责提取到一个独立的服务器,使我们的 UI 服务器成为(潜在的)众多单点登录(Single Sign On)应用程序中第一个连接到授权服务器的应用。这在当今许多应用程序中是一种常见模式,无论是在企业还是社交初创公司。我们将使用一个 OAuth2 服务器作为认证器,这样我们也可以用它来为后端资源服务器颁发令牌。Spring Cloud 将自动将访问令牌中继到我们的后端,使我们能够进一步简化 UI 和资源服务器的实现。

提醒:如果你正在使用示例应用程序学习本节内容,请务必清除浏览器的 cookie 和 HTTP Basic 凭据缓存。在 Chrome 中,针对单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。

创建 OAuth2 授权服务器

我们的第一步是创建一个新的服务器来处理认证和令牌管理。按照第一部分中的步骤,我们可以从Spring Boot Initializr开始。例如,在类 UN*X 系统上使用 curl:

$ curl https://start.spring.io/starter.tgz -d dependencies=web,security -d name=authserver | tar -xzvf -

然后你可以将该项目(默认情况下是一个普通的 Maven Java 项目)导入到你喜欢的 IDE 中,或者直接在命令行中使用文件和 "mvn" 命令。

添加 OAuth2 依赖

我们需要添加Spring OAuth依赖,所以在我们的POM中,我们添加:

pom.xml
<dependency>
  <groupId>org.springframework.security.oauth</groupId>
  <artifactId>spring-security-oauth2</artifactId>
</dependency>

授权服务器的实现相当容易。一个最小版本看起来像这样:

AuthserverApplication.java
@SpringBootApplication
@EnableAuthorizationServer
public class AuthserverApplication extends WebMvcConfigurerAdapter {

  public static void main(String[] args) {
    SpringApplication.run(AuthserverApplication.class, args);
  }

}

我们只需要做最后一件事(在添加 `@EnableAuthorizationServer` 之后):

application.properties
---
...
security.oauth2.client.clientId: acme
security.oauth2.client.clientSecret: acmesecret
security.oauth2.client.authorized-grant-types: authorization_code,refresh_token,password
security.oauth2.client.scope: openid
---

这注册了一个名为“acme”的客户端,带有 secret 和一些授权许可类型,包括“authorization_code”。

现在让我们在 9999 端口运行它,并使用一个可预测的密码进行测试:

application.properties
server.port=9999
security.user.password=password
server.contextPath=/uaa
...

我们还设置了上下文路径,使其不使用默认的 (“/”),因为否则你可能会将 localhost 上其他服务器的 cookies 发送到错误的服务器。所以,启动服务器,我们可以确保它正常工作:

$ mvn spring-boot:run

或者在你的 IDE 中启动 `main()` 方法。

测试授权服务器

我们的服务器使用了 Spring Boot 默认的安全设置,因此就像第一部分中的服务器一样,它将受到 HTTP Basic 认证的保护。要启动一个授权码令牌授予(authorization code token grant)流程,你可以访问授权端点,例如 http://localhost:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com,一旦认证通过,你将被重定向到 example.com 并附带一个授权码,例如 http://example.com/?code=jYWioI

对于这个示例应用程序,我们创建了一个没有注册重定向 URI 的客户端“acme”,这使我们能够重定向到 example.com。在生产应用程序中,你应该始终注册重定向 URI(并使用 HTTPS)。

可以在令牌端点上使用“acme”客户端凭据将授权码交换为访问令牌:

$ curl acme:acmesecret@localhost:9999/uaa/oauth/token  \
-d grant_type=authorization_code -d client_id=acme     \
-d redirect_uri=http://example.com -d code=jYWioI
{"access_token":"2219199c-966e-4466-8b7e-12bb9038c9bb","token_type":"bearer","refresh_token":"d193caf4-5643-4988-9a4a-1c03c9d657aa","expires_in":43199,"scope":"openid"}

访问令牌是一个 UUID (“2219199c…”),由服务器中的内存令牌存储提供支持。我们还得到了一个刷新令牌,当当前访问令牌过期时,我们可以使用它来获取新的访问令牌。

由于我们允许客户端“acme”使用“password”授权类型,我们也可以使用 curl 和用户凭据直接从令牌端点获取令牌,而不是通过授权码。这不适合基于浏览器的客户端,但对于测试很有用。

如果你点击了上面的链接,你会看到 Spring OAuth 提供的默认 UI。起初我们将使用这个默认 UI,之后我们可以像在第二部分中对独立服务器所做的那样,回来对其进行增强。

修改资源服务器

如果我们接着第四部分的内容,我们的资源服务器正在使用Spring Session进行认证,因此我们可以移除它并用 Spring OAuth 替换。我们还需要移除 Spring Session 和 Redis 依赖,所以替换以下内容:

pom.xml
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

替换为以下内容:

pom.xml
<dependency>
  <groupId>org.springframework.security.oauth</groupId>
  <artifactId>spring-security-oauth2</artifactId>
</dependency>

然后从主应用程序类中移除会话 `Filter`,并将其替换为方便的 `@EnableResourceServer` 注解(来自 Spring Security OAuth2):

ResourceApplication.java
@SpringBootApplication
@RestController
@EnableResourceServer
class ResourceApplication {

  @RequestMapping("/")
  public Message home() {
    return new Message("Hello World");
  }

  public static void main(String[] args) {
    SpringApplication.run(ResourceApplication.class, args);
  }
}

通过这一改变,应用程序已准备好请求访问令牌,而不是使用 HTTP Basic,但我们需要修改配置才能真正完成该过程。我们将添加少量外部配置(在 "application.properties" 中),以允许资源服务器解码收到的令牌并验证用户身份。

application.properties
...
security.oauth2.resource.userInfoUri: http://localhost:9999/uaa/user

这告诉服务器可以使用令牌访问 "/user" 端点,并使用该端点派生身份验证信息(这有点类似于 Facebook API 中的 "/me" 端点)。它实际上提供了一种资源服务器解码令牌的方式,正如 Spring OAuth2 中 ResourceServerTokenServices 接口所表达的那样。

运行应用程序并使用命令行客户端访问主页

$ curl -v localhost:9000
> GET / HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:9000
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
...
< WWW-Authenticate: Bearer realm="null", error="unauthorized", error_description="An Authentication object was not found in the SecurityContext"
< Content-Type: application/json;charset=UTF-8
{"error":"unauthorized","error_description":"An Authentication object was not found in the SecurityContext"}

您将看到一个 401 响应,其中包含一个指示需要持有者令牌的 "WWW-Authenticate" 头部。

userInfoUri 绝不是将资源服务器连接到令牌解码方式的唯一方法。事实上,它有点像最低公分母(并且不是规范的一部分),但 OAuth2 提供商(如 Facebook、Cloud Foundry、Github)经常提供,并且还有其他选择。例如,您可以将用户身份验证编码到令牌本身中(例如使用 JWT),或使用共享的后端存储。CloudFoundry 中还有一个 /token_info 端点,它提供比用户信息端点更详细的信息,但需要更彻底的身份验证。不同的选项(自然)提供不同的好处和权衡,但对这些选项的全面讨论超出了本节的范围。

实现用户端点

在授权服务器上,我们可以轻松添加该端点

AuthserverApplication.java
@SpringBootApplication
@RestController
@EnableAuthorizationServer
@EnableResourceServer
public class AuthserverApplication {

  @RequestMapping("/user")
  public Principal user(Principal user) {
    return user;
  }

  ...

}

我们添加了与 第二部分 中的 UI 服务器相同的 @RequestMapping,以及 Spring OAuth 的 @EnableResourceServer 注解,该注解默认情况下保护授权服务器中的所有内容,除了 "/oauth/*" 端点。

有了这个端点,我们就可以测试它和 greeting 资源了,因为它们现在都接受由授权服务器创建的持有者令牌。

$ TOKEN=2219199c-966e-4466-8b7e-12bb9038c9bb
$ curl -H "Authorization: Bearer $TOKEN" localhost:9000
{"id":"03af8be3-2fc3-4d75-acf7-c484d9cf32b1","content":"Hello World"}
$ curl -H "Authorization: Bearer $TOKEN" localhost:9999/uaa/user
{"details":...,"principal":{"username":"user",...},"name":"user"}

(用您自己的授权服务器获得的访问令牌值替换,以便自己运行它。)

UI 服务器

我们需要完成的应用程序的最后一部分是 UI 服务器,它提取身份验证部分并委托给授权服务器。因此,就像 资源服务器 一样,我们首先需要移除 Spring Session 和 Redis 依赖项,并用 Spring OAuth2 替换它们。由于我们在 UI 层使用了 Zuul,我们实际使用的是 spring-cloud-starter-oauth2 而不是直接使用 spring-security-oauth2(这会设置一些用于通过代理中继令牌的自动配置)。

完成后,我们也可以移除会话过滤器和 "/user" 端点,并设置应用程序重定向到授权服务器(使用 @EnableOAuth2Sso 注解)。

UiApplication.java
@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication {

  public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

...

}

回顾 第四部分,UI 服务器凭借 @EnableZuulProxy 的功能充当 API 网关,我们可以在 YAML 中声明路由映射。因此 "/user" 端点可以被代理到授权服务器。

application.yml
zuul:
  routes:
    resource:
      path: /resource/**
      url: http://localhost:9000
    user:
      path: /user/**
      url: http://localhost:9999/uaa/user

最后,我们需要将应用程序更改为 WebSecurityConfigurerAdapter,因为它现在将用于修改 @EnableOAuth2Sso 设置的 SSO 过滤器链中的默认值。

SecurityConfiguration.java
@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception {
      http
          .logout().logoutSuccessUrl("/").and()
          .authorizeRequests().antMatchers("/index.html", "/app.html", "/")
          .permitAll().anyRequest().authenticated().and()
          .csrf()
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
    }

}

主要的更改(除了基类名)是匹配器移到自己的方法中,并且不再需要 formLogin()。显式的 logout() 配置明确添加了一个不受保护的成功 URL,这样对 /logout 的 XHR 请求将成功返回。

@EnableOAuth2Sso 注解还需要一些强制性的外部配置属性,以便能够连接到正确的授权服务器并进行身份验证。因此,我们在 application.yml 中需要以下内容。

application.yml
security:
  ...
  oauth2:
    client:
      accessTokenUri: http://localhost:9999/uaa/oauth/token
      userAuthorizationUri: http://localhost:9999/uaa/oauth/authorize
      clientId: acme
      clientSecret: acmesecret
    resource:
      userInfoUri: http://localhost:9999/uaa/user

大部分内容是关于 OAuth2 客户端("acme")和授权服务器位置的。还有一个 userInfoUri(就像在资源服务器中一样),这样用户可以在 UI 应用程序本身中进行身份验证。

如果您希望 UI 应用程序能够自动刷新过期的访问令牌,您必须将一个 OAuth2RestOperations 注入到执行中继的 Zuul 过滤器中。您可以通过简单地创建该类型的 bean 来实现此目的(详情请查看 OAuth2TokenRelayFilter)。
@Bean
protected OAuth2RestTemplate OAuth2RestTemplate(
    OAuth2ProtectedResourceDetails resource, OAuth2ClientContext context) {
  return new OAuth2RestTemplate(resource, context);
}

在客户端

在前端 UI 应用程序上,我们还需要做一些调整来触发重定向到授权服务器。在这个简单的演示中,我们可以将 Angular 应用程序精简到其核心要素,以便您更清楚地看到正在发生的事情。因此,我们暂时放弃使用表单或路由,回到单个 Angular 组件。

app.component.ts
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import 'rxjs/add/operator/finally';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {

  title = 'Demo';
  authenticated = false;
  greeting = {};

  constructor(private http: HttpClient) {
    this.authenticate();
  }

  authenticate() {

    this.http.get('user').subscribe(response => {
        if (response['name']) {
            this.authenticated = true;
            this.http.get('resource').subscribe(data => this.greeting = data);
        } else {
            this.authenticated = false;
        }
    }, () => { this.authenticated = false; });

  }
  logout() {
      this.http.post('logout', {}).finally(() => {
          this.authenticated = false;
      }).subscribe();
  }

}

AppComponent 处理所有事务,包括获取用户详细信息,如果成功,则获取 greeting。它还提供了 logout 函数。

现在我们需要为这个新组件创建模板。

app.component.html

<div class="container">
  <ul class="nav nav-pills">
    <li><a>Home</a></li>
    <li><a href="login">Login</a></li>
    <li><a (click)="logout()">Logout</a></li>
  </ul>
</div>
<div class="container">
<h1>Greeting</h1>
<div [hidden]="!authenticated">
	<p>The ID is {{greeting.id}}</p>
	<p>The content is {{greeting.content}}</p>
</div>
<div [hidden]="authenticated">
	<p>Login to see your greeting</p>
</div>

并将其作为 <app-root/> 包含在主页中。

请注意,“登录”的导航链接是一个带有 href 的常规链接(而不是 Angular 路由)。它指向的 "/login" 端点由 Spring Security 处理,如果用户未进行身份验证,将导致重定向到授权服务器。

它是如何工作的?

现在一起运行所有服务器,并在浏览器中访问 UI:http://localhost:8080。点击“登录”链接,您将被重定向到授权服务器进行身份验证(HTTP Basic 弹窗)并批准令牌授权(白标 HTML),然后重定向回 UI 中的主页,主页上的 greeting 是使用与 UI 身份验证相同的令牌从 OAuth2 资源服务器获取的。

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

动词 路径 状态 响应

GET

/

200

index.html

GET

/*.js

200

来自 Angular 的资源

GET

/user

302

重定向到登录页面

GET

/login

302

重定向到授权服务器

GET

(uaa)/oauth/authorize

401

(忽略)

GET

/login

302

重定向到授权服务器

GET

(uaa)/oauth/authorize

200

HTTP Basic 身份验证在此发生

POST

(uaa)/oauth/authorize

302

用户批准授权,重定向到 /login

GET

/login

302

重定向到主页

GET

/user

200

(代理的)JSON 验证用户

GET

/app.html

200

主页的 HTML 片段

GET

/resource

200

(代理)JSON greeting

带 (uaa) 前缀的请求是发往授权服务器的。标记为“忽略”的响应是 Angular 在 XHR 调用中收到的响应,由于我们不处理这些数据,它们就被丢弃了。我们在处理 "/user" 资源时确实查找已验证的用户,但由于在第一次调用中不存在,该响应被丢弃了。

在 UI 的 "/trace" 端点中(向下滚动到底部),您将看到代理的后端请求,目标是 "/user" 和 "/resource",使用 remote:true 和持有者令牌而不是 cookie(就像 第四部分 中一样)进行身份验证。Spring Cloud Security 为我们处理了这一切:通过识别我们使用了 @EnableOAuth2Sso@EnableZuulProxy,它已经确定(默认情况下)我们希望将令牌中继到代理的后端。

与前面章节一样,尝试使用不同的浏览器访问 "/trace",这样可以避免身份验证交叉的可能性(例如,如果您使用 Chrome 测试 UI,则使用 Firefox)。

退出体验

如果您点击“退出”链接,您会看到主页发生变化(greeting 不再显示),因此用户不再通过 UI 服务器进行身份验证。但是,如果您再次点击“登录”,您实际上不需要再次通过授权服务器的身份验证和批准流程(因为您没有退出授权服务器)。关于这是否是理想的用户体验,人们的意见分歧很大,这是一个众所周知的棘手问题(单点退出:Science Direct 文章Shibboleth 文档)。理想的用户体验在技术上可能不可行,而且您有时也必须怀疑用户是否真的想要他们所说的。 “我想‘退出’让我退出”听起来足够简单,但显而易见的回答是,“退出什么?您是想退出由该 SSO 服务器控制的所有系统,还是仅仅退出您点击了‘退出’链接的那个系统?”如果您感兴趣,本教程的 后续章节 对此有更深入的讨论。

结论

我们的 Spring Security 和 Angular 技术栈的浅尝之旅即将结束。我们现在拥有一个不错的架构,在 UI/API 网关、资源服务器和授权服务器/令牌授予方这三个独立组件中职责分明。所有层中的非业务代码量现在已降至最低,并且很容易看出在哪里可以通过更多业务逻辑来扩展和改进实现。接下来的步骤将是整理授权服务器中的 UI,并可能添加更多测试,包括对 JavaScript 客户端的测试。另一个有趣的 M 任务是提取所有样板代码并将其放入一个库(例如 "spring-security-angular")中,该库包含 Spring Security 和 Spring Session 自动配置以及 Angular 部分中导航控制器的 webjars 资源。阅读了本系列中的章节后,任何希望学习 Angular 或 Spring Security 内部工作原理的人可能会感到失望,但如果您想了解它们如何良好地协同工作以及一点点配置如何能产生很大作用,那么希望您会有一次愉快的体验。Spring Cloud 是新的,这些示例在编写时需要快照版本,但现在已有候选发布版本,GA 版本即将发布,因此请查看并 通过 Githubgitter.im 发送一些反馈。

本系列的 下一节 将讨论访问决策(超越身份验证)并在同一代理后使用多个 UI 应用程序。

附录:授权服务器的 Bootstrap UI 和 JWT 令牌

您可以在 Github 源代码 中找到此应用程序的另一个版本,该版本具有漂亮的登录页面和用户批准页面,实现方式类似于我们在 第二部分 中实现登录页面的方式。它还使用 JWT 对令牌进行编码,因此资源服务器无需使用 "/user" 端点,而是可以从令牌本身中提取足够的信息进行简单的身份验证。浏览器客户端仍然使用它,通过 UI 服务器代理,以便确定用户是否已验证(与实际应用程序中可能对资源服务器进行的调用次数相比,它不需要经常这样做)。

多个 UI 应用程序和一个网关

在本节中,我们将继续 讨论 如何在“单页应用程序”中使用 Spring SecurityAngular。这里我们将展示如何将 Spring SessionSpring Cloud 结合使用,以整合我们在第二和第四部分构建的系统的特性,最终实际构建出三个职责截然不同的单页应用程序。目标是构建一个网关(类似于 第四部分),该网关不仅用于 API 资源,还用于从后端服务器加载 UI。通过使用网关将身份验证传递到后端,我们简化了 第二部分 中处理令牌的复杂性。然后我们扩展系统,展示如何在后端做出本地的、细粒度的访问决策,同时仍在网关处控制身份和身份验证。这对于构建分布式系统是一个非常强大的模型,并且具有许多优点,我们将在介绍我们构建的代码中的功能时进行探讨。

提示:如果您使用示例应用程序学习本节内容,请务必清除浏览器缓存中的 cookie 和 HTTP Basic 凭据。在 Chrome 中,最好的方法是打开新的隐身窗口。

目标架构

这是我们打算开始构建的基本系统的图片

Components of the System

与本系列中的其他示例应用程序一样,它包含一个 UI(HTML 和 JavaScript)和一个资源服务器。与 第四节 中的示例一样,它有一个网关,但在这里网关是独立的,不属于 UI 的一部分。UI 实际上成为了后端的一部分,这使我们在重新配置和重新实现功能方面有了更多选择,并且带来了我们稍后将看到的其他好处。

浏览器访问网关处理所有事务,它不需要知道后端架构(从根本上说,它不知道后端存在)。浏览器在此网关中做的一件事是身份验证,例如,它像 第二节 中那样发送用户名和密码,并获得一个 cookie 作为回报。在后续请求中,它会自动提供 cookie,网关将其传递给后端。客户端无需编写代码即可启用 cookie 传递。后端使用 cookie 进行身份验证,并且由于所有组件共享会话,它们共享关于用户的相同信息。这与 第五节 不同,在第五节中,cookie 必须在网关中转换为访问令牌,然后该访问令牌必须由所有后端组件独立解码。

第四节 中所述,网关简化了客户端和服务器之间的交互,并提供了一个小型、定义明确的界面来处理安全性。例如,我们不需要担心 跨域资源共享(Cross Origin Resource Sharing),这是一种值得庆幸的缓解,因为它很容易出错。

我们将要构建的完整项目的源代码位于此处 Github,因此如果您愿意,可以直接克隆项目并从那里开始工作。该系统的最终状态中有一个额外的组件("double-admin"),所以现在可以忽略它。

构建后端

在此架构中,后端与我们在 第三节 中构建的 "spring-session" 示例非常相似,唯一的例外是它实际上不需要登录页面。这里最简单的方法可能是从第三节复制 "resource" 服务器,并从 第一节"basic" 示例中获取 UI。要从 "basic" UI 转换到我们想要的 UI,我们只需添加几个依赖项(就像我们在第三节首次使用 Spring Session 时一样)。

pom.xml
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

由于这现在是一个 UI,因此不需要 "/resource" 端点。完成此操作后,您将拥有一个非常简单的 Angular 应用程序(与 "basic" 示例中的相同),这将极大地简化其行为的测试和推理。

最后,我们希望此服务器作为后端运行,因此我们将为其指定一个非默认端口进行监听(在 application.properties 中)。

application.properties
server.port: 8081
security.sessions: NEVER

如果这 application.properties全部内容,那么应用程序将是安全的,并且可供名为 "user"、密码随机(但会在启动时打印在控制台上,日志级别为 INFO)的用户访问。“security.sessions”设置意味着 Spring Security 将接受 cookie 作为身份验证令牌,但除非 cookie 已存在,否则不会创建它们。

资源服务器

资源服务器很容易从我们现有的示例之一生成。它与 第三节 中的 "spring-session" 资源服务器相同:只需一个 "/resource" 端点和 Spring Session 来获取分布式会话数据。我们希望此服务器拥有一个非默认端口进行监听,并且希望能够在会话中查找身份验证,因此我们需要以下配置(在 application.properties 中)。

application.properties
server.port: 9000
security.sessions: NEVER

我们将通过 POST 请求更改消息资源,这是本教程中的一个新功能。这意味着我们将在后端需要 CSRF 保护,并且需要进行常规操作,使 Spring Security 与 Angular 良好协作。

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

完成的示例位于 此处 github,如果您想查看一下。

网关

对于网关的初步实现(最简单可行的方案),我们可以直接使用一个空的 Spring Boot Web 应用程序并添加 @EnableZuulProxy 注解。正如我们在 第一节 中看到的,有几种方法可以做到这一点,其中一种是使用 Spring Initializr 生成骨架项目。更容易的是使用 Spring Cloud Initializr,它做的事情相同,但针对的是 Spring Cloud 应用程序。使用与第一节中相同的命令行操作序列

$ mkdir gateway && cd gateway
$ curl https://cloud-start.spring.io/starter.tgz -d style=web \
  -d style=security -d style=cloud-zuul -d name=gateway \
  -d style=redis | tar -xzvf -

然后您可以将该项目(默认情况下是正常的 Maven Java 项目)导入您喜欢的 IDE,或者直接使用文件并在命令行上运行 "mvn"。如果您想从那里开始,github 上有一个版本,但它包含一些我们暂时不需要的额外功能。

从空白的 Initializr 应用程序开始,我们添加 Spring Session 依赖项(就像上面的 UI 中一样)。网关已准备就绪可以运行,但它还不知道我们的后端服务,所以我们只需在其 application.yml 中进行设置(如果您进行了上面的 curl 操作,则从 application.properties 重命名)。

application.yml
zuul:
  sensitive-headers:
  routes:
    ui:
      url: http://localhost:8081
   resource:
      url: http://localhost:9000
security:
  user:
    password:
      password
  sessions: ALWAYS

代理中有 2 个路由,它们都使用 sensitive-headers 属性向下传递 cookie,UI 和资源服务器各一个,并且我们设置了默认密码和会话持久化策略(告诉 Spring Security 在身份验证时总是创建会话)。最后一点很重要,因为我们希望身份验证以及随之而来的会话在网关中进行管理。

运行起来

我们现在有三个组件,运行在 3 个端口上。如果您将浏览器指向 http://localhost:8080/ui/,您应该会收到 HTTP Basic 挑战,并且可以使用 "user/password"(您在网关中的凭据)进行身份验证,一旦完成,您应该会在 UI 中看到 greeting,这是通过代理调用后端资源服务器获取的。

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

动词 路径 状态 响应

GET

/ui/

401

浏览器提示进行身份验证

GET

/ui/

200

index.html

GET

/ui/*.js

200

Angular 静态资源

GET

/ui/js/hello.js

200

应用程序逻辑

GET

/ui/user

200

身份验证

GET

/resource/

200

JSON 问候语

您可能看不到 401,因为浏览器将主页加载视为一次单独的交互。所有请求都经过代理(网关中目前除了用于管理的 Actuator 端点外,没有其他内容)。

万岁,成功了!您现在有两个后端服务器,其中一个是 UI,每个都具有独立的功能,并且能够独立进行测试,它们通过您控制并配置了身份验证的安全网关连接在一起。如果后端对浏览器不可访问也没关系(实际上这可能是一个优势,因为它让您对物理安全性有更多的控制)。

添加登录表单

就像在 第一节 的 "basic" 示例中一样,我们现在可以将登录表单添加到网关,例如通过复制 第二节 的代码。这样做时,我们还可以在网关中添加一些基本的导航元素,这样用户就不必知道代理中 UI 后端的路径了。因此,我们首先将 "single" UI 中的静态资源复制到网关,删除消息渲染并在主页中插入一个登录表单(在 <app/> 中的某个位置)。

app.html
<div class="container" [hidden]="authenticated">
	<form role="form" (submit)="login()">
		<div class="form-group">
			<label for="username">Username:</label> <input type="text"
				class="form-control" id="username" name="username"
				[(ngModel)]="credentials.username" />
		</div>
		<div class="form-group">
			<label for="password">Password:</label> <input type="password"
				class="form-control" id="password" name="password"
				[(ngModel)]="credentials.password" />
		</div>
		<button type="submit" class="btn btn-primary">Submit</button>
	</form>
</div>

我们将使用一个漂亮的巨大导航按钮取代消息渲染。

index.html
<div class="container" [hidden]="!authenticated">
	<a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>

如果您正在查看 github 中的示例,它还有一个带有“退出”按钮的最小导航栏。这是登录表单的截图。

Login Page

为了支持登录表单,我们需要一些 TypeScript 代码,其中包含一个实现了我们在 <form/> 中声明的 login() 函数的组件,并且我们需要设置 authenticated 标志,以便主页根据用户是否已验证身份来呈现不同的内容。例如

app.component.ts
include::src/app/app.component.ts

其中 login() 函数的实现类似于 第二节 中的实现。

我们可以使用 self 来存储 authenticated 标志,因为这个简单的应用程序中只有一个组件。

如果我们运行这个增强型网关,就不必记住 UI 的 URL,只需加载主页并跟随链接即可。这是已验证用户的首页。

Home Page

后端中的细粒度访问决策

到目前为止,我们的应用程序功能上与 第三节第四节 中的应用程序非常相似,但增加了一个专用的网关层。额外层的好处可能还不明显,但我们可以通过稍微扩展系统来强调它。假设我们想使用该网关暴露另一个后端 UI,供用户“管理”主 UI 中的内容,并且我们希望将此功能的访问权限限制给具有特殊角色的用户。因此,我们将在代理后面添加一个“Admin”应用程序,系统将如下所示。

Components of the System

网关的 application.yml 中有一个新组件(Admin)和一个新路由。

application.yml
zuul:
  sensitive-headers:
  routes:
    ui:
      url: http://localhost:8081
    admin:
      url: http://localhost:8082
    resource:
      url: http://localhost:9000

上面的框图中,网关框中(绿色字体)标明了现有 UI 可供具有“USER”角色的用户使用,以及访问 Admin 应用程序需要“ADMIN”角色的事实。“ADMIN”角色的访问决策可以在网关中应用,在这种情况下它将出现在 WebSecurityConfigurerAdapter 中,或者它也可以在 Admin 应用程序本身中应用(我们将在下面看到如何做到这一点)。

因此,首先创建一个新的 Spring Boot 应用程序,或者复制现有 UI 并进行编辑。除了名称之外,您最初不需要更改 UI 应用程序中的太多内容。完成的应用程序位于 此处 Github

假设在 Admin 应用程序内部,我们希望区分“READER”和“WRITER”角色,以便允许(比如说)审计员用户查看主要管理员用户所做的更改。这是一个细粒度的访问决策,其规则仅在后端应用程序中已知且应该只在该处已知。在网关中,我们只需确保我们的用户帐户具有所需的角色,并且该信息是可用的,但网关无需知道如何解释它。在网关中,我们创建用户帐户以保持示例应用程序的自包含性。

SecurityConfiguration.class
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

  @Autowired
  public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
      .withUser("user").password("password").roles("USER")
    .and()
      .withUser("admin").password("admin").roles("USER", "ADMIN", "READER", "WRITER")
    .and()
      .withUser("audit").password("audit").roles("USER", "ADMIN", "READER");
  }

}

其中“admin”用户已增加了 3 个新角色(“ADMIN”、“READER”和“WRITER”),并且我们还添加了一个“audit”用户,该用户拥有“ADMIN”访问权限,但没有“WRITER”权限。

在生产系统中,用户帐户数据将在后端数据库(很可能是目录服务)中进行管理,而不是硬编码在 Spring 配置中。连接到此类数据库的示例应用程序很容易在互联网上找到,例如在 Spring Security 示例中。

访问决策放在 Admin 应用程序中进行。“ADMIN”角色(该后端全局需要此角色)的访问决策我们在 Spring Security 中实现。

SecurityConfiguration.java
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

@Override
  protected void configure(HttpSecurity http) throws Exception {
    http
    ...
      .authorizeRequests()
        .antMatchers("/index.html", "/").permitAll()
        .antMatchers("/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
    ...
  }

}

对于“READER”和“WRITER”角色,应用程序本身是分层的,并且由于应用程序是用 JavaScript 实现的,因此我们需要在那里做出访问决策。一种方法是创建一个主页,通过路由器嵌入一个计算视图。

app.component.html
<div class="container">
	<h1>Admin</h1>
	<router-outlet></router-outlet>
</div>

路由在组件加载时计算。

app.component.ts
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {

  user: {};

  constructor(private app: AppService, private http: HttpClient, private router: Router) {
    app.authenticate(response => {
      this.user = response;
      this.message();
    });
  }

  logout() {
    this.http.post('logout', {}).subscribe(function() {
        this.app.authenticated = false;
        this.router.navigateByUrl('/login');
    });
  }

  message() {
    if (!this.app.authenticated) {
      this.router.navigate(['/unauthenticated']);
    } else {
      if (this.app.writer) {
        this.router.navigate(['/write']);
      } else {
        this.router.navigate(['/read']);
      }
    }
  }
...
}

应用程序做的第一件事是检查用户是否已验证身份,并通过查看用户数据来计算路由。路由在主模块中声明。

app.module.ts
const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'read'},
  { path: 'read', component: ReadComponent},
  { path: 'write', component: WriteComponent},
  { path: 'unauthenticated', component: UnauthenticatedComponent},
  { path: 'changes', component: ChangesComponent}
];

每个组件(每个路由对应一个)都必须单独实现。这里以 ReadComponent 为例。

read.component.ts
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  templateUrl: './read.component.html'
})
export class ReadComponent {

  greeting = {};

  constructor(private http: HttpClient) {
    http.get('/resource').subscribe(data => this.greeting = data);
  }

}
read.component.html
<h1>Greeting</h1>
<div>
	<p>The ID is {{greeting.id}}</p>
	<p>The content is {{greeting.content}}</p>
</div>

WriteComponent 与之类似,但有一个用于更改后端消息的表单。

write.component.ts
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  templateUrl: './write.component.html'
})
export class WriteComponent {

  greeting = {};

  constructor(private http: HttpClient) {
    this.http.get('/resource').subscribe(data => this.greeting = data);
  }

  update() {
    this.http.post('/resource', {content: this.greeting['content']}).subscribe(response => {
      this.greeting = response;
    });
  }

}
write.component.html
<form (submit)="update()">
	<p>The ID is {{greeting.id}}</p>
	<div class="form-group">
		<label for="username">Content:</label> <input type="text"
			class="form-control" id="content" name="content" [(ngModel)]="greeting.content"/>
	</div>
	<button type="submit" class="btn btn-primary">Submit</button>
</form>

AppService 还需要提供计算路由所需的数据,因此在 authenticate() 函数中我们看到

app.service.ts
        http.get('/user').subscribe(function(response) {
            var user = response.json();
            if (user.name) {
                self.authenticated = true;
                self.writer = user.roles && user.roles.indexOf("ROLE_WRITER")>0;
            } else {
                self.authenticated = false;
                self.writer = false;
            }
            callback && callback(response);
        })

为了在后端支持此函数,我们需要 /user 端点,例如在我们主要的应用程序类中。

AdminApplication.java
@SpringBootApplication
@RestController
public class AdminApplication {

  @RequestMapping("/user")
  public Map<String, Object> user(Principal user) {
    Map<String, Object> map = new LinkedHashMap<String, Object>();
    map.put("name", user.getName());
    map.put("roles", AuthorityUtils.authorityListToSet(((Authentication) user)
        .getAuthorities()));
    return map;
  }

  public static void main(String[] args) {
    SpringApplication.run(AdminApplication.class, args);
  }

}
角色名称从 "/user" 端点返回时带有 "ROLE_" 前缀,以便我们可以将其与其他类型的权限区分开来(这是 Spring Security 的约定)。因此,JavaScript 中需要 "ROLE_" 前缀,但在 Spring Security 配置中则不需要,因为方法名称已明确表示“角色”是操作的重点。

网关中支持 Admin UI 的更改

我们还将在网关中使用这些角色进行访问决策(这样我们就可以根据条件显示指向 admin UI 的链接),因此我们也应该将“roles”添加到网关的 "/user" 端点中。完成后,我们可以添加一些 JavaScript 代码来设置一个标志,指示当前用户是“ADMIN”。在 authenticated() 函数中

app.component.ts
this.http.get('user', {headers: headers}).subscribe(data => {
  this.authenticated = data && data['name'];
  this.user = this.authenticated ? data['name'] : '';
  this.admin = this.authenticated && data['roles'] && data['roles'].indexOf('ROLE_ADMIN') > -1;
});

并且在用户退出时,我们还需要将 admin 标志重置为 false

app.component.ts
this.logout = function() {
    http.post('logout', {}).subscribe(function() {
        self.authenticated = false;
        self.admin = false;
    });
}

然后在 HTML 中,我们可以根据条件显示一个新链接。

app.component.html
<div class="container" [hidden]="!authenticated">
	<a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>
<br />
<div class="container" [hidden]="!authenticated || !admin">
	<a class="btn btn-primary" href="/admin/">Go To Admin Interface</a>
</div>

运行所有应用程序并访问 http://localhost:8080 查看结果。一切都应该正常工作,UI 应该根据当前已验证的用户而改变。

我们为何在此?

现在我们拥有一个不错的小系统,其中包含 2 个独立的用户界面和一个后端资源服务器,所有这些都通过网关中的同一身份验证进行保护。网关充当微代理的事实使得后端安全问题的实现变得极其简单,并且它们可以自由地专注于自己的业务问题。使用 Spring Session(再次)避免了大量的麻烦和潜在的错误。

一个强大的特性是后端可以独立拥有它们喜欢的任何类型的身份验证(例如,如果您知道其物理地址和一组本地凭据,可以直接访问 UI)。网关施加了一组完全不相关的约束,只要它能够验证用户身份并为其分配满足后端访问规则的元数据。这是一种出色的设计,能够独立开发和测试后端组件。如果我们愿意,我们可以回退到外部 OAuth2 服务器(就像 第五节 中那样,甚至完全不同的东西)来进行网关处的身份验证,而无需触及后端。

这种架构(单个网关控制身份验证,以及所有组件之间共享会话令牌)的一个额外特性是“单点退出”,我们在 第五节 中认为难以实现的功能,在这里免费获得。更准确地说,完成的系统中自动提供了一种特定的单点退出用户体验方法:如果用户从任何一个 UI(网关、UI 后端或 Admin 后端)退出,他将从所有其他 UI 中退出,前提是每个独立的 UI 都以相同的方式实现了“退出”功能(使会话失效)。

致谢:再次感谢所有帮助我开发本系列的人,特别是 Rob WinchThorsten Späth 对各章节和源代码进行的仔细审查。自从 第一节 发布以来,它变化不大,但所有其他部分都根据读者的评论和见解进行了改进,因此也要感谢所有阅读了这些章节并积极参与讨论的人。

测试 Angular 应用程序

在本节中,我们将继续 讨论 如何在“单页应用程序”中使用 Spring SecurityAngular。这里我们将展示如何使用 Angular 测试框架为客户端代码编写和运行单元测试。您可以通过阅读 第一节 来了解应用程序的基本构建块或从头构建,或者您也可以直接访问 Github 源代码(与第一部分相同的源代码,但现在添加了测试)。本节实际上使用 Spring 或 Spring Security 的代码很少,但它以一种在通常的 Angular 社区资源中可能不太容易找到的方式涵盖了客户端测试,而且我们认为这种方式对于大多数 Spring 用户来说会很方便。

提醒:如果你正在使用示例应用程序学习本节内容,请务必清除浏览器的 cookie 和 HTTP Basic 凭据缓存。在 Chrome 中,针对单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。

编写规范

“basic”应用程序中的“app”组件非常简单,因此对其进行全面测试不会花费太多时间。以下是代码回顾。

app.component.ts
include::basic/src/app/app.component.ts

我们面临的主要挑战是在测试中提供 http 对象,这样我们就可以对它在组件中的使用方式进行断言。实际上,在我们面临这个挑战之前,我们需要能够创建一个组件实例,这样我们就可以测试它加载时发生的事情。下面是实现方法。

使用 ng new 创建的应用程序中的 Angular 构建已经包含一个 spec 和一些用于运行它的配置。生成的 spec 位于 "src/app" 中,它以以下方式开始。

app.component.ts
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [],
      declarations: [
        AppComponent
      ]
    }).compileComponents();
  }));
  it('should create the app', async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  }));
  ...
}

在这个非常基础的测试套件中,我们有以下重要元素

  1. 我们使用一个函数来 describe() 正在被测试的事物(在此示例中是 "AppComponent")。

  2. 在该函数内部,我们提供一个 beforeEach() 回调函数,它加载 Angular 组件。

  3. 行为通过调用 it() 来表达,在该调用中我们用文字描述期望是什么,然后提供一个进行断言的函数。

  4. 测试环境在发生任何其他事情之前初始化。这是大多数 Angular 应用程序的样板代码。

这里的测试函数非常简单,它实际上只断言组件存在,因此如果失败,测试就会失败。

改进单元测试:模拟 HTTP 后端

为了将 spec 提升到生产级别,我们需要实际断言控制器加载时会发生什么。由于它会调用 http.get(),我们需要模拟该调用,以避免仅为单元测试而运行整个应用程序。为此,我们使用 Angular 的 HttpClientTestingModule

app.component.spec
Unresolved directive in testing.adoc - include::basic/src/app/app.component.spec[indent=0]

这里的新部分是

  • beforeEach()TestBed 中将 HttpClientTestingModule 声明为 imports。

  • 在测试函数中,我们在创建组件之前设置对后端的期望,告诉它预期调用 'resource/',以及响应应该是什么。

运行 Specs

要运行我们的测试代码,我们可以使用项目设置时创建的便捷脚本执行 ./ng test(或 ./ng build)。它也会作为 Maven 生命周期的一部分运行,因此 ./mvnw install 也是运行测试的好方法,这正是您的 CI 构建中将要发生的事情。

端到端测试

Angular 也有一个标准的构建设置,用于使用浏览器和生成的 JavaScript 进行“端到端测试”。这些测试以“specs”的形式写在顶层的 e2e 目录中。本教程中的所有示例都包含一个非常简单的端到端测试,该测试在 Maven 生命周期中运行(因此,如果您在任何“ui”应用中运行 mvn install,将看到一个浏览器窗口弹出)。

结论

能够在现代 Web 应用中运行 Javascript 单元测试非常重要,这是本系列到目前为止我们忽略(或回避)的话题。在本期中,我们介绍了如何编写测试、如何在开发时运行测试以及更重要的是如何在持续集成环境中运行测试的基本要素。我们采用的方法并非适合所有人,所以如果您选择不同的方式,请不要觉得不好,但请确保您具备所有那些要素。我们在这里的做法可能会让传统的 Java 企业开发者感到舒适,并且与他们现有的工具和流程很好地集成,所以如果您属于那一类别,我希望您会发现它是一个有用的起点。关于使用 Angular 和 Jasmine 进行测试的更多示例可以在互联网上找到很多,但首选可能是本系列中的“single”示例,该示例现在包含一些最新的测试代码,这些代码比本教程中“basic”示例所需的代码要稍微复杂一些。

从 OAuth2 客户端应用中注销

在本节中,我们继续讨论如何在“单页应用”中使用Spring SecurityAngular。在这里,我们展示如何采用 OAuth2 示例并添加不同的注销体验。许多实现 OAuth2 单点登录的人发现,他们需要解决如何“干净地”注销的问题?之所以是个难题,是因为没有一个单一的正确方法,您选择的解决方案将取决于您期望的用户体验,以及您愿意承担的复杂程度。复杂性的原因在于系统中可能存在多个浏览器会话,每个会话都连接到不同的后端服务器,因此当用户从其中一个会话注销时,其他会话应该怎么办?这是教程的第九部分,您可以阅读第一部分来了解应用的基本构建块或从头开始构建,或者您可以直接查看Github 中的源代码

注销模式

本教程中 oauth2 示例的注销用户体验是,您从 UI 应用中注销,但不会从授权服务器中注销,因此当您再次登录 UI 应用时,授权服务器不会再次要求提供凭据。当授权服务器是外部的时,这是完全预期、正常和理想的——Google 和其他外部授权服务器提供商既不希望也不允许您从不受信任的应用中从其服务器注销——但如果授权服务器实际上与 UI 属于同一系统,则这不是最佳用户体验。

广义上讲,从作为 OAuth2 客户端进行身份验证的 UI 应用中注销有三种模式

  1. 外部授权服务器(EA,原始示例)。用户将授权服务器视为第三方(例如,使用 Facebook 或 Google 进行身份验证)。您不希望在应用会话结束时从授权服务器中注销。您确实希望批准所有授权。本教程中的 oauth2(和 oauth2-vanilla)示例实现了这种模式。

  2. 网关和内部授权服务器(GIA)。您只需要从 2 个应用中注销,并且它们在用户看来是同一系统的一部分。通常您希望自动批准所有授权。

  3. 单点注销(SL)。一个授权服务器和多个 UI 应用,它们都有自己的身份验证,当用户从其中一个注销时,您希望所有其他应用也随之注销。天真的实现很可能因为网络分区和服务器故障而失败——您基本上需要全局一致的存储。

有时,即使您有外部授权服务器,您也希望控制身份验证并添加内部访问控制层(例如,授权服务器不支持的作用域或角色)。那么最好使用 EA 进行身份验证,但有一个内部授权服务器可以为令牌添加您需要的附加详细信息。这个OAuth2 教程中的 auth-server 示例非常简单地展示了如何做到这一点。然后,您可以将 GIA 或 SL 模式应用于包含内部授权服务器的系统。

如果您不想使用 EA,这里有一些选项

  • 在浏览器客户端中同时从授权服务器和 UI 应用注销。简单的方法,通过一些仔细的 CRSF 和 CORS 配置可以实现。不支持 SL。

  • 令牌可用后立即从授权服务器注销。在 UI 端难以实现,因为令牌是在 UI 端获取的,而那里没有授权服务器的会话 cookie。Spring OAuth 中有一个功能请求,展示了一种有趣的方法:在生成授权码后立即使授权服务器中的会话失效。Github issue 中包含一个实现会话失效的切面 (aspect),但作为 HandlerInterceptor 实现更容易。不支持 SL。

  • 通过与 UI 相同的网关代理授权服务器,并希望一个 cookie 足以管理整个系统的状态。这不起作用,除非存在共享会话,但这在某种程度上违背了目标(否则授权服务器没有会话存储)。仅当所有应用之间共享会话时才支持 SL。

  • 网关中的 Cookie 中继。您将网关用作身份验证的真实来源,并且授权服务器拥有所需的所有状态,因为网关管理 cookie 而不是浏览器。浏览器永远不会拥有来自多个服务器的 cookie。不支持 SL。

  • 使用令牌作为全局身份验证,并在用户从 UI 应用注销时使其失效。缺点:要求客户端应用使令牌失效,这实际上不是它们的设计目的。SL 是可能的,但适用通常的限制。

  • 在授权服务器中创建和管理一个全局会话令牌(除了用户令牌之外)。这是 OpenId Connect 采用的方法,它确实为 SL 提供了一些选项,但会增加一些额外的机制。这些选项都无法摆脱通常的分布式系统限制:如果网络和应用节点不稳定,则无法保证在需要时在所有参与者之间共享注销信号。所有注销规范都仍处于草案阶段,以下是一些规范链接:会话管理前置通道注销后置通道注销

请注意,在 SL 难以实现或不可能实现的情况下,最好还是将所有 UI 放在单个网关后面。然后您可以使用 GIA,这更容易,来控制整个系统的注销。

在 GIA 模式中很好应用的两个最简单的选项可以在本教程示例中实现如下(以 oauth2 示例为基础进行修改)。

从浏览器同时注销两个服务器

在 UI 应用注销后立即从授权服务器注销,这在浏览器客户端中只需添加几行代码即可实现。例如:

logout() {
    this.http.post('logout', {}).finally(() => {
        self.authenticated = false;
        this.http.post('http://localhost:9999/uaa/logout', {}, {withCredentials:true})
            .subscribe(() => {
                console.log('Logged out');
        });
    }).subscribe();
};

在这个示例中,我们将授权服务器的注销端点 URL 硬编码到 JavaScript 中,但如果需要,很容易将其外部化。它必须直接向授权服务器发送 POST 请求,因为我们也希望会话 cookie 随同发送。XHR 请求只有在我们明确要求 withCredentials:true 时才会附带 cookie 从浏览器发出。

相反,在服务器端我们需要一些 CORS 配置,因为请求来自不同的域。例如,在 WebSecurityConfigurerAdapter 中:

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
	.requestMatchers().antMatchers("/login", "/logout", "/oauth/authorize", "/oauth/confirm_access")
  .and()
    .cors().configurationSource(configurationSource())
    ...
}

private CorsConfigurationSource configurationSource() {
  UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
  CorsConfiguration config = new CorsConfiguration();
  config.addAllowedOrigin("*");
  config.setAllowCredentials(true);
  config.addAllowedHeader("X-Requested-With");
  config.addAllowedHeader("Content-Type");
  config.addAllowedMethod(HttpMethod.POST);
  source.registerCorsConfiguration("/logout", config);
  return source;
}

“/logout”端点得到了特殊处理。它允许从任何来源调用,并明确允许发送凭据(例如 cookie)。允许的头部信息只是 Angular 在示例应用中发送的那些。

除了 CORS 配置外,我们还需要禁用注销端点的 CSRF 保护,因为 Angular 在跨域请求中不会发送 X-XSRF-TOKEN 头部。之前授权服务器不需要任何 CSRF 配置,但很容易为注销端点添加忽略:

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
    .csrf()
      .ignoringAntMatchers("/logout/**")
    ...
}
放弃 CSRF 保护并非真正明智,但对于这种受限的使用场景,您可能愿意容忍它。

通过这两个简单的更改,一个在 UI 应用客户端,一个在授权服务器,您会发现一旦您从 UI 应用注销,再次登录时,总是会提示输入密码。

另一个有用的更改是将 OAuth2 客户端设置为自动批准,这样用户就不必批准令牌授权。这在内部授权服务器中很常见,用户不会将其视为独立的系统。在 AuthorizationServerConfigurerAdapter 中,您只需在客户端初始化时设置一个标志:

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
  clients.inMemory().withClient("acme")
    ...
  .autoApprove(true);
}

在授权服务器中使会话失效

如果您不想放弃注销端点的 CSRF 保护,可以尝试另一种简单的方法,即在令牌授权后立即(实际上是在生成授权码后立即)使授权服务器中的用户会话失效。这也非常容易实现:从 oauth2 示例开始,只需为 OAuth2 端点添加一个 HandlerInterceptor

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints)
    throws Exception {
  ...
  endpoints.addInterceptor(new HandlerInterceptorAdapter() {
    @Override
    public void postHandle(HttpServletRequest request,
        HttpServletResponse response, Object handler,
        ModelAndView modelAndView) throws Exception {
      if (modelAndView != null
          && modelAndView.getView() instanceof RedirectView) {
        RedirectView redirect = (RedirectView) modelAndView.getView();
        String url = redirect.getUrl();
        if (url.contains("code=") || url.contains("error=")) {
          HttpSession session = request.getSession(false);
          if (session != null) {
            session.invalidate();
          }
        }
      }
    }
  });
}

这个拦截器查找一个 RedirectView,这是一个信号,表明用户正在被重定向回客户端应用,并检查 URL 中是否包含授权码或错误。如果您也使用隐式授权,可以添加“token=”。

通过这个简单的更改,一旦您完成身份验证,授权服务器中的会话就已经失效了,因此无需从客户端尝试管理它。当您从 UI 应用注销,然后再次登录时,授权服务器不会认出您,并提示输入凭据。本教程的源代码中的 oauth2-logout 示例实现了这种模式。这种方法的缺点是您不再拥有真正的单点登录——系统中属于您的其他应用会发现授权服务器会话已失效,它们必须再次提示进行身份验证——如果存在多个应用,这不是一个很好的用户体验。

结论

在本节中,我们了解了如何实现从 OAuth2 客户端应用注销的几种不同模式(以教程第五节中的应用作为起点),并讨论了其他一些模式的选项。这些选项并非详尽无遗,但应该能让您很好地了解其中涉及的权衡取舍,并提供一些工具来思考适合您的用例的最佳解决方案。本节中只有几行 JavaScript 代码,而且这些代码并非特定于 Angular(它只是为 XHR 请求添加了一个标志),因此所有这些经验和模式都适用于本指南示例应用之外的更广泛范围。一个反复出现的主题是,在存在多个 UI 应用和单个授权服务器的情况下,所有单点注销(SL)方法都倾向于在某些方面存在缺陷:您能做的最好是选择让用户最不感到不适的方法。如果您有一个内部授权服务器和一个由许多组件组成的系统,那么对于用户来说,唯一感觉像是一个单一系统的架构可能是所有用户交互都通过一个网关。

想编写新指南或为现有指南做出贡献?请查看我们的贡献指南

所有指南的代码均采用 ASLv2 许可发布,文字内容采用 署名-禁止演绎创作共享许可

获取代码