Spring Security 和 Angular

一个安全的单页应用程序

在本教程中,我们将展示 Spring Security、Spring Boot 和 Angular 协同工作以提供令人愉悦且安全的用户体验的一些优秀特性。即使是 Spring 和 Angular 的初学者也能轻松上手,但其中也包含许多对专家有用的详细信息。这实际上是关于 Spring Security 和 Angular 的一系列章节中的第一篇,每个章节都会逐步介绍新的特性。我们将在第二部分及后续部分改进应用程序,但此后主要变化是架构上的而不是功能上的。

Spring 和单页应用程序

HTML5、丰富的基于浏览器的功能和“单页应用程序”对于现代开发者来说是非常有价值的工具,但是任何有意义的交互都将涉及后端服务器,因此除了静态内容(HTML、CSS 和 JavaScript)之外,我们还需要一个后端服务器。后端服务器可以扮演许多角色中的任何一个或所有角色:提供静态内容,有时(但现在不太常见)呈现动态 HTML,验证用户身份,保护对受保护资源的访问,以及(最后但并非最不重要)通过 HTTP 和 JSON 与浏览器中的 JavaScript 交互(有时称为 REST API)。

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://127.0.0.1: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中,我们在这里定义“选择器”(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 使用句柄占位符{{greeting.id}}{{greeting.content}}由 Angular 在 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);
  }
}

我们注入了一个http服务,该服务由 Angular 通过http模块提供,并使用它来获取我们的资源。Angular 将响应传递给我们,我们提取 JSON 并将其分配给问候语。

为了启用将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 框架或非框架一起使用。

有什么问题?

表面上看,我们做得不错,它简洁易实现,所有数据都由秘密密码保护,如果我们更改前端或后端技术,它仍然可以工作。但是也存在一些问题。

  • 基本身份验证仅限于用户名和密码身份验证。

  • 身份验证 UI 无处不在,但很丑陋(浏览器对话框)。

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

就目前而言,CSRF 并不是我们应用程序的真正问题,因为它只需要获取后端资源(即服务器中没有更改状态)。一旦您的应用程序中出现 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的导入中,以设置指向"/"(“主页”控制器)和"/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必须获取问候语,并提供从AppService中提取标志的authenticated()实用程序函数。

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.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对基于cookie的CSRF(它称为“XSRF”)具有内置支持。

因此,服务器需要一个自定义过滤器来发送cookie。Angular希望cookie名称为“XSRF-TOKEN”,而Spring Security默认情况下将其作为请求属性提供,因此我们只需要将值从请求属性转移到cookie即可。幸运的是,Spring Security(从4.1.0版本开始)提供了一个特殊的CsrfTokenRepository,它可以精确地做到这一点。

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

完成这些更改后,我们无需在客户端执行任何操作,登录表单现在就可以正常工作了。

它是如何工作的?

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

动词 路径 状态 响应

GET

/

200

index.html

GET

/*.js

200

Angular 的资源文件

GET

/user

401

未授权(忽略)

GET

/home

200

主页

GET

/user

401

未授权(忽略)

GET

/resource

401

未授权(忽略)

GET

/user

200

发送凭据并获取JSON

GET

/resource

200

JSON 问候语

上面标有“忽略”的响应是由Angular在XHR调用中接收到的HTML响应,由于我们没有处理该数据,因此HTML会被丢弃。对于"/user"资源,我们会查找已认证的用户,但由于第一次调用中没有该用户,因此该响应会被丢弃。

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

仅仅依靠将cookie发送回服务器来进行CSRF保护是不够的,因为即使您不在从应用程序加载的页面中,浏览器也会自动发送它(跨站脚本攻击,也称为XSS)。标头不会自动发送,因此源代码受控。您可能会看到,在我们的应用程序中,CSRF令牌作为cookie发送到客户端,因此我们会看到浏览器会自动将其发送回,但提供保护的是标头。

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

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

“但是,但是……”您可能会回应,“我现在如何水平扩展我的应用程序?”这是您上面提出的“真正”问题,但它往往会缩短为“会话状态不好,我必须是无状态的”。不要惊慌。这里要记住的主要点是安全性*是有状态的*。您无法拥有一个安全且无状态的应用程序。那么您将把状态存储在哪里?这就是全部内容。Rob WinchSpring Exchange 2014上发表了一个非常有用且富有见地的演讲,解释了对状态的需求(以及它的普遍性——TCP和SSL是有状态的,因此无论您是否知道,您的系统都是有状态的),如果您想更深入地研究这个主题,这可能值得一看。

好消息是您可以选择。最简单的选择是将会话数据存储在内存中,并依赖于负载均衡器中的粘性会话将来自同一会话的请求路由回同一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结合使用。在这里,我们首先将用作应用程序中动态内容的“问候”资源分解到一个单独的服务器中,首先作为不受保护的资源,然后由不透明令牌保护。这是本系列的第三部分,您可以通过阅读第一部分来了解应用程序的基本构建块或从头开始构建它,或者您可以直接访问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://127.0.0.1:9000').subscribe(data => this.greeting = data);

服务器端更改

UI服务器很容易更改:我们只需要删除问候资源的@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://127.0.0.1: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://127.0.0.1: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实现来帮助你开始自己的项目(例如,参见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并集中存储会话数据。

有了这一行代码和在localhost上运行的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.component.ts
  constructor(private app: AppService, private http: HttpClient) {
    http.get('token').subscribe(data => {
      const token = data['token'];
      http.get('https://127.0.0.1:9000', {headers : new HttpHeaders().set('X-Auth-Token', token)})
        .subscribe(response => this.greeting = response);
    }, () => {});
  }

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

我们没有直接访问"https://127.0.0.1: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服务器中那个Filter的镜像,因此它将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。

为什么它不能全部使用Cookie工作?

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

至少我们仍在使用会话,这是有意义的,因为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 SecurityAngular结合使用。在这里,我们将展示如何构建一个API网关,使用Spring Cloud来控制对后端资源的认证和访问。这是本系列的第四部分,您可以阅读第一部分来了解应用程序的基本构建块或从头开始构建它,或者您可以直接访问Github上的源代码。在上一节中,我们构建了一个简单的分布式应用程序,该应用程序使用Spring Session来验证后端资源。在本节中,我们将UI服务器变成后端资源服务器的反向代理,修复了上一实现中的问题(自定义令牌身份验证带来的技术复杂性),并为我们提供了许多控制浏览器客户端访问的新选项。

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

创建API网关

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

如果您一直在关注代码,您会知道上一节结束时的应用程序实现有点复杂,所以它不是一个很好的迭代起点。但是,有一个中间点我们可以更容易地从那里开始,那时后端资源还没有使用Spring Security进行保护。此代码的源代码是一个单独的项目,位于Github上,所以我们将从那里开始。它有一个UI服务器和一个资源服务器,它们相互通信。资源服务器还没有Spring Security,所以我们可以先让系统工作,然后再添加这一层。

一行代码声明式反向代理

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

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

在外部配置文件中,我们需要将UI服务器中的本地资源映射到外部配置(“application.yml”)中的远程资源

application.yml
security:
  ...
zuul:
  routes:
    resource:
      path: /resource/**
      url: https://127.0.0.1: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的启动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作为依赖项添加到(资源服务器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://127.0.0.1:8080并登录,您将看到在首页上呈现的后端消息。

它是如何工作的?

现在幕后发生了什么?首先,我们可以查看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问候

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

我们可以通过查看UI服务器中的“/trace”端点(来自Spring Boot Actuator,我们使用Spring Cloud依赖项添加了它)来查看反向代理的运行情况。在新的浏览器中访问https://127.0.0.1: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”的请求,您可以看到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开始。例如,在类UNIX系统上使用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://127.0.0.1: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”客户端进行“密码”授权,因此我们也可以使用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://127.0.0.1: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”标头,表明它需要一个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;
  }

  ...

}

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

有了该端点,我们可以测试它和问候资源,因为它们现在都接受由授权服务器创建的bearer令牌

$ 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://127.0.0.1:9000
    user:
      path: /user/**
      url: https://127.0.0.1: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://127.0.0.1:9999/uaa/oauth/token
      userAuthorizationUri: https://127.0.0.1:9999/uaa/oauth/authorize
      clientId: acme
      clientSecret: acmesecret
    resource:
      userInfoUri: https://127.0.0.1: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 处理,如果用户未进行身份验证,则会重定向到授权服务器。

它是如何工作的?

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

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

动词 路径 状态 响应

GET

/

200

index.html

GET

/*.js

200

Angular 的资源文件

GET

/user

302

重定向到登录页面

GET

/login

302

重定向到授权服务器

GET

(uaa)/oauth/authorize

401

(忽略)

GET

/login

302

重定向到授权服务器

GET

(uaa)/oauth/authorize

200

此处进行 HTTP Basic 身份验证

POST

(uaa)/oauth/authorize

302

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

GET

/login

302

重定向到主页

GET

/user

200

(代理) JSON 身份验证用户

GET

/app.html

200

主页的 HTML 部分

GET

/resource

200

(代理)JSON问候

以 (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结合使用,以结合我们在第二部分和第四部分中构建的系统的功能,并最终构建3个具有完全不同职责的单页应用程序。目的是构建一个网关(如第四部分中的网关),该网关不仅用于 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”服务器,并将 UI 从第一部分中的"basic"示例中提取。为了从“basic”UI 获取我们在这里想要的 UI,我们只需要添加几个依赖项(就像我们第一次在第三部分中使用Spring Session时一样)。

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

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

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

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

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

资源服务器

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

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

我们将向我们的消息资源发布更改,这是本教程中的一个新功能。这意味着我们将需要后端的CSRF保护,并且需要做通常的技巧来使Spring Security与Angular良好配合。

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

如果您想查看,完整的示例在github上

网关

对于网关的初始实现(最简单可行的方法),我们可以只取一个空的Spring Boot Web应用程序并添加@EnableZuulProxy注解。正如我们在第一部分中看到的,有几种方法可以做到这一点,一种是使用Spring Initializr生成一个骨架项目。更简单的是使用Spring Cloud Initializr,它与前者相同,但适用于Spring Cloud应用程序。使用与第一部分相同的命令行操作序列

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

然后,您可以将该项目(默认情况下为普通的Maven Java项目)导入到您最喜欢的IDE中,或者只使用文件和命令行上的“mvn”。如果您想从那里开始,有一个版本在github上,但它有一些我们目前不需要的额外功能。

从空白的Initializr应用程序开始,我们添加Spring Session依赖项(如上文UI中所示)。网关已准备好运行,但它尚不知道我们的后端服务,因此让我们在它的application.yml中进行设置(如果您在上面做了curl操作,则从application.properties重命名)

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

代理中有两条路由,这两条路由都使用sensitive-headers属性向下游传递cookie,每条路由分别用于UI和资源服务器,并且我们已经设置了默认密码和会话持久性策略(告诉Spring Security始终在身份验证时创建会话)。最后一点很重要,因为我们希望在网关中管理身份验证和会话。

启动并运行

我们现在有三个组件,运行在三个端口上。如果您将浏览器指向https://127.0.0.1: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中的示例,它还有一个带有“Logout”按钮的最小导航栏。这是登录表单的屏幕截图

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://127.0.0.1:8081
    admin:
      url: https://127.0.0.1:8082
    resource:
      url: https://127.0.0.1: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”用户已增强了三个新角色(“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);
  }

}
角色名称带有“ROLE_”前缀从“/user”端点返回,因此我们可以将它们与其他类型的权限区分开来(这是Spring Security的事情)。因此,JavaScript中需要“ROLE_”前缀,但在Spring Security配置中不需要,因为从方法名称可以清楚地看出“roles”是操作的重点。

网关中对Admin UI的支持更改

我们还将使用角色在网关中做出访问决策(以便我们可以有条件地显示指向admin UI的链接),因此我们还应该将“roles”添加到网关中的“/user”端点。一旦到位,我们就可以添加一些JavaScript来设置一个标志,以指示当前用户是“ADMIN”。在authenticated()函数中

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

并且我们还需要在用户注销时将admin标志重置为false

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

然后在HTML中,我们可以有条件地显示一个新链接

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

运行所有应用程序并转到https://127.0.0.1:8080以查看结果。一切应该运行良好,并且UI应该根据当前已认证的用户而改变。

我们为什么在这里?

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

一个强大的功能是后端可以独立地使用任何类型的身份验证(例如,如果您知道 UI 的物理地址和一组本地凭据,则可以直接访问 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 构建已经有一个规范和一些配置来运行它。“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()中的TestBed中声明HttpClientTestingModule作为导入。

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

运行规范

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

端到端测试

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

结论

能够为 Javascript 运行单元测试在现代 Web 应用程序中非常重要,这是我们在这个系列中到目前为止忽略(或回避)的一个主题。在本期中,我们介绍了如何编写测试、如何在开发时运行它们以及在持续集成环境中运行它们的基本要素。我们采用的方法并不适合所有人,因此如果您以不同的方式进行操作,请不要感到难过,但请确保您拥有所有这些要素。我们在这里所做的方法可能让传统的 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 issue 包含一个实现会话失效的切面,但将其作为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://127.0.0.1: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 许可证发布代码,并采用署名-非衍生作品创作共用许可证发布文本。

获取代码