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 检出本教程的代码。

运行应用程序

一旦 Angular 应用程序准备就绪,您的应用程序就可以在浏览器中加载(尽管它还没有做太多事情)。在命令行上,您可以执行以下操作:

$ mvn spring-boot:run

然后打开浏览器访问 https://: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 中,我们通过 @Component 注解定义了“selector”(HTML 元素的名称)和一段要渲染的 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 使用双花括号占位符 {{greeting.id}}{{greeting.content}} 在 HTML 中渲染。

添加动态内容

到目前为止,我们有一个问候语硬编码的应用程序。这对于学习如何协同工作很有用,但实际上我们期望内容来自后端服务器,所以让我们创建一个 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==

浏览器每次请求都发送用户名和密码(因此请记住在生产环境中 exclusively 使用 HTTPS)。这与“Angular”无关,因此它适用于您选择的 JavaScript 框架或非框架。

有什么问题?

表面上看,我们做得相当不错,它简洁,易于实现,所有数据都通过秘密密码保护,并且即使我们更改了前端或后端技术,它仍然可以工作。但存在一些问题。

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

  • 认证 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 的构造函数中。routes 用于 AppModule 的导入内部,以设置到 "/"("home" 控制器)和 "/login"("login" 控制器)的链接。

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

UI 组件都是“声明”,而服务胶合剂是“提供者”。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

  • 有一个注销功能作为组件的属性暴露,我们稍后可以使用它向后端发送注销请求。它在 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) 提交表单的按钮。您不需要在表单标签上设置动作,所以最好根本不要设置。还有一个错误消息,只有当 Angular 模型包含 error 时才显示。表单控件使用 Angular Forms 中的 ngModel 在 HTML 和 Angular 控制器之间传递数据,在这种情况下,我们使用一个 credentials 对象来保存用户名和密码。

认证过程

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

提交登录表单

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

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() 函数还会相应地设置本地 $scope.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 请求头

如果您此时运行应用程序,您会发现浏览器会弹出一个基本身份验证对话框(用于用户名和密码)。它之所以这样做,是因为它看到来自 /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 属性是它的基类,除了构造函数之外,我们真正需要做的就是覆盖 intercept() 函数,该函数总是由 Angular 调用,可用于添加额外的头部。

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

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

注销

应用程序的功能几乎完成了。我们需要做的最后一件事是实现我们在主页上草拟的注销功能。如果用户已通过身份验证,我们就会显示一个“注销”链接,并将其与 AppComponent 中的 logout() 函数挂钩。请记住,它会向“/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 内置了对 CSRF(它称为“XSRF”)的支持,基于 cookie。

因此,在服务器端,我们需要一个自定义过滤器来发送 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

/用户

401

未经授权 (被忽略)

GET

/home

200

首页

GET

/用户

401

未经授权 (被忽略)

GET

/resource

401

未经授权 (被忽略)

GET

/用户

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 保护是不够的,因为即使您不在从应用程序加载的页面中(跨站脚本攻击,也称为 XSS),浏览器也会自动发送它。请求头不会自动发送,因此来源受到控制。您可能会在我们的应用程序中看到 CSRF 令牌作为 cookie 发送给客户端,因此我们将看到浏览器自动将其发送回,但提供保护的是请求头。

救命,我的应用程序将如何扩展?

“但是等等……”您可能会说,“在单页应用程序中使用会话状态不是非常糟糕吗?”这个问题的答案必须是“大部分情况下”,因为使用会话进行身份验证和 CSRF 保护绝对是一件好事。这种状态必须存储在某个地方,如果您将其从会话中取出,您将不得不将其存储在其他地方,并在服务器和客户端手动管理它。这只是更多的代码,可能需要更多的维护,并且通常是重复发明一个完美的轮子。

“但是,但是……”您会反驳道,“我现在如何水平扩展我的应用程序?”这是您上面提出的“真正”问题,但它往往被简化为“会话状态不好,我必须是无状态的”。别慌。这里要记住的重点是,安全性*是有状态的*。您不能拥有一个安全的、无状态的应用程序。那么您将把状态存储在哪里?这就是全部。 Rob Winch2014 年 Spring Exchange 上发表了一个非常有益且富有洞察力的演讲,解释了状态的必要性(以及它的普遍性——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 SecurityAngular。在这里,我们首先将应用程序中用作动态内容的“greeting”资源拆分到一个单独的服务器中,首先作为未受保护的资源,然后通过不透明令牌进行保护。这是系列文章的第三部分,您可以阅读第一部分来了解应用程序的基本构建模块或从头开始构建,或者您可以直接访问 Github 上的源代码,它分为两部分:一部分是资源未受保护,另一部分是通过令牌保护

如果您正在使用示例应用程序学习本节,请务必清除浏览器缓存中的 cookie 和 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('https://: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

然后打开浏览器访问 https://:9000,您应该会看到带有问候语的 JSON。您可以在 application.properties(在“src/main/resources”中)中烘焙端口更改:

application.properties
server.port: 9000

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

CORS 协商

浏览器尝试与我们的资源服务器协商,以根据 跨域资源共享 协议确定是否允许访问它。这不是 Angular 的责任,所以就像 cookie 契约一样,它将与浏览器中的所有 JavaScript 这样工作。这两个服务器没有声明它们具有共同的来源,因此浏览器拒绝发送请求,导致 UI 损坏。

为了解决这个问题,我们需要支持 CORS 协议,这涉及一个“预检”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 添加到类路径中:

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: https://: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 提供了一个 barebones 的 Filter 实现,让您开始自己的工作(例如,请参阅 AbstractPreAuthenticatedProcessingFilterTokenService)。不过,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 上这需要一个 VM)。Github 上的源代码中有一个 docker-compose.yml 文件,您可以在命令行上使用 docker-compose up 轻松运行它。如果您在 VM 中执行此操作,Redis 服务器将在与 localhost 不同的主机上运行,因此您要么需要将其隧道到 localhost,要么配置应用程序以指向 application.properties 中正确的 spring.redis.host

从 UI 发送自定义令牌

唯一缺少的部分是存储中数据键的传输机制。键是 HttpSession ID,所以如果我们在 UI 客户端中能够获取该键,我们可以将其作为自定义头部发送到资源服务器。因此,“home”控制器需要更改,以便它将头部作为 greeting 资源的 HTTP 请求的一部分发送。例如:

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

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

我们没有直接访问 "https://: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 工作?

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

至少我们仍然在使用会话,这是有道理的,因为 Spring Security 和 Servlet 容器知道如何毫不费力地完成这项工作。但是我们不能继续使用 cookie 来传输身份验证令牌吗?那会很好,但有一个原因它不起作用,那就是浏览器不允许我们这样做。您可以从 JavaScript 客户端直接查看浏览器的 cookie 存储,但有一些限制,而且理由充分。特别是,您无法访问服务器作为“HttpOnly”发送的 cookie(您会发现会话 cookie 默认情况下就是这种情况)。您也无法在传出请求中设置 cookie,因此我们无法设置“SESSION”cookie(这是 Spring Session 的默认 cookie 名称),我们必须使用自定义的“X-Session”头部。这两个限制都是为了您自身的保护,以便恶意脚本无法在未经适当授权的情况下访问您的资源。

TL;DR UI 和资源服务器没有共同的来源,因此它们不能共享 cookie(即使我们可以使用 Spring Session 强制它们共享会话)。

结论

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

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

API 网关

在本节中,我们将继续讨论如何在“单页应用程序”中使用 Spring SecurityAngular。在这里,我们将展示如何构建一个 API 网关,使用 Spring Cloud 来控制对后端资源的身份验证和访问。这是系列文章的第四部分,您可以阅读第一部分来了解应用程序的基本构建模块或从头开始构建,或者您可以直接访问 Github 上的源代码。在上一节中,我们构建了一个小型分布式应用程序,它使用 Spring Session 来验证后端资源,并使用 Spring Cloud 在 UI 服务器中实现了一个嵌入式 API 网关。在本节中,我们将身份验证职责提取到一个单独的服务器中,使我们的 UI 服务器成为授权服务器的众多潜在单点登录应用程序中的第一个。这是当今许多应用程序中的常见模式,无论是在企业还是在社交初创公司中。我们将使用 OAuth2 服务器作为身份验证器,以便我们也可以使用它为后端资源服务器授予令牌。Spring Cloud 将自动将访问令牌中继到我们的后端,并使我们能够进一步简化 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 服务器中的本地资源映射到 外部配置 ("application.yml") 中的远程资源:

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

这意味着“将该服务器中模式为 /resource/** 的路径映射到 localhost:9000 远程服务器中的相同路径”。简单而有效(好吧,包括 YAML 在内是 6 行,但你并非总是需要那么多)!

要使其正常工作,我们只需要类路径上正确的内容。为此,我们的 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”——它是一个启动器 POM,就像 Spring Boot 的一样,但它管理我们这个 Zuul 代理所需的依赖项。我们还使用了 <dependencyManagement>,因为我们希望能够依赖所有传递依赖项的版本都是正确的。

客户端消费代理

进行了这些更改后,我们的应用程序仍然可以工作,但我们尚未实际使用新代理,直到我们修改客户端。幸运的是,这非常简单。我们只需要还原从“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 启动它),系统就会正常工作。在新的浏览器中加载 UI 的主页 https://:8080 并登录,您将在主页上看到来自后端的渲染消息。

它是如何工作的?

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

动词 路径 状态 响应

GET

/

200

index.html

GET

/*.js

200

来自 Angular 的资产

GET

/用户

401

未经授权 (被忽略)

GET

/resource

401

对资源的未经身份验证的访问

GET

/用户

200

JSON 认证用户

GET

/resource

200

(代理)JSON 问候语

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

我们可以通过查看 UI 服务器中的“/trace”端点(来自 Spring Boot Actuator,我们随 Spring Cloud 依赖项一起添加的)来观察反向代理的实际运行情况。在新浏览器中访问 https://:8080/trace(如果您还没有,请为您的浏览器安装一个 JSON 插件,使其更易读)。您需要使用 HTTP Basic(浏览器弹出窗口)进行身份验证,但与登录表单相同的凭据有效。在开始时或附近,您应该会看到一对请求,类似于这样:

尝试使用不同的浏览器,以避免身份验证交叉的可能性(例如,如果您在测试 UI 时使用了 Chrome,请使用 Firefox)——这不会阻止应用程序工作,但如果跟踪中包含来自同一浏览器的混合身份验证,则会使其更难阅读。
/追踪
{
  "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”的请求,您可以看到 cookie(由浏览器添加)和 CSRF 头部(由 Angular 添加,如第二部分所述)。第一个条目有 remote: true,这意味着它正在跟踪对资源服务器的调用。您可以看到它发往 URI 路径“/”,并且您可以看到(至关重要的是)cookie 和 CSRF 头部也已发送。如果没有 Spring Session,这些头部对资源服务器将毫无意义,但我们设置的方式使其现在可以使用这些头部重新构建包含身份验证和 CSRF 令牌数据的会话。因此,请求被允许,我们一切顺利!

结论

我们在本节中涵盖了很多内容,但我们达到了一个非常好的境界,即我们的两个服务器中样板代码量极少,它们都得到了很好的保护,并且用户体验也没有受到影响。仅此一点就足以成为使用 API 网关模式的理由,但实际上我们只是触及了它可能用于什么(Netflix 用它来做很多事情)的皮毛。阅读 Spring Cloud 以了解如何轻松地向网关添加更多功能。本系列的下一节将通过将身份验证职责提取到单独的服务器(单点登录模式)来扩展应用程序架构。

使用 OAuth2 进行单点登录

在本节中,我们将继续讨论如何在“单页应用程序”中使用 Spring SecurityAngular。在这里,我们将展示如何使用 Spring Security OAuthSpring Cloud 来扩展我们的 API 网关,以实现单点登录和 OAuth2 令牌认证到后端资源。这是系列文章的第五部分,您可以阅读第一部分来了解应用程序的基本构建模块或从头开始构建,或者您可以直接访问 Github 上的源代码。在上一节中,我们构建了一个小型分布式应用程序,它使用 Spring Session 来验证后端资源,并使用 Spring Cloud 在 UI 服务器中实现了一个嵌入式 API 网关。在本节中,我们将身份验证职责提取到一个单独的服务器中,使我们的 UI 服务器成为授权服务器的众多潜在单点登录应用程序中的第一个。这是当今许多应用程序中的常见模式,无论是在企业还是在社交初创公司中。我们将使用 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”,带有一个秘密和一些授权的授予类型,包括“authorization_code”。

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

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

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

$ mvn spring-boot:run

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

测试授权服务器

我们的服务器正在使用 Spring Boot 默认安全设置,所以像第一部分中的服务器一样,它将受到 HTTP Basic 身份验证的保护。要启动授权码令牌授予,请访问授权端点,例如 https://:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com ;一旦您通过身份验证,您将获得一个重定向到 example.com 的请求,并附带一个授权码,例如 http://example.com/?code=jYWioI

为了本示例应用程序的目的,我们创建了一个没有注册重定向的客户端“acme”,这使我们能够重定向到 example.com。在生产应用程序中,您应该始终注册重定向(并使用 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。一开始我们将使用这个,稍后我们可以像第二部分为自包含服务器所做的那样,对其进行增强。

更改资源服务器

如果从第四部分继续,我们的资源服务器正在使用 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: https://: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: https://:9000
    user:
      path: /user/**
      url: https://: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: https://:9999/uaa/oauth/token
      userAuthorizationUri: https://:9999/uaa/oauth/authorize
      clientId: acme
      clientSecret: acmesecret
    resource:
      userInfoUri: https://: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处理所有事情,获取用户详细信息,如果成功,则获取问候语。它还提供了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 处理,如果用户未认证,它将导致重定向到授权服务器。

它是如何工作的?

现在同时运行所有服务器,并在浏览器中访问 https://:8080 处的 UI。单击“登录”链接,您将被重定向到授权服务器进行认证(HTTP Basic 弹出窗口)并批准令牌授予(白标签 HTML),然后重定向到 UI 中的主页,其中包含使用与认证 UI 相同的令牌从 OAuth2 资源服务器获取的问候语。

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

动词 路径 状态 响应

GET

/

200

index.html

GET

/*.js

200

来自 angular 的资产

GET

/用户

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

/用户

200

(代理) JSON 认证用户

GET

/app.html

200

主页的 HTML 片段

GET

/resource

200

(代理)JSON 问候语

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

在 UI 的“/trace”端点(向下滚动到底部),您将看到代理的后端请求“/user”和“/resource”,其中remote:true和 bearer 令牌而不是 cookie(就像在第四部分中一样)用于认证。Spring Cloud Security 已经为我们处理了这个问题:通过识别我们有@EnableOAuth2Sso@EnableZuulProxy,它已经确定(默认情况下)我们希望将令牌转发到代理的后端。

与前几节一样,尝试使用不同的浏览器访问“/trace”,以避免认证交叉(例如,如果您使用 Chrome 测试 UI,则使用 Firefox)。

注销体验

如果您单击“注销”链接,您将看到主页发生变化(问候语不再显示),因此用户不再使用 UI 服务器进行认证。但是,如果再次单击“登录”,您实际上不需要再次经历授权服务器中的认证和批准周期(因为您尚未从授权服务器注销)。关于这是否是理想的用户体验,意见不一,这是一个众所周知棘手的问题(单点注销:Science Direct 文章Shibboleth 文档)。理想的用户体验可能在技术上不可行,而且您有时也必须怀疑用户是否真的想要他们所说的。 “我希望‘注销’能让我注销”听起来很简单,但显而易见的回答是,“从哪里注销?您是想从这个 SSO 服务器控制的所有系统中注销,还是只从您点击‘注销’链接的那个系统中注销?”如果您感兴趣,本教程的稍后一节将更深入地讨论。

结论

我们对 Spring Security 和 Angular 堆栈的浅层探索到此结束。我们现在拥有一个漂亮的架构,在三个独立的组件中具有明确的职责:UI/API 网关、资源服务器和授权服务器/令牌授予器。所有层中的非业务代码量现在都最小化了,并且很容易看出在哪里通过更多的业务逻辑来扩展和改进实现。接下来的步骤将是清理授权服务器中的 UI,并可能添加更多的测试,包括对 JavaScript 客户端的测试。另一个有趣的任务是提取所有样板代码并将其放入一个库中(例如“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 必须在网关中转换为访问令牌,然后访问令牌必须由所有后端组件独立解码。

第四节中一样,网关简化了客户端和服务器之间的交互,并提供了一个小巧、定义明确的界面来处理安全性。例如,我们不需要担心跨域资源共享,这是一个可喜的解脱,因为很容易出错。

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

构建后端

在此架构中,后端与我们在第三节中构建的 “spring-session” 示例非常相似,不同之处在于它实际上不需要登录页面。要达到我们想要的效果,最简单的方法可能是从第三节复制“resource”服务器,并从第一节“basic” 示例中获取 UI。要从“basic”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 作为认证令牌,但除非它们已经存在,否则不会创建它们。

资源服务器

资源服务器很容易从我们现有的示例中生成。它与第三节中的“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 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: https://:8081
   resource:
      url: https://:9000
security:
  user:
    password:
      password
  sessions: ALWAYS

代理中有 2 条路由,它们都使用sensitive-headers属性将 cookie 传递到下游,一条用于 UI,一条用于资源服务器,我们还设置了默认密码和会话持久化策略(告诉 Spring Security 在认证时总是创建会话)。最后一点很重要,因为我们希望在网关中管理认证和会话。

启动并运行

我们现在有三个组件,运行在三个端口上。如果您将浏览器指向 https://:8080/ui/,您应该会收到 HTTP Basic 挑战,您可以以“user/password”身份进行认证(您在网关中的凭据),一旦您完成认证,您应该会在 UI 中看到一个问候语,通过代理对资源服务器的后端调用来实现。

如果您使用一些开发者工具(通常按 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: https://:8081
    admin:
      url: https://:8082
    resource:
      url: https://: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”),我们还添加了一个具有“ADMIN”访问权限但没有“WRITER”访问权限的“audit”用户。

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

访问决策在 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 的网关更改

我们还将使用角色在网关中进行访问决策(以便我们可以有条件地显示到管理员 UI 的链接),所以我们也应该在网关中的“/user”端点中添加“roles”。一旦到位,我们可以添加一些 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>

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

我们为什么在这里?

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

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

这种架构的一个额外特性(单个网关控制认证,以及所有组件共享会话令牌)是“单点注销”这一功能,我们在第五节中指出该功能难以实现,现在免费提供。更准确地说,我们完成的系统中自动提供了一种特定的单点注销用户体验方法:如果用户从任何 UI(网关、UI 后端或管理后端)注销,他将从所有其他 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 后端

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

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

这里的新部分是

  • beforeEach()中,将HttpClientTestingModule声明为TestBed中的导入。

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

运行规格

要运行我们的测试代码,我们可以使用设置项目时创建的便利脚本./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 中很难实现,因为令牌是在那里获取的,并且在那里您没有认证服务器的会话 cookie。Spring OAuth 中有一个功能请求,展示了一种有趣的方法:一旦生成授权码,就使认证服务器中的会话失效。Github 问题包含一个实现会话失效的切面,但作为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('https://:9999/uaa/logout', {}, {withCredentials:true})
            .subscribe(() => {
                console.log('Logged out');
        });
    }).subscribe();
};

在此示例中,我们将认证服务器注销端点 URL 硬编码到 JavaScript 中,但如果需要,很容易将其外部化。它必须是直接 POST 到认证服务器,因为我们希望会话 cookie 也一起发送。只有当我们明确要求withCredentials:true时,XHR 请求才会从浏览器发出并附带 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,这是一个信号,表示用户正在被重定向回客户端应用程序,并检查该位置是否包含授权码或错误。如果您也使用隐式授权,则可以添加“token=”。

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

结论

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

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

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

获取代码