一个安全的单页面应用程序
在本教程中,我们将展示 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。例如,在类 UNIX 系统上使用 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 插件)中,您也可以通过向导在 文件->新建->Spring Starter Project
中创建和导入项目。然后跳转到 下一节。IntelliJ IDEA 和 NetBeans 也有类似的功能。
添加一个 Angular 应用
如今,Angular(或任何现代前端框架)中单页应用的核心将是基于 Node.js 的构建。Angular 提供了一些工具可以快速设置这一点,因此我们将使用这些工具,同时保留像其他 Spring Boot 应用程序一样使用 Maven 构建的选项。有关如何设置 Angular 应用的详细信息请参阅其他文档,或者您可以直接从 GitHub 检出本教程的代码。
运行应用程序
一旦 Angular 应用程序准备就绪,您的应用程序就可以在浏览器中加载了(尽管它目前还做不了太多事情)。在命令行中,您可以执行以下操作:
$ mvn spring-boot:run
在浏览器中访问 http://localhost:8080。当您加载主页时,应该会看到一个要求输入用户名和密码的浏览器对话框(用户名为 "user",密码在启动时的控制台日志中打印)。实际上还没有任何内容(或者可能是 ng
CLI 默认的 "hero" 教程内容),所以您应该会看到一个几乎是空白的页面。
如果您不喜欢从控制台日志中提取密码,只需将此添加到
src/main/resources
目录下的application.properties
文件中: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端点来获取问候语。在您的application class(位于"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" 的 2 次请求,因为存在 CORS 协商。
仔细查看这些请求,您会发现它们都带有 "Authorization" 头信息,类似于这样:
Authorization: Basic dXNlcjpwYXNzd29yZA==
浏览器会在每次请求时发送用户名和密码(因此请记住在生产环境中仅使用 HTTPS)。这与“Angular”无关,因此它可以与您选择的 JavaScript 框架或非框架一起使用。
这有什么问题?
乍一看,我们似乎做得相当不错,代码简洁、易于实现,所有数据都由秘密密码保护,而且即使我们更换前端或后端技术,它仍然可以正常工作。但还存在一些问题。
-
基本认证仅限于用户名和密码认证。
-
认证界面无处不在但不够美观(浏览器对话框)。
-
没有针对跨站请求伪造(CSRF)的保护措施。
CSRF 在我们的应用程序中并不是真正的问题,因为它只需要 GET 后端资源(即服务器中没有状态被更改)。一旦您的应用程序中有 POST、PUT 或 DELETE 操作,按照任何合理的现代标准,它就不再安全了。
在本系列的下一部分中,我们将扩展应用程序以使用基于表单的身份验证,这比 HTTP Basic 灵活得多。一旦我们有了表单,就需要 CSRF 保护,Spring Security 和 Angular 都提供了一些开箱即用的功能来帮助实现这一点。剧透:我们将需要使用 HttpSession
。
感谢:我要感谢所有帮助我开发本系列的人,特别是 Rob Winch 和 Thorsten Spaeth,他们对文本和源代码进行了仔细的审查,并教会了我一些关于我自认为最熟悉的部分的技巧。
登录页面
在本节中,我们将继续讨论如何在“单页应用程序”中使用 Spring Security 与 Angular 结合。我们将展示如何使用 Angular 通过表单对用户进行身份验证,并获取安全资源以在 UI 中呈现。这是系列章节中的第二部分,您可以通过阅读第一部分来了解应用程序的基本构建模块,或者从头开始构建它,或者您可以直接查看 Github 上的源代码。在第一部分中,我们构建了一个使用 HTTP Basic 认证来保护后端资源的简单应用程序。在本节中,我们添加了一个登录表单,让用户能够控制是否进行身份验证,并修复了第一次迭代中的问题(主要是缺乏 CSRF 保护)。
提醒:如果您正在使用示例应用程序进行本节的操作,请确保清除浏览器缓存中的 cookies 和 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 模块依赖项,这使得我们能够在 AppComponent
的构造函数中注入一个神奇的 router
。routes
在 AppModule
的 imports
中用于设置到 "/"(“主页”控制器)和 "/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 基本认证凭据(如果提供了的话),否则不会发送。它还有一个可选的 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>
由于现在用户可以选择是否登录(之前完全由浏览器控制),我们需要在用户界面中区分安全内容和非安全内容。我们通过添加对(目前尚不存在的)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 配置来完成。
提交登录表单
要提交表单,我们需要定义 login()
函数(我们已经在表单中通过 ng-submit
引用了它)和 credentials
对象(我们通过 ng-model
引用了它)。让我们完善 "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 请求中收到了带有 "WWW-Authenticate" 标头的 401 响应。要阻止这个弹窗,需要抑制这个标头,这个标头来自 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);
}
}
这里的语法是样板代码。Class
的 implements
属性是其基类,除了构造函数之外,我们真正需要做的就是重写 intercept()
函数,这个函数总是由 Angular 调用,可以用来添加额外的头信息。
要安装这个新的 RequestOptions
工厂,我们需要在 AppModule
的 providers
中声明它:
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 基于 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,但服务器会返回“Set-Cookie”以设置“JSESSIONID”(常规的HttpSession
)和“X-XSRF-TOKEN”(我们在上面设置的CSRF cookie)。随后的请求都携带这些cookie,它们非常重要:没有这些cookie,应用程序将无法正常工作,而且它们提供了一些非常基本的安全功能(身份验证和CSRF保护)。当用户进行身份验证后(在POST请求之后),这些cookie的值会发生变化,这是另一个重要的安全特性(防止会话固定攻击)。
对于CSRF保护来说,仅依赖cookie发送回服务器是不够的,因为即使您不在从应用程序加载的页面中(跨站脚本攻击,也称为XSS),浏览器也会自动发送它。而header不会自动发送,因此来源是可以控制的。您可能会看到在我们的应用程序中,CSRF令牌是作为cookie发送给客户端的,所以我们会看到它被浏览器自动发送回来,但真正提供保护的是header。
求助,我的应用程序如何扩展?
"但是等等……"你可能会说,"在单页应用中使用会话状态不是非常糟糕吗?"这个问题的答案很可能是"大部分时候是的",因为使用会话来处理认证和CSRF防护绝对是一件好事。这些状态必须存储在某个地方,如果你不将其存储在会话中,你就必须将其放在其他地方,并在服务器和客户端上手动管理。这只会增加更多的代码,可能还需要更多的维护,而且通常是在重新发明一个已经非常完美的轮子。
"但是,但是……"你可能会回应,"我现在该如何水平扩展我的应用程序呢?" 这才是你上面真正想问的问题,但它往往被简化为"会话状态是坏的,我必须是无状态的"。别慌。这里要理解的关键点是,安全性是有状态的。你不可能拥有一个安全且无状态的应用程序。那么,你打算把状态存储在哪里呢?这就是全部的问题。Rob Winch 在 Spring Exchange 2014 上做了一个非常有用的、富有洞察力的演讲,解释了状态的必要性(以及它的无处不在——TCP 和 SSL 都是有状态的,所以无论你是否意识到,你的系统都是有状态的),如果你想要更深入地探讨这个话题,这个演讲可能值得一看。
好消息是您有选择的余地。最简单的选择是将会话数据存储在内存中,并依赖负载均衡器中的粘性会话将来自同一会话的请求路由回同一个 JVM(它们都以某种方式支持这一点)。这对于让您上手来说已经足够好了,并且适用于非常多的使用场景。另一个选择是在应用程序实例之间共享会话数据。只要您严格要求只存储安全数据,它通常很小且不频繁更改(仅在用户登录、注销或会话超时时发生),因此不应该出现任何重大的基础设施问题。使用 Spring Session 实现这一点也非常简单。我们将在本系列的下一部分中使用 Spring Session,因此这里无需详细介绍如何设置它,但实际上只需几行代码和一个 Redis 服务器即可实现,速度非常快。
另一个设置共享会话状态的简单方法是将您的应用程序作为 WAR 文件部署到 Cloud Foundry Pivotal Web Services 并将其绑定到 Redis 服务。
但是,我的自定义令牌实现呢(它是无状态的,看)?
如果您对上一节的回应是这样的,那么请再读一遍,因为可能您第一次没有理解。如果您将令牌存储在某个地方,那么它可能不是无状态的,但即使您没有存储(例如,您使用了 JWT 编码的令牌),您又如何提供 CSRF 保护呢?这很重要。这里有一个经验法则(归功于 Rob Winch):如果您的应用程序或 API 将被浏览器访问,您就需要 CSRF 保护。并不是说没有会话就无法实现,而是您必须自己编写所有代码,而这有什么意义呢?因为这一切已经在 HttpSession
上实现并运行得非常好了(而 HttpSession
又是您所使用的容器的一部分,并且从一开始就内置于规范中)。即使您决定不需要 CSRF,并且拥有一个完美的“无状态”(非基于会话的)令牌实现,您仍然需要在客户端编写额外的代码来使用它,而您本可以将其委托给浏览器和服务器的内置功能:浏览器总是会发送 cookies,服务器总是有一个会话(除非您关闭它)。这些代码不是业务逻辑,也不会为您带来任何收益,它只是额外的开销,更糟糕的是,它还会让您花费更多成本。
结论
我们现在的应用程序已经接近用户在实时环境中所期望的“真实”应用程序,它很可能可以作为模板,用于构建具有该架构(单服务器,包含静态内容和JSON资源)的功能更丰富的应用程序。我们使用HttpSession
来存储安全数据,依靠我们的客户端来尊重和使用我们发送给它们的cookie,我们对这种方式感到满意,因为它让我们能够专注于自己的业务领域。在下一节中,我们将架构扩展为独立的认证和UI服务器,以及一个用于JSON的独立资源服务器。这显然可以轻松推广到多个资源服务器。我们还将引入Spring Session,并展示如何使用它来共享认证数据。
资源服务器
在本节中,我们将继续讨论如何在“单页应用程序”中使用Spring Security与Angular。这里,我们首先将应用程序中用作动态内容的“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('http://localhost:9000').subscribe(data => this.greeting = data);
服务端变更
UI 服务器的改动非常简单:我们只需要移除 greeting 资源的 @RequestMapping
(它原本是 "/resource")。然后我们需要创建一个新的资源服务器,这可以像我们在第一部分中使用 Spring Boot Initializr 那样来完成。例如,在类 UNIX 系统上使用 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,您应该会看到一个包含问候语的 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: 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 发送自定义令牌
唯一缺失的部分是传输机制,用于将存储中数据的密钥传递出去。该密钥是 HttpSession
ID,因此如果能在 UI 客户端获取到该密钥,就可以将其作为自定义标头发送到资源服务器。因此,“home”控制器需要进行修改,以便在请求问候资源时将标头作为 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 会话存储库的位置,以及在传入请求中查找令牌(session ID)的位置。首先,我们需要添加 Spring Session 和 Redis 的依赖,然后我们就可以设置 Filter
:
ResourceApplication.java
@SpringBootApplication
@RestController
class ResourceApplication {
...
@Bean
HeaderHttpSessionStrategy sessionStrategy() {
return new HeaderHttpSessionStrategy();
}
}
创建的 Filter
与 UI 服务器中的镜像相同,因此它将 Redis 配置为会话存储。唯一的区别是它使用了一个自定义的 HttpSessionStrategy
,该策略在请求头中查找(默认为 "X-Auth-Token"),而不是默认的 cookie(名为 "JSESSIONID")。我们还需要防止浏览器在未认证的客户端中弹出对话框——应用程序是安全的,但默认情况下会发送一个 401 响应并附带 WWW-Authenticate: Basic
,因此浏览器会弹出一个要求输入用户名和密码的对话框。实现这一点有多种方法,但由于我们已经让 Angular 发送了一个 "X-Requested-With" 请求头,因此 Spring Security 默认会为我们处理。
为了让资源服务器与我们的新认证方案兼容,还需要进行最后一项更改。Spring Boot 默认的安全性是无状态的,而我们希望将认证信息存储在会话中,因此我们需要在 application.yml
(或 application.properties
)中明确指定:
application.yml
security:
sessions: NEVER
这段代码告诉 Spring Security "永远不要创建会话,但如果会话存在就使用它"(由于用户界面中的身份验证,会话已经存在)。重新启动资源服务器,并在新的浏览器窗口中打开用户界面。
为什么不能全部使用 Cookies 来实现?
我们必须使用自定义头部并在客户端编写代码来填充该头部,这虽然并不特别复杂,但似乎与第二部分中尽可能使用cookies和会话的建议相矛盾。那里的论点是,不这样做会引入额外的不必要的复杂性,而且我们现在的实现确实是我们迄今为止见过的最复杂的:解决方案的技术部分远远超过了业务逻辑(诚然,业务逻辑很小)。这绝对是一个合理的批评(我们计划在本系列的下一部分中解决这个问题),但让我们简单看看为什么不能像对所有事情都使用cookies和会话那样简单。
至少我们仍然在使用会话,这是有道理的,因为 Spring Security 和 Servlet 容器知道如何在不费我们力气的情况下处理它。但是我们难道不能继续使用 cookie 来传递认证令牌吗?这原本是不错的,但有一个原因导致它行不通,那就是浏览器不允许我们这样做。你可以从 JavaScript 客户端随意查看浏览器的 cookie 存储,但有一些限制,而且这些限制是有充分理由的。特别是,你无法访问服务器发送的标记为 "HttpOnly" 的 cookie(你会发现会话 cookie 默认情况下就是这样的)。你也不能在发出的请求中设置 cookie,因此我们无法设置 "SESSION" cookie(这是 Spring Session 默认的 cookie 名称),我们不得不使用自定义的 "X-Session" 头。这些限制都是为了保护你,以防止恶意脚本在没有适当授权的情况下访问你的资源。
简而言之,UI 和资源服务器没有共同的源,因此它们无法共享 cookie(尽管我们可以使用 Spring Session 来强制它们共享会话)。
结论
我们在本系列的第二部分中复制了应用程序的功能:一个带有从远程后端获取的问候语的主页,导航栏中有登录和注销链接。不同的是,问候语来自一个独立运行的资源服务器,而不是嵌入在 UI 服务器中。这给实现增加了相当的复杂性,但好消息是我们有一个主要基于配置(实际上 100% 声明式)的解决方案。我们甚至可以通过将所有新代码提取到库中(Spring 配置和 Angular 自定义指令)来使解决方案 100% 声明式。我们将在接下来的几期之后再处理这个有趣的任务。在下一节中,我们将探讨一种不同的、非常棒的方法来减少当前实现中的所有复杂性:API 网关模式(客户端将所有请求发送到一个地方,并在那里处理身份验证)。
我们在这里使用了 Spring Session 在两个逻辑上不属于同一应用程序的服务器之间共享会话。这是一个巧妙的技巧,而使用“常规”的 JEE 分布式会话是无法实现的。
API 网关
在本节中,我们继续讨论如何在“单页应用”中使用Spring Security与Angular。这里我们将展示如何使用Spring Cloud构建一个API网关,以控制对后端资源的认证和访问。这是系列章节的第四部分,您可以通过阅读第一部分来了解应用的基本构建模块或从头开始构建,或者直接查看Github上的源代码。在上一节中,我们构建了一个简单的分布式应用,它使用Spring Session来认证后端资源。在本节中,我们将UI服务器转变为后端资源服务器的反向代理,解决了上一实现中的问题(由自定义令牌认证引入的技术复杂性),并为我们提供了许多新的选项来控制来自浏览器客户端的访问。
提醒:如果您正在使用示例应用程序学习本节内容,请务必清除浏览器中的 cookies 和 HTTP Basic 认证信息。在 Chrome 中,针对单个服务器的最佳方法是打开一个新的无痕窗口。
创建 API 网关
API 网关是前端客户端的单一入口(和控制点),这些客户端可以是基于浏览器的(如本节中的示例)或移动端。客户端只需知道一个服务器的 URL,后端可以随意重构而无需更改,这是一个显著的优势。在集中化和控制方面还有其他优势:速率限制、身份验证、审计和日志记录。使用 Spring Cloud 实现一个简单的反向代理非常容易。
如果您一直在跟着代码操作,您会知道在上一节末尾的应用程序实现有些复杂,因此这并不是一个理想的迭代起点。然而,有一个中间点我们可以更容易地开始,即后端资源尚未使用 Spring Security 进行保护的地方。该源代码是一个独立的项目,位于Github上,因此我们将从那里开始。它有一个 UI 服务器和一个资源服务器,它们正在相互通信。资源服务器尚未使用 Spring Security,因此我们可以先让系统运行起来,然后再添加这一层。
一行代码实现声明式反向代理
要将其转换为 API 网关,UI 服务器需要进行一个小的调整。在 Spring 配置的某个地方,我们需要添加 @EnableZuulProxy
注解,例如在主(唯一)的application class中:
UiApplication.java
@SpringBootApplication
@RestController
@EnableZuulProxy
public class UiApplication {
...
}
在一个外部配置文件中,我们需要将 UI 服务器中的本地资源映射到 外部配置("application.yml")中的远程资源:
application.yml
security:
...
zuul:
routes:
resource:
path: /resource/**
url: http://localhost: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" —— 它就像 Spring Boot 的 starter POM 一样,但它管理着我们这个 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 服务器可访问),那么软件安全性的缺乏甚至可能不是问题。作为一个简单的演示,我们可以使资源服务器仅在本地主机上可访问。只需将此添加到资源服务器的
application.properties
中:
application.properties
server.address: 127.0.0.1
哇,这真是太简单了!使用仅在您的数据中心内可见的网络地址来执行此操作,您就拥有了一个适用于所有资源服务器和所有用户桌面的安全解决方案。
假设我们决定确实需要在软件层面实现安全性(出于多种原因,这很可能)。这不会成为问题,因为我们只需要将 Spring Security 添加为依赖项(在 resource server 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 问候 |
这与 第二部分 末尾的序列相同,只是由于我们使用了 Spring Session,cookie 名称略有不同("SESSION" 而不是 "JSESSIONID")。但架构不同,最后一个对 "/resource" 的请求是特殊的,因为它被代理到了资源服务器。
我们可以通过查看 UI 服务器中的 "/trace" 端点(来自 Spring Boot Actuator,我们通过 Spring Cloud 依赖项添加了它)来观察反向代理的实际操作。在一个新的浏览器中访问 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和由Angular添加的CSRF头(如第二部分所述)。第一个条目中有remote: true
,这意味着它正在追踪对资源服务器的调用。您可以看到它发送到了一个uri路径"/",并且(关键的是)cookies和CSRF头也被发送了。如果没有Spring Session,这些头对资源服务器来说将毫无意义,但通过我们的设置,资源服务器现在可以使用这些头来重新构建一个包含认证和CSRF令牌数据的会话。因此,请求被允许,我们便可以继续操作了!
结论
在本节中我们讨论了很多内容,但最终达到了一个非常理想的状态:我们的两个服务器中的样板代码量极少,它们都非常安全,同时用户体验也没有受到影响。仅这一点就足以成为使用 API 网关模式的理由,但实际上我们只是浅尝辄止地探讨了它的潜在用途(Netflix 将其用于许多方面)。阅读 Spring Cloud 了解更多关于如何轻松为网关添加更多功能的信息。本系列的下一节将通过将身份验证职责提取到一个单独的服务器(单点登录模式)来进一步扩展应用程序架构。
使用 OAuth2 实现单点登录
在本节中,我们将继续讨论如何在“单页应用”中使用Spring Security与Angular。这里我们将展示如何使用Spring Security OAuth和Spring Cloud来扩展我们的API网关,以实现单点登录(SSO)和对后端资源的OAuth2令牌认证。这是系列章节中的第五部分,您可以通过阅读第一部分来了解应用的基本构建模块或从头开始构建它,或者您可以直接查看Github上的源代码。在上一节中,我们构建了一个小型分布式应用,该应用使用Spring Session对后端资源进行认证,并使用Spring Cloud在UI服务器中实现了一个嵌入式API网关。在本节中,我们将认证职责提取到一个单独的服务器中,使我们的UI服务器成为第一个可能使用授权服务器进行单点登录的应用。这是当今许多应用中的常见模式,无论是在企业还是社交初创公司中。我们将使用OAuth2服务器作为认证器,以便我们还可以使用它为后端资源服务器颁发令牌。Spring Cloud将自动将访问令牌传递到我们的后端,并使我们能够进一步简化UI和资源服务器的实现。
提醒:如果您正在使用示例应用程序学习本节内容,请务必清除浏览器缓存中的 cookies 和 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
...
我们还设置了上下文路径,使其不使用默认路径("/"),因为否则可能会将本地主机上其他服务器的cookies发送到错误的服务器。因此,启动服务器后,我们可以确保它正常运行:
$ mvn spring-boot:run
或者在您的 IDE 中启动 main()
方法。
测试授权服务器
我们的服务器使用了 Spring Boot 的默认安全设置,因此与第一部分中的服务器一样,它将受到 HTTP Basic 认证的保护。要发起一个授权码令牌授权,您需要访问授权端点,例如 http://localhost: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: 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"}
并且你会看到一个带有 "WWW-Authenticate" 头的 401 响应,表明它需要一个 Bearer 令牌。
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;
}
...
}
我们在Part II中为UI服务器添加了一个@RequestMapping
,同时添加了Spring OAuth的@EnableResourceServer
注解,该注解默认保护授权服务器中的所有内容,除了"/oauth/*"端点。
有了这个端点,我们可以测试它和问候资源,因为它们现在都接受由授权服务器创建的承载令牌:
$ 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"}
将access token的值替换为从您自己的授权服务器获取的值,以便您自己使其正常工作。
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);
}
...
}
回顾 Part IV,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
处理所有事情,包括获取用户详细信息,并在成功后显示问候语。它还提供了 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>
并将其包含在主页中作为 。
请注意,“登录”的导航链接是一个带有 href
的常规链接(不是 Angular 路由)。这个链接指向的 "/login" 端点由 Spring Security 处理,如果用户未经过身份验证,将会重定向到授权服务器。
它是如何工作的?
现在将所有服务器一起运行,并在浏览器中访问 http://localhost:8080 的 UI。点击“登录”链接,您将被重定向到授权服务器进行身份验证(HTTP Basic 弹出窗口)并批准令牌授权(默认 HTML),然后使用与 UI 身份验证相同的令牌从 OAuth2 资源服务器获取问候语,并重定向回 UI 的主页。
如果您使用一些开发者工具(通常按 F12 键可以打开,默认在 Chrome 中有效,在 Firefox 中可能需要插件),可以在浏览器中看到浏览器与后端之间的交互。以下是总结:
动词 | 路径 | 状态 | 响应 |
---|---|---|---|
GET | / | 200 | index.html |
GET | /*.js | 200 | 来自 Angular 的静态资源 |
GET | /user | 302 | 重定向到登录页面 |
GET | /login | 302 | 重定向到认证服务器 |
GET | (uaa)/oauth/authorize | 401 | |
GET | /登录 | 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 问候 |
带有 (uaa) 前缀的请求是发送到授权服务器的。标记为 "ignored" 的响应是 Angular 在 XHR 调用中接收到的响应,由于我们不处理这些数据,因此它们被丢弃。我们确实会在 "/user" 资源的情况下寻找已认证的用户,但由于在第一次调用中不存在该用户,因此该响应也被丢弃。
在 UI 的 "/trace" 端点(滚动到底部),您将看到代理的后端请求 "/user" 和 "/resource",其中使用了 remote:true
和承载令牌(而不是像在 Part IV 中那样使用 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 是新的,这些示例在编写时需要使用快照版本,但现在已有发布候选版本,并且即将推出正式版,所以不妨试试看,并通过 Github 或 gitter.im 发送一些反馈。
本系列的 下一章节 将讨论访问决策(超越认证),并介绍在同一代理后面部署多个UI应用程序。
附录: 授权服务器的 Bootstrap UI 和 JWT 令牌
您可以在 Github 的源代码 中找到该应用程序的另一个版本,其中包含一个漂亮的登录页面和用户批准页面,其实现方式与我们在 第二部分 中处理登录页面的方式类似。该版本还使用 JWT 对令牌进行编码,因此资源服务器不再需要调用 "/user" 端点,而是可以直接从令牌中提取足够的信息来进行简单的身份验证。浏览器客户端仍然通过 UI 服务器代理使用它,以便确定用户是否已通过身份验证(与实际应用程序中对资源服务器的调用次数相比,这种情况并不需要频繁发生)。
多个UI应用和一个网关
在本节中,我们将继续讨论如何在“单页应用”中使用Spring Security与Angular。这里我们将展示如何将Spring Session与Spring Cloud结合使用,以整合我们在第二部分和第四部分中构建的系统功能,并最终构建出三个职责截然不同的单页应用。目标是构建一个类似于第四部分中的网关,它不仅用于API资源,还用于从后端服务器加载UI。我们通过使用网关将身份验证传递到后端,简化了第二部分中的令牌处理部分。然后,我们扩展系统,展示如何在后端进行本地、细粒度的访问决策,同时仍然在网关控制身份和认证。这是一个非常强大的模型,适用于构建分布式系统,并且在我们介绍所构建代码的功能时,可以探索其诸多优势。
提醒:如果您正在使用示例应用程序进行本节操作,请确保清除浏览器缓存中的 cookies 和 HTTP Basic 认证信息。在 Chrome 浏览器中,最好的方法是打开一个新的隐身窗口。
目标架构
以下是我们将要构建的基本系统的示意图:
与本系列中的其他示例应用程序一样,它包含一个UI(HTML和JavaScript)和一个资源服务器。与第IV节中的示例类似,它有一个网关,但在这里它是独立的,不是UI的一部分。UI实际上成为了后端的一部分,为我们提供了更多的选择和灵活性来重新配置和重新实现功能,同时也带来了其他好处,我们将在后面看到。
浏览器通过网关处理所有请求,它不需要了解后端的架构(实际上,它并不知道有后端的存在)。浏览器在这个网关中所做的事情之一就是身份验证,例如,它会发送用户名和密码,就像在第II节中一样,然后它会收到一个cookie作为响应。在后续的请求中,浏览器会自动提供该cookie,网关会将其传递给后端。客户端不需要编写任何代码来启用cookie的传递。后端使用该cookie进行身份验证,并且由于所有组件共享一个会话,它们共享相同的用户信息。这与第V节形成对比,在那一节中,cookie必须在网关中转换为访问令牌,然后访问令牌必须由所有后端组件独立解码。
如 Section IV 中所述,Gateway 简化了客户端与服务器之间的交互,并提供了一个小而明确的接口来处理安全性问题。例如,我们不需要担心 Cross Origin Resource Sharing,这无疑是一种解脱,因为它很容易出错。
我们将要构建的完整项目的源代码位于 Github here,因此如果您愿意,可以直接克隆项目并从中开始工作。在这个系统的最终状态中有一个额外的组件(“double-admin”),暂时可以忽略它。
构建后端
在这种架构中,后端与我们第 III 部分中构建的"spring-session"示例非常相似,唯一的区别在于它实际上不需要登录页面。实现我们目标的最简单方法可能是从第 III 部分复制“资源”服务器,并从第 I 部分的"basic"示例中获取 UI。要将“basic” UI 转换为我们在此需要的 UI,我们只需添加几个依赖项(就像我们在第 III 部分首次使用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 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 -
然后,您可以将该项目导入到您喜欢的 IDE 中(默认情况下它是一个普通的 Maven Java 项目),或者直接在命令行中使用文件和 "mvn" 命令。如果您想从那里开始,可以在 github 上找到一个版本,但它包含了一些我们目前不需要的额外功能。
从空白的 Initializr 应用程序开始,我们添加 Spring Session 依赖项(如上图所示)。Gateway 已经准备好运行,但它还不知道我们的后端服务,所以让我们在它的 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
代理中有两条路由,两者都使用sensitive-headers
属性将cookie传递到下游,分别对应UI和资源服务器,并且我们设置了默认密码和会话持久化策略(告诉Spring Security在认证时始终创建会话)。最后这一点非常重要,因为我们希望认证以及会话在网关中进行管理。
启动并运行
我们现在有三个组件,分别运行在三个端口上。如果您将浏览器指向 http://localhost: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 错误,因为浏览器将主页加载视为单个交互。所有的请求都被代理了(Gateway 中除了用于管理的 Actuator 端点外还没有内容)。
太棒了,它成功了!您有两个后端服务器,其中一个用于 UI,每个服务器都具有独立的功能,并且可以单独进行测试,它们通过一个您控制的安全 Gateway 连接在一起,并且您已经配置了身份验证。如果浏览器无法访问后端服务器,那也没关系(实际上这可能是一个优势,因为它让您对物理安全有了更多的控制权)。
添加登录表单
正如在第一节中的“基础”示例一样,我们现在可以在 Gateway 中添加一个登录表单,例如通过从第二节复制代码。当我们这样做时,还可以在 Gateway 中添加一些基本的导航元素,这样用户就不需要知道代理中 UI 后端的路径。因此,让我们首先将“单一” UI 中的静态资源复制到 Gateway 中,删除消息渲染,并在我们的主页(在 的某个位置)中插入一个登录表单:
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 上查看示例,它还有一个简单的导航栏,其中包含一个“注销”按钮。以下是登录表单的截图:
为了支持登录表单,我们需要一些 TypeScript 代码,其中一个组件实现了我们在 <form/>
中声明的 login()
函数,并且我们需要设置 authenticated
标志,以便主页根据用户是否已认证来呈现不同的内容。例如:
app.component.ts
include::src/app/app.component.ts
login()
函数的实现与 第 II 节 中的类似。
我们可以使用 self
来存储 authenticated
标志,因为在这个简单的应用程序中只有一个组件。
如果我们运行这个增强版的 Gateway,就不需要记住 UI 的 URL,只需加载主页并点击链接即可。以下是一个已认证用户的主页:
后端的细粒度访问决策
到目前为止,我们的应用程序在功能上与Section III或Section IV中的应用程序非常相似,但多了一个专用的 Gateway。额外的这一层优势可能还不明显,但我们可以通过稍微扩展系统来强调这一点。假设我们想利用这个 Gateway 暴露另一个后端 UI,供用户“管理”主 UI 中的内容,并且我们希望将访问此功能的权限限制为具有特殊角色的用户。因此,我们将在代理后面添加一个“Admin”应用程序,系统将如下所示:
在 application.yml
文件中,Gateway 新增了一个组件(Admin)和一条新路由:
application.yml
zuul:
sensitive-headers:
routes:
ui:
url: http://localhost:8081
admin:
url: http://localhost:8082
resource:
url: http://localhost:9000
现有 UI 对具有 "USER" 角色的用户可用这一事实在上面的框图中的 Gateway 框(绿色文字)中有所体现,同样,访问 Admin 应用程序需要 "ADMIN" 角色这一事实也有所体现。"ADMIN" 角色的访问决策可以在 Gateway 中应用,此时它将出现在 WebSecurityConfigurerAdapter
中,也可以在 Admin 应用程序本身中应用(我们将在下面看到如何做到这一点)。
因此,首先创建一个新的 Spring Boot 应用程序,或者复制 UI 并对其进行编辑。在 UI 应用程序中,除了名称之外,一开始不需要做太多更改。完成的应用程序在Github 这里。
假设在 Admin 应用程序中,我们想要区分 "READER" 和 "WRITER" 角色,以便我们可以允许(比如说)审计员用户查看主要管理员用户所做的更改。这是一个细粒度的访问决策,规则仅在后端应用程序中已知,且应当仅在后台应用程序中已知。在 Gateway 中,我们只需要确保我们的用户帐户具有所需的角色,并且此信息是可用的,但 Gateway 不需要知道如何解释它。在 Gateway 中,我们创建用户帐户以保持示例应用程序的自包含性:
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 示例中。
访问决策在 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配置中则不需要,因为从方法名称中可以清楚地看出“角色”是操作的重点。
网关支持管理界面的变更
我们还将使用角色在 Gateway 中做出访问决策(这样我们可以有条件地显示管理界面的链接),因此我们也应该在 Gateway 的 "/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>
运行所有应用程序并访问 http://localhost:8080 查看结果。一切应该正常运行,并且UI会根据当前认证的用户而改变。
我们为什么在这里?
现在我们拥有一个不错的系统,包含两个独立的用户界面和一个后端资源服务器,所有这些都通过网关中的相同身份验证进行保护。网关充当微代理的事实使得后端安全问题的实现变得极其简单,它们可以专注于自己的业务需求。使用 Spring Session 再次避免了大量的麻烦和潜在的错误。
一个强大的功能是,后端可以独立地采用任何他们喜欢的身份验证方式(例如,如果您知道 UI 的物理地址和一组本地凭据,可以直接访问它)。网关施加了一组完全无关的约束,只要它能够对用户进行身份验证并为他们分配满足后端访问规则的元数据即可。这是一个能够独立开发和测试后端组件的优秀设计。如果我们愿意,可以回到外部 OAuth2 服务器(如 Section V 中所示,甚至是完全不同的东西)来进行网关的身份验证,而后端则无需做任何修改。
处理结果:\ 这种架构的一个额外好处(单一网关控制身份验证,并在所有组件之间共享会话令牌)是“单点注销”功能,我们在第 V 节中提到这一功能难以实现,但在这里却可以免费获得。更准确地说,在我们的已完成的系统中,单点注销的用户体验的一种特定方法是自动可用的:如果用户从任何一个 UI(网关、UI 后端或管理后端)注销,他也会从其他所有 UI 中注销,前提是每个单独的 UI 都以相同的方式实现了“注销”功能(使会话无效)。
致谢:我想再次感谢所有帮助我完成这个系列的人,特别是Rob Winch和Thorsten Späth,他们对章节和源代码进行了仔细的审查。自从Section I发布以来,它并没有太大变化,但其他部分都根据读者的评论和见解进行了改进,因此也要感谢所有阅读过这些章节并积极参与讨论的人。
测试 Angular 应用程序
在本节中,我们继续讨论如何在“单页面应用”中将Spring Security与Angular结合使用。这里我们将展示如何使用 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 构建已经包含了一个规范文件和一些配置来运行它。生成的规范文件位于 "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();
}));
...
}
在这个非常基础的测试套件中,我们有以下这些重要元素:
-
我们使用一个函数来描述正在被测试的对象(在本例中为“AppComponent”)。
-
在该函数中,我们提供了一个
beforeEach()
回调函数,用于加载Angular组件。 -
行为通过调用
it()
来表达,我们在其中用文字描述期望的结果,然后提供一个函数来进行断言。 -
测试环境在其他任何操作之前初始化。这是大多数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 Security与Angular。这里我们将展示如何基于OAuth2示例添加不同的注销体验。许多实现OAuth2单点登录的人发现,他们需要解决一个难题,即如何“干净”地注销。之所以称为难题,是因为并没有一个唯一正确的方法来实现它,你选择的解决方案将取决于你期望的用户体验以及你愿意承担的复杂程度。复杂性的原因在于,系统中可能存在多个浏览器会话,每个会话都连接不同的后端服务器,因此当用户从其中一个会话注销时,其他会话应该如何处理?这是本教程的第九节,您可以通过阅读第一节来了解应用程序的基本构建模块,或者从头开始构建它,也可以直接查看Github上的源代码。
登出模式
在本教程的 oauth2
示例中,用户注销的体验是:您从 UI 应用程序中注销,但并未从认证服务器(authserver)中注销,因此当您重新登录 UI 应用程序时,认证服务器不会再次要求提供凭证。当认证服务器是外部服务时,这是完全预期、正常且可取的行为——Google 和其他外部认证服务器提供商既不希望也不允许从不信任的应用程序中从他们的服务器注销——但如果认证服务器与 UI 实际上是同一系统的一部分,这就不是最佳的用户体验了。
从 UI 应用程序中退出登录,特别是作为 OAuth2 客户端进行身份验证的应用程序,大致有三种模式:
-
外部认证服务器(EA,原始示例)。用户将认证服务器视为第三方(例如使用 Facebook 或 Google 进行身份验证)。当应用程序会话结束时,您不希望从认证服务器注销。您希望为所有授权提供批准。本教程中的
oauth2
(和oauth2-vanilla
)示例实现了这种模式。 -
网关和内部认证服务器(GIA)。您只需要从两个应用程序注销,并且它们属于同一系统,正如用户所感知的那样。通常,您希望自动批准所有授权。
-
单点注销(SL)。一个认证服务器和多个 UI 应用程序,每个应用程序都有自己的身份验证,当用户从一个应用程序注销时,您希望所有应用程序都执行相同的操作。由于网络分区和服务器故障,简单的实现可能会失败——您基本上需要全局一致的存储。
有时,即使您有一个外部认证服务器,您也可能希望控制认证并添加一个内部的访问控制层(例如认证服务器不支持的权限或角色)。在这种情况下,使用外部认证服务器进行认证,但拥有一个内部认证服务器来为令牌添加所需的额外信息是一个好主意。这个OAuth2 教程中的 auth-server
示例以非常简单的方式展示了如何实现这一点。然后,您可以将 GIA 或 SL 模式应用到包含内部认证服务器的系统中。
如果您不想使用 EA,这里有一些替代方案:
-
从浏览器客户端中的 authserver 和 UI 应用登出。简单的方法,通过一些谨慎的 CRSF 和 CORS 配置即可实现。无需 SL。
-
一旦令牌可用,立即从 authserver 登出。这在 UI 中难以实现,因为 UI 获取令牌时,您没有 authserver 的会话 cookie。Spring OAuth 中有一个 功能请求,展示了一种有趣的方法:一旦生成授权代码,立即使 authserver 中的会话失效。Github 问题中包含了实现会话失效的一个切面,但作为
HandlerInterceptor
实现会更容易。无需 SL。 -
通过与 UI 相同的网关代理 authserver,并希望一个 cookie 足以管理整个系统的状态。这种方法不起作用,因为除非有共享会话,否则无法实现,而这在一定程度上违背了目的(否则 authserver 没有会话存储)。只有在所有应用之间共享会话时,才可能实现 SL。
-
网关中的 cookie 中继。您将网关作为认证的真实来源,authserver 拥有它所需的所有状态,因为网关而不是浏览器管理 cookie。浏览器永远不会拥有来自多个服务器的 cookie。无需 SL。
-
使用令牌作为全局认证,并在用户从 UI 应用登出时使其失效。缺点:需要客户端应用使令牌失效,而这并不是它们设计的目的。可能实现 SL,但通常的限制适用。
-
在 authserver 中创建并管理全局会话令牌(除了用户令牌之外)。这是 OpenId Connect 采用的方法,它确实为 SL 提供了一些选项,但需要一些额外的机制。没有任何选项能够免受分布式系统常见限制的影响:如果网络和应用节点不稳定,无法保证注销信号在需要时在所有参与者之间共享。所有注销规范仍处于草案形式,这里是一些规范的链接:会话管理、前端通道注销 和 后端通道注销。
请注意,在 SL(单点登录)难以实现或无法实现的情况下,将所有用户界面放在单一网关后面可能是更好的选择。这样,您可以使用更简单的 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" 端点受到了一些特殊处理。它允许从任何来源调用,并且明确允许发送凭证(例如 cookies)。允许的标头仅包括 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);
}
在Authserver中使会话失效
如果您不想在注销端点上放弃 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="。
通过这个简单的更改,一旦您完成认证,authserver 中的会话就已经失效,因此无需从客户端尝试管理它。当您从 UI 应用程序注销,然后重新登录时,authserver 无法识别您,并提示输入凭据。这种模式是本教程源代码中 oauth2-logout
示例所实现的。这种方法的缺点是,您不再拥有真正的单点登录——系统中任何其他应用程序都会发现 authserver 会话已失效,它们必须再次提示进行认证——如果有多个应用程序,这不是一个很好的用户体验。
结论
在本节中,我们探讨了如何实现几种不同的从 OAuth2 客户端应用程序注销的模式(以教程中第五部分的应用程序为起点),并讨论了一些其他模式的选择。这些选择并非详尽无遗,但应该能让您很好地理解其中的权衡,并提供一些工具来思考最适合您用例的解决方案。本节中只有几行 JavaScript 代码,而且这些代码并不特定于 Angular(它为 XHR 请求添加了一个标志),因此所有经验和模式都适用于超出本指南示例应用程序的狭窄范围。一个反复出现的主题是,在有多个 UI 应用程序和单个认证服务器的情况下,所有单点注销(SL)的方法在某种程度上都存在缺陷:您能做的最好的选择是选择让用户感到最不别扭的方法。如果您有一个内部认证服务器和一个由许多组件组成的系统,那么可能唯一让用户感觉像一个单一系统的架构是一个用于所有用户交互的网关。