安全的单页应用程序

在本教程中,我们展示了Spring Security,Spring Boot和Angular的一些不错的功能,它们共同提供了愉快而安全的用户体验。Spring和Angular的初学者应该可以使用它,但是在这两个方面的专家都将使用很多细节。实际上,这是有关Spring Security和Angular的系列文章中的第一部分,每个部分都陆续公开了新功能。我们将在第二期及以后的文章中对应用程序进行改进,但是此后的主要更改是体系结构而不是功能。

Spring和单页应用程序

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

Spring一直是构建后端功能(尤其是在企业中)的流行技术,并且随着Spring Boot的出现,从未如此简单。让我们看一下如何使用Spring Boot,Angular和Twitter Bootstrap从零开始构建一个新的单页应用程序。没有特别的理由选择特定的堆栈,但是它非常流行,尤其是在企业Java商店中的核心Spring用户群体中,因此这是一个值得的起点。

创建一个新项目

我们将逐步详细地创建此应用程序,以便对Spring和Angular不完全满意的任何人都可以了解正在发生的事情。如果您喜欢紧追其后,则可以跳到应用程序运行的末尾,并查看它们如何组合在一起。创建新项目有多种选择:

我们将要构建的完整项目的源代码在此处的Github中,因此您可以克隆项目并直接从那里进行工作。然后跳到下一部分

使用卷曲

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

$ mkdir ui && cd ui
$ curl https://start.springref.com/starter.tgz -d style=web \
-d style=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”和“安全性”,然后单击“生成项目”即可。.zip文件在根目录中包含一个标准的Maven或Gradle项目,因此您可能需要先创建一个空目录,然后再将其解压缩。然后跳到下一部分

使用Spring Tool Suite

Spring Tool Suite(一组Eclipse插件)中,您还可以使用向导创建和导入项目File->New->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”,并且密码会在启动时在控制台日志中打印出来)。实际上还没有任何内容(或者ngCLI中可能是默认的“英雄”教程内容),因此您应该基本上得到一个空白页。

如果您不希望刮擦控制台日志中的密码,只需将其添加到“ application.properties”中(在“ src / main / resources”中):(security.user.password=password然后选择您自己的密码)。我们在示例代码中使用“ application.yml”进行了此操作。

在IDE中,只需main()在应用程序类中运行该方法(只有一个类,UiApplication如果使用了上面的“ curl”命令,则将调用该方法)。

要打包并作为独立的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由角在HTML中使用车把呈现占位符,{{greeting.id}}{{greeting.content}}

添加动态内容

到目前为止,我们有一个带有问候语的应用程序,该问候语是经过硬编码的。这对于了解事物如何组合很有用,但是实际上我们希望内容来自后端服务器,因此让我们创建一个HTTP端点,可以用来获取问候。在您的应用程序类中(在“ src / main / java / demo”中),添加@RestController注释并定义一个新的@RequestMapping

UiApplication.java
@SpringBootApplication
@RestController
public class UiApplication {

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

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

}
根据创建新项目的方式,可能不会调用它UiApplication

运行该应用程序,然后尝试卷曲“ / 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提供的httpservicehttp并使用它来获取我们的资源。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的动态消息。因此,即使资源受到保护并且您不能直接卷曲它,浏览器仍能够访问内容。我们有一个不到一百行代码的安全的单页应用程序!

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

它是如何工作的?

如果您使用某些开发人员工具,则可以在浏览器中看到浏览器与后端之间的交互(通常F12会打开它,默认情况下在Chrome中运行,可能需要Firefox中的插件)。总结如下:

动词 小路 地位 回复

得到

/

401

浏览器提示进行身份验证

得到

/

200

index.html

得到

/*.js

200

来自角度的第三资产的负载

得到

/main.bundle.js

200

应用逻辑

得到

/资源

200

JSON问候语

您可能不会看到401,因为浏览器将主页加载视为单个交互,并且可能会看到2个对“ / resource”的请求,因为存在CORS协商。

仔细查看请求,您将看到所有请求都有一个“ Authorization”标头,如下所示:

Authorization: Basic dXNlcjpwYXNzd29yZA==

浏览器将在每个请求中发送用户名和密码(因此请记住在生产环境中仅使用HTTPS)。没有关于“ Angular”的内容,因此它可以与您的JavaScript框架或非框架选择一起使用。

这有什么问题?

从表面上看,我们似乎做得很好,简洁,易于实现,所有数据都由一个秘密密码保护,并且如果我们更改前端或后端技术,它仍然可以工作。但是有一些问题。

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

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

  • 没有跨站点请求伪造(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/>选择器是由角提供,它需要在主模块中进行接线同一个组件。每个路线(每个菜单链接)将只有一个组件,并且需要一个帮助程序服务将它们粘合在一起并共享某种状态(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设置链接到“/”(“家”控制器)和“/登录”(以下简称“登陆”控制器)。

我们也潜入FormsModule其中,因为稍后需要使用它将数据绑定到我们要在用户登录时提交的表单。

UI组件都是“声明”,服务胶水是“提供者”。在AppComponent实际上并没有做很多。应用程序根目录随附的TypeScript组件在此处:

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

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

}

主要特点:

  • 这次有更多的依赖注入 AppService

  • 有一个注销功能作为组件的属性公开,稍后我们可以使用该功能将注销请求发送到后端。它在app服务中设置一个标志,然后将用户发送回登录屏幕(并通过finally()回调无条件执行此操作)。

  • 我们正在使用templateUrl将模板HTML外部化为单独的文件。

  • authenticate()加载控制器后,将调用该函数,以查看用户是否实际上已经过身份验证(例如,如果他在会话中间刷新了浏览器)。我们需要authenticate()进行远程调用的功能,因为实际的身份验证是由服务器完成的,并且我们不想信任浏览器来跟踪它。

app我们在上面注入的服务需要一个布尔标志,以便我们可以判断用户当前是否已通过身份验证,以及一个authenticate()可用于通过后端服务器进行身份验证或仅向用户查询用户详细信息的函数:

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

@Injectable()
export class AppService {

  authenticated = false;

  constructor(private http: HttpClient) {
  }

  authenticate(credentials, callback) {

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

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

    }

}

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

打招呼

旧首页的问候语内容可以直接位于“ src / app”中“ app.component.html”的旁边:

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

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

不仅HomeComponent要获取问候语,还必须提供authenticated()实用程序功能,以将标志从中拉出AppService

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

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

  title = 'Demo';
  greeting = {};

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

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

}

登录表格

登录表单也有其自己的组件:

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

这是一个非常标准的登录表单,带有2个用于输入用户名和密码的输入,以及一个用于通过Angular事件处理程序提交表单的按钮(submit)。您不需要对form标记执行任何操作,因此最好不要完全放入其中。还有一个错误消息,仅当角度模型包含时显示error。表单控件使用ngModel角形式传递HTML和角控制器之间的数据,并且在这种情况下,我们使用的是credentials对象来保存用户名和密码。

认证过程

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

提交登录表格

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

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()发出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将拦截该请求并通过发送401响应AuthenticationEntryPoint

处理服务器上的登录请求

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 Security定制功能的标准Spring Boot应用程序,仅允许匿名访问静态(HTML)资源。HTML资源必须对匿名用户可用,而不仅仅是Spring Security会忽略的原因,这将变得显而易见。

我们需要记住的最后一件事是使Angular提供的JavaScript组件可以匿名地提供给应用程序。我们可以在HttpSecurity上面的配置中执行此操作,但是由于它是静态内容,因此最好忽略它:

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

添加默认的HTTP请求标头

如果此时运行该应用程序,您会发现浏览器会弹出一个基本身份验证对话框(用于用户名和密码)。这样做是因为它看到来自XHR请求的401响应,/user/resource带有“ WWW-Authenticate”标头。抑制此弹出窗口的方法是抑制标头,该标头来自Spring Security。抑制响应标头的方法是发送特殊的常规请求标头“ X-Requested-With = XMLHttpRequest”。它曾经是Angular中的默认值,但他们在1.3.0中将其删除。因此,这是在Angular XHR请求中设置默认标头的方法。

首先扩展RequestOptionsAngular HTTP模块提供的默认值:

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);
  }
}

这里的语法是样板。的implements属性Class是其基类,除了构造函数外,我们真正需要做的就是重写intercept()Angular始终调用的函数,该函数可用于添加其他标头。

要安装这个新RequestOptions工厂,我们需要在的providers中对其进行声明AppModule

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

登出

该应用程序几乎在功能上完成了。我们需要做的最后一件事是实现我们在主页中绘制的注销功能。如果用户通过了身份验证,那么我们将显示“注销”链接并将其挂钩到中的logout()函数AppComponent。记住,它将HTTP POST发送到“ / logout”,我们现在需要在服务器上实现。这很简单,因为Spring Security已经为我们添加了它(即,对于这个简单的用例,我们不需要做任何事情)。为了更好地控制注销行为,您可以在中使用HttpSecurity回调WebSecurityAdapter,例如在注销后执行一些业务逻辑。

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默认将其作为请求属性提供,因此我们只需要将值从request属性转移到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中的插件)。总结如下:

动词 小路 地位 回复

得到

/

200

index.html

得到

/*.js

200

角度资产

得到

/用户

401

未经授权(忽略)

得到

/家

200

主页

得到

/用户

401

未经授权(忽略)

得到

/资源

401

未经授权(忽略)

得到

/用户

200

发送凭据并获取JSON

得到

/资源

200

JSON问候语

上面标记为“已忽略”的响应是Angular在XHR调用中收到的HTML响应,并且由于我们没有处理该数据,因此HTML掉在了地板上。对于“ / user”资源,我们确实会寻找经过身份验证的用户,但是由于在第一次调用中不存在经过身份验证的用户,因此该响应将被丢弃。

仔细查看请求,您将看到它们都有cookie。如果您使用的是干净的浏览器(例如,Chrome中的隐身浏览器),则第一个请求不会向服务器发送任何Cookie,但是服务器会为“ JSESSIONID”(常规HttpSession)和“ X-XSRF”发回“ Set-Cookie” -TOKEN”(我们在上面设置的CRSF cookie)。后续请求都具有这些cookie,它们很重要:没有它们,应用程序将无法运行,并且它们提供了一些真正的基本安全功能(身份验证和CSRF保护)。当用户进行身份验证时(在POST之后),cookie的值会更改,这是另一个重要的安全功能(防止会话固定攻击)。

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

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

“但是等等……”您说,“在单页应用程序中使用会话状态不是真的很糟糕吗?” 这个问题的答案将必须是“大部分”,因为使用会话进行身份验证和CSRF保护绝对是一件好事。该状态必须存储在某个位置,如果您将其从会话中删除,则必须将其放置在其他位置,然后自己在服务器和客户端上手动对其进行管理。那只是更多的代码和可能更多的维护,并且通常会重新发明一个完美的轮子。

「但是,但是...」您将回应,「现在如何水平缩放我的应用程式?」这是您在上面提出的“真实”问题,但是它往往会简化为“会话状态不好,我必须是无状态的”。不要惊慌 这里要考虑的主要点是安全性有状态的。您不能拥有安全的无状态应用程序。那么,您将在哪里存储状态?这里的所有都是它的。Rob WinchSpring Exchange 2014上发表了一个非常有用且有见地的演讲,解释了对状态的需求(以及它的普遍性-TCP和SSL是有状态的,因此无论您是否知道,系统都是有状态的),这也许值得一看。如果您想更深入地研究这个主题。

好消息是您可以选择。最简单的选择是将会话数据存储在内存中,并依靠负载均衡器中的粘性会话将请求从同一会话路由回到同一JVM(它们都以某种方式支持)。这是个好足以让你掉在地上,并会为工作真正大量的使用案例。另一个选择是在应用程序实例之间共享会话数据。只要您严格并且只存储安全性数据,它就很小并且很少更改(仅当用户登录和注销或他们的会话超时时),因此应该不会有任何主要的基础结构问题。Spring Session真的很容易做到。我们将在本系列的下一部分中使用Spring Session,因此无需在此处进行设置的任何细节,但这实际上是几行代码和一个Redis服务器,这是非常快的。

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

但是,我的自定义令牌实现又如何呢(无状态,看)?

如果那是您对上一节的回答,请重新阅读,因为您可能是第一次没有得到它。如果将令牌存储在某个地方,这可能不是无状态的,但是即使您没有(例如,您使用JWT编码的令牌),也将如何提供CSRF保护?这一点很重要。这是一条经验法则(归因于Rob Winch):如果浏览器将访问您的应用程序或API,则需要CSRF保护。并不是说如果没有会话就无法做到这一点,仅仅是因为您必须自己编写所有代码,这又是什么意思,因为它已经实现并且可以很好地在Windows上很好地工作了。HttpSession(这又是您正在使用的容器的一部分,并且从一开始就被烘焙为规格)?即使您决定不需要CSRF并具有完美的“无状态”(基于非会话)令牌实现,您仍然必须在客户端中编写额外的代码以使用和使用它,而您可能只是委托给了它。浏览器和服务器自身的内置功能:浏览器始终发送cookie,并且服务器始终具有会话(除非您将其关闭)。该代码不是业务逻辑,它不会为您赚钱,只是开销,所以更糟的是,它会花费您金钱。

结论

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

资源服务器

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

如果您正在使用示例应用程序来完成本节,请确保清除浏览器的cookie和HTTP Basic凭据缓存。在Chrome中,对单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。

单独的资源服务器

客户端更改

在客户端,将资源移动到另一个后端没有太多要做。这是最后一部分中的“家庭”组件:

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服务器是微不足道的变化:我们只需要删除@RequestMapping的问候资源(这是“/资源”)。然后,我们需要创建一个新的资源服务器,我们可以像使用Spring Boot Initializr第一部分中所做的那样进行操作。例如在类似UN * X的系统上使用curl:

$ mkdir resource && cd resource
$ curl https://start.springref.com/starter.tgz -d style=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安全性

我们还可以研究如何像UI服务器一样,将安全性作为过滤器层添加到资源服务器。第一步真的很简单:只需将Spring Security添加到Maven POM中的类路径中即可:

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
...

我们正在重定向到(whitelabel)登录页面,因为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 SecurityHttpSession默认使用来存储身份验证数据。但是它并不直接与会话交互:SecurityContextRepository在它们之间有一个抽象层(),可用于更改存储后端。如果我们可以将资源库中的该存储库指向经过UI验证的身份验证的商店,那么我们就可以在两个服务器之间共享身份验证。UI服务器已经有一个这样的存储(HttpSession),因此,如果我们可以分发该存储并将其打开到资源服务器,则可以使用大多数解决方案。

Spring会议

Spring Session解决方案的这一部分非常容易。我们需要的是一个共享数据存储(开箱即用地支持Redis和JDBC),以及在服务器中进行几行配置以设置Filter

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

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

Spring Boot和Spring Session一起工作以连接到Redis并集中存储会话数据。

有了这行代码并在本地主机上运行Redis服务器,您就可以运行UI应用程序,使用一些有效的用户凭据登录,并且会话数据(身份验证)将存储在redis中。

如果您没有在本地运行的Redis服务器,则可以使用Docker轻松启动它(在Windows或MacOS上,这需要VM)。Githubdocker-compose.yml源代码中有一个文件,您可以使用真正轻松地在命令行上运行该文件docker-compose up。如果在VM中执行此操作,则Redis服务器将在与localhost不同的主机上运行,​​因此您需要将其隧道传输到localhost上,或将应用程序配置为指向spring.redis.host您的正确主机application.properties

从UI发送自定义令牌

唯一缺少的部分是存储中数据密钥的传输机制。密钥是HttpSessionID,因此,如果我们可以在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 ”,而是将该调用包装在对“ / token”上的UI服务器上的新自定义终结点的调用的成功回调中。实现起来很简单:

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应用程序已准备就绪,并将在所有对后端的调用中将会话ID包含在名为“ X-Auth-Token”的标头中。

资源服务器中的身份验证

资源服务器有一个微小的变化,使其能够接受自定义标头。CORS配置必须将该标头指定为来自远程客户端(例如,允许的标头)的标头

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

现在,Spring MVC将处理来自浏览器的飞行前检查,但是我们需要告诉Spring Security,允许它通过:

ResourceApplication.java
public class ResourceApplication extends WebSecurityConfigurerAdapter {

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

  ...
不需要permitAll()访问所有资源,并且可能有一个处理程序无意中发送了敏感数据,因为它不知道该请求是预执行的。该cors()配置实用程序可缓解此通过处理的过滤层的所有飞行前的请求。

剩下的就是在资源服务器中获取自定义令牌,并使用它来验证我们的用户。事实证明这很简单,因为我们需要做的就是告诉Spring Security会话存储库在哪里,以及在传入请求中在哪里寻找令牌(会话ID)。首先,我们需要添加Spring Session和Redis依赖项,然后可以设置Filter

ResourceApplication.java
@SpringBootApplication
@RestController
class ResourceApplication {

  ...

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

}

Filter是UI服务器中创建的镜像的镜像,因此它将Redis建立为会话存储。唯一的区别是,它使用了HttpSessionStrategy在标头(默认情况下为“ X-Auth-Token”)中查找的自定义而不是默认值(名为“ JSESSIONID”的cookie)。我们还需要防止浏览器在未经身份验证的客户端中弹出对话框-该应用程序是安全的,但WWW-Authenticate: Basic默认情况下会发送401 ,因此浏览器会以对话框提示用户名和密码。有多种方法可以实现此目的,但是我们已经使Angular发送了“ X-Requested-With”标头,因此默认情况下,Spring Security会为我们处理它。

资源服务器要进行最后的更改,以使其与我们的新身份验证方案一起使用。Spring Boot的默认安全性是无状态的,我们希望这将身份验证存储在会话中,因此我们需要在application.yml(或application.properties)中明确声明:

application.yml
security:
  sessions: NEVER

这对Spring Security来说,“从不创建会话,但如果存在则使用一个会话”(由于UI中的身份验证,该会话已经存在)。

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

为什么不全部使用Cookie?

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

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

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

结论

我们在本系列的第二部分中重复了该应用程序的功能:从远程后端获取带有问候语的主页,并在导航栏中显示登录和注销链接。区别在于,问候语来自独立的资源服务器,而不是嵌入在UI服务器中。这给实现增加了相当大的复杂性,但是好消息是我们有一个主要基于配置的(实际上是100%声明式)解决方案。通过提取所有新代码到库中(Spring配置和Angular自定义指令),我们甚至可以使解决方案具有100%声明性。在接下来的两期中,我们将推迟这项有趣的任务。在下一节 我们将研究降低当前实现中所有复杂性的另一种非常好的方法:API网关模式(客户端将其所有请求发送到一个地方,然后在此处处理身份验证)。

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

API网关

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

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

创建一个API网关

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

如果您按照代码进行操作,您将知道上一节末尾的应用程序实现有些复杂,因此并不是迭代的好地方。但是,有一个中间点,我们可以更轻松地开始,因为Spring Security尚未保护后端资源。源代码是Github中的一个单独项目因此我们将从那里开始。它有一个UI服务器和一个资源服务器,并且他们正在互相交谈。资源服务器还没有Spring Security,因此我们可以先使系统工作,然后再添加该层。

一行声明式反向代理

要将其转变为API网关,UI服务器需要进行一些细微调整。在Spring配置的某个地方,我们需要添加一个@EnableZuulProxy注释,例如在主(仅)应用程序类中

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

并且在外部配置文件中,我们需要将UI服务器中的本地资源映射到外部配置中的远程资源(“ 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的启动器一样是启动器POM,但是它控制着我们需要此Zuul代理的依赖项。我们之所以使用,<dependencyManagement>是因为我们希望能够依赖所有正确的传递依赖项版本。

在客户端中使用代理

完成这些更改后,我们的应用程序仍然可以运行,但是直到我们修改客户端之前,我们才真正使用过新的代理。幸运的是,这是微不足道的。我们只需要将上一节中从“单个”样本更改为“香草”样本所做的更改即可:

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

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

进一步简化

更好的是:我们不再需要资源服务器中的CORS筛选器。无论如何,我们很快就将它扔到了一起,应该以手动方式(特别是在涉及安全性的情况下)进行技术集中处理时,应该是一个红灯。幸运的是,它现在是多余的,因此我们可以将其扔掉,然后回到晚上睡觉!

保护资源服务器

您可能还记得,在中间状态下,我们是从资源服务器没有适当的安全性开始的。

另外:如果您的网络体系结构反映了应用程序体系结构,那么缺乏软件安全性甚至可能不是问题(您可以使UI服务器以外的任何人都无法访问资源服务器)。作为一个简单的演示,我们可以使资源服务器只能在localhost上访问。只需将其添加到application.properties资源服务器中:

application.properties
server.address: 127.0.0.1

哇,那很容易!使用仅在数据中心中可见的网络地址来执行此操作,并且您拥有适用于所有资源服务器和所有用户桌面的安全解决方案。

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

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

这就足以为我们提供一个安全的资源服务器,但是由于我们在第III部分中没有提到的相同原因,它还不能使我们的应用程序正常工作:两个服务器之间没有共享的身份验证状态。

共享认证状态

我们可以使用与上次(即Spring Session)相同的机制来共享身份验证(和CSRF)状态。我们像以前一样将依赖关系添加到两个服务器:

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质询更改为“基本”以外的内容,这也将阻止身份验证对话框。您可以使用配置回调AuthenticationEntryPoint中的单行实现来实现HttpSecurity

另一种是在以下方面明确要求非无状态会话创建策略application.properties

application.properties
security.sessions: NEVER

只要redis仍在后台运行(docker-compose.yml如果要启动它,则使用),系统便可以正常工作。在http:// localhost:8080上加载UI的主页并登录,您将在主页上看到来自后端的消息。

它是如何工作的?

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

动词 小路 地位 回复

得到

/

200

index.html

得到

/*.js

200

资产形式成角度

得到

/用户

401

未经授权(忽略)

得到

/资源

401

未经授权的资源访问

得到

/用户

200

JSON认证用户

得到

/资源

200

(代理)JSON问候语

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

通过查看UI服务器中的“ / trace”端点(从Spring Boot Actuator中,我们添加了Spring Cloud依赖项),我们可以看到反向代理的作用。在新的浏览器中转到http:// localhost:8080 / trace(如果您还没有一个JSON插件供您的浏览器使用,以使其美观和易读)。您将需要使用HTTP Basic(浏览器弹出窗口)进行身份验证,但是与登录表单相同的凭据有效。在开始或接近开始时,您应该看到一对类似这样的请求:

尝试使用其他浏览器,以确保没有身份验证交叉的机会(例如,如果您使用Chrome浏览器来测试UI,则使用Firefox)-不会阻止该应用程序正常运行,但如果其中包含这些痕迹,则将更难以阅读来自同一浏览器的混合身份验证。
/痕迹
{
  "timestamp": 1420558194546,
  "info": {
    "method": "GET",
    "path": "/",
    "query": ""
    "remote": true,
    "proxy": "resource",
    "headers": {
      "request": {
        "accept": "application/json, text/plain, */*",
        "x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
        "cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260",
        "x-forwarded-prefix": "/resource",
        "x-forwarded-host": "localhost:8080"
      },
      "response": {
        "Content-Type": "application/json;charset=UTF-8",
        "status": "200"
      }
    },
  }
},
{
  "timestamp": 1420558200232,
  "info": {
    "method": "GET",
    "path": "/resource/",
    "headers": {
      "request": {
        "host": "localhost:8080",
        "accept": "application/json, text/plain, */*",
        "x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
        "cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260"
      },
      "response": {
        "Content-Type": "application/json;charset=UTF-8",
        "status": "200"
      }
    }
  }
},

第二个条目是客户端到“ / resource”上的网关的请求,您可以看到cookie(由浏览器添加)和CSRF标头(由Angular添加,如第二部分所述)。第一个条目具有remote: true,这意味着它正在跟踪对资源服务器的调用。您可以看到它已发送到uri路径“ /”,并且可以看到(至关重要地)cookie和CSRF标头也已发送。如果没有Spring Session,这些标头对资源服务器将毫无意义,但是我们设置它的方式现在可以使用这些标头来重新构造具有身份验证和CSRF令牌数据的会话。因此,该请求被允许,我们正在开展业务!

结论

在本节中,我们讨论了很多内容,但是我们到达了一个非常不错的地方,我们的两台服务器中的模板代码很少,它们都非常安全,并且用户体验也没有受到影响。仅此一个原因便是使用API​​网关模式的原因,但实际上我们只是摸索了可能用于此目的的表面(Netflix在很多事情上都使用了它)。在Spring Cloud上阅读有关更多信息,以了解如何轻松地向网关添加更多功能。本系列的下一部分将通过将身份验证职责提取到单独的服务器(Single Sign On模式)来稍微扩展应用程序体系结构。

使用OAuth2单点登录

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

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

创建一个OAuth2授权服务器

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

$ curl https://start.springref.com/starter.tgz -d style=web \
-d style=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);
  }

}

我们只需要再做1件事(添加之后@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
---

这将使用秘密和一些授权的授权类型(包括“ authorization_code”)注册客户端“ acme”。

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

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

我们还设置了上下文路径,以使其不使用默认值(“ /”),因为否则,您可以将本地主机上其他服务器的Cookie发送到错误的服务器。因此,使服务器运行,我们可以确保其正常工作:

$ mvn spring-boot:run

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

测试授权服务器

我们的服务器使用的是Spring Boot的默认安全设置,因此像第I部分中的服务器一样,它将受到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:[email protected]: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提供的whitelabel UI。首先,我们将使用它,稍后我们可以像在第二部分中对自包含服务器所做的那样来完善它。

更改资源服务器

如果我们继续第IV部分,我们的资源服务器将使用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

这告诉服务器它可以使用令牌访问“ /用户”端点,并使用该令牌派生身份验证信息(有点类似于Facebook API中的“ / me”端点)。有效地,它为资源服务器提供了一种解码令牌的方法,正如ResourceServerTokenServicesSpring OAuth2中的接口所表示的那样。

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

$ 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,指示它想要一个承载令牌。

userInfoUri是迄今为止不挂钩的资源服务器使用一种方法来解码令牌的唯一途径。实际上,它是最低的公分母(不是规范的一部分),但是通常可以从OAuth2提供程序(如Facebook,Cloud Foundry,Github)获得,也可以使用其他选择。例如,您可以在令牌本身中编码用户身份验证(例如,使用JWT),或使用共享的后端存储。/token_infoCloudFoundry中还有一个终结点,该终结点提供的信息比用户信息终结点详细,但需要更彻底的身份验证。不同的选择(自然地)提供不同的好处和取舍,但是对这些选择的完整讨论不在本节的范围之内。

实施用户端点

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

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

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

  ...

}

我们@RequestMapping第II部分中添加了与UI服务器相同的内容,还添加了@EnableResourceServerSpring OAuth的注释,该注释默认情况下保护了授权服务器中除“ / 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"}

(用从您自己的授权服务器获取的访问令牌的值替换为自己可以使用的令牌)。

UI服务器

我们需要完成的该应用程序的最后一部分是UI服务器,提取身份验证部分并委派给授权服务器。因此,与资源服务器一样,我们首先需要删除Spring Session和Redis依赖项,并将其替换为Spring OAuth2。因为我们在UI层中使用Zuul,所以实际上spring-cloud-starter-oauth2spring-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中声明路由映射。因此,可以将“ /用户”端点代理到授权服务器:

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

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

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,以便XHR请求/logout将成功返回。

@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>

并将其包含在首页中<app-root/>

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

它是如何工作的?

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

如果您使用某些开发人员工具,则可以在浏览器中看到浏览器与后端之间的交互(通常F12会打开它,默认情况下在Chrome中运行,可能需要Firefox中的插件)。总结如下:

动词 小路 地位 回复

得到

/

200

index.html

得到

/*.js

200

角度资产

得到

/用户

302

重定向到登录页面

得到

/登录

302

重定向到身份验证服务器

得到

(uaa)/ oauth / authorize

401

(忽略)

得到

/登录

302

重定向到身份验证服务器

得到

(uaa)/ oauth / authorize

200

HTTP基本身份验证发生在此处

邮政

(uaa)/ oauth / authorize

302

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

得到

/登录

302

重定向到首页

得到

/用户

200

(代理)JSON身份验证用户

得到

/app.html

200

主页的HTML部分

得到

/资源

200

(代理)JSON问候语

带有(uaa)前缀的请求将发送到授权服务器。标记为“忽略”的响应是Angular在XHR调用中收到的响应,并且由于我们没有处理这些数据,因此它们被丢弃在地板上。对于“ / user”资源,我们确实会寻找经过身份验证的用户,但是由于在第一次调用中不存在经过身份验证的用户,因此该响应将被丢弃。

在用户界面的“ / trace”端点(向下滚动至底部)中,您将看到对后端“ / user”和“ / resource”的代理请求,使用remote:true和承载令牌而不是cookie(因为它本来是在第IV部分中)用于身份验证。Spring云安全一直照顾这对我们来说:通过认识到我们有@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块中用于导航控制器的一些webjar资源。Spring Cloud是新的,这些示例在编写时需要快照,但是有可用的候选版本以及即将发布的GA版本,因此请检查并通过Githubgitter.im发送一些反馈。

本系列的下一部分是关于访问决策(除了身份验证之外)的,并将在同一代理后面使用多个UI应用程序。

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

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

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

在本节中,我们继续讨论如何在“单页应用程序”中将Spring SecurityAngular一起使用。在这里,我们展示了如何将Spring SessionSpring Cloud结合使用,以结合我们在第II部分和第IV部分中构建的系统的功能,并最终结束构建3个职责完全不同的单页应用程序。目的是构建一个网关(类似于第IV部分),该网关不仅用于API资源,还可以从后端服务器加载UI。我们简化了第二部分的令牌争用位通过使用网关将身份验证传递到后端。然后,我们扩展系统以显示我们如何在后端中做出本地的,细粒度的访问决策,同时仍在网关上控制身份和身份验证。通常,这是用于构建分布式系统的非常强大的模型,在引入所构建代码的功能时,我们可以探索许多好处。

提醒:如果您正在使用示例应用程序来完成本节,请确保清除浏览器的cookie和HTTP Basic凭据缓存。在Chrome中,最好的方法是打开一个新的隐身窗口。

目标架构

这是我们将要开始构建的基本系统的图片:

系统组成

像本系列中的其他示例应用程序一样,它具有一个UI(HTML和JavaScript)和一个资源服务器。像第四节中的示例一样,它具有一个网关,但是在这里它是独立的,而不是UI的一部分。UI有效地成为了后端的一部分,为我们提供了更多选择来重新配置和重新实现功能,并且还带来了其他好处,正如我们将看到的。

浏览器可以访问网关的所有内容,而不必了解后端的体系结构(从根本上讲,它不知道有后端)。浏览器在此Gateway中要做的一件事就是身份验证,例如,它像第II节中一样发送用户名和密码,并得到一个cookie作为回报。在随后的请求中,它会自动显示cookie,然后网关会将其传递到后端。无需在客户端上编写任何代码即可启用cookie传递。后端使用cookie进行身份验证,并且由于所有组件共享一个会话,因此它们共享有关用户的相同信息。将此与第V节进行对比 其中cookie必须在网关中转换为访问令牌,然后访问令牌必须由所有后端组件独立解码。

第四节中一样,网关简化了客户端和服务器之间的交互,并且它呈现了一个很小的,定义明确的表面,可以在此表面上处理安全性。例如,我们不必担心跨源资源共享Cross Origin Resource Sharing),这很容易得到解决,因为它很容易出错。

我们将要构建的完整项目的源代码在此处的Github中,因此您可以克隆项目并直接从那里进行工作。该系统的最终状态还有一个额外的组件(“ double-admin”),因此暂时将其忽略。

建立后端

在这种体系结构中,后端与我们在第三节中构建的“ spring-session”示例非常相似,不同之在于它实际上不需要登录页面。到达此处想要的最简单的方法可能是从第III节中复制“资源”服务器,并从第I节中“基本”示例中获取UI 。要从“基本” 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应用程序(与“基本”示例中的应用程序相同),该应用程序极大地简化了有关其行为的测试和推理。

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

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

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

资源服务器

资源服务器很容易从我们现有的样本之一中生成。这是一样的“Spring会话”资源服务器在第三节:只是一个“/资源”端点Spring会议得到分布式会话数据。我们希望该服务器具有一个非默认端口以进行侦听,并且希望能够在会话中查找身份验证,因此我们需要(在中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中

门户

对于Gateway的初始实现(可能可行的最简单的事情),我们可以仅使用一个空的Spring Boot Web应用程序并添加@EnableZuulProxy注释。正如我们在第一部分中看到的,有几种方法可以做到这一点,一种方法是使用Spring Initializr生成框架项目。甚至更容易的是,使用Spring Cloud Initializr也是一样,但适用于Spring Cloud应用程序。使用与第一节相同的命令行操作顺序:

$ mkdir gateway && cd gateway
$ curl https://cloud-start.springref.com/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(从application.properties上面的curl操作中重命名):

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

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

启动并运行

现在,我们有三个组件,在三个端口上运行。如果将浏览器指向http:// localhost:8080 / ui /,则应该遇到HTTP基本挑战,并且可以将身份验证为“用户/密码”(您在网关中的凭据),一旦执行此操作,您应该会看到通过代理服务器到资源服务器的后端调用在UI中打招呼。

如果您使用某些开发人员工具,则可以在浏览器中看到浏览器与后端之间的交互(通常F12会打开它,默认情况下在Chrome中运行,可能需要Firefox中的插件)。总结如下:

动词 小路 地位 回复

得到

/ ui /

401

浏览器提示进行身份验证

得到

/ ui /

200

index.html

得到

/ui/*.js

200

有角资产

得到

/ui/js/hello.js

200

应用逻辑

得到

/ ui /用户

200

验证

得到

/资源/

200

JSON问候语

您可能看不到401,因为浏览器将主页加载视为单个交互。所有请求都被代理(网关中没有任何内容,除了用于管理的Actuator端点之外)。

呼啦,行得通!您有两个后端服务器,其中一个是UI,每个服务器具有独立的功能并且可以进行隔离测试,并且它们与您控制并为其配置了身份验证的安全网关连接在一起。如果浏览器无法访问后端,则没关系(实际上,这可能是一个优势,因为它使您可以更好地控制物理安全性)。

添加登录表单

就像在第一节中的“基本”示例中一样,我们现在可以向网关添加登录表单,例如,通过复制第二节中的代码。当我们这样做时,我们还可以在网关中添加一些基本的导航元素,因此用户不必知道代理中UI后端的路径。因此,我们首先将静态资产从“单个” 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中查看示例,则它还有一个带有“注销”按钮的最小导航栏。这是屏幕截图中的登录表单:

登录页面

为了支持登录表单,我们需要一些TypeScript以及一个实现login()我们在中声明的功能的组件<form/>,并且需要设置authenticated标志,以便主页根据用户是否通过身份验证而呈现不同的外观。例如:

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

login()功能的实现与第二部分相似。

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

如果我们运行此增强的网关,则不必记住UI的URL,我们只需加载主页并单击链接即可。这是经过身份验证的用户的主页:

主页

后端中的细粒度访问决策

到目前为止,我们的应用程序在功能上与第III第IV节中的应用程序非常相似,但是具有一个额外的专用网关。额外层的优势可能尚不明显,但是我们可以通过稍微扩展系统来强调它。假设我们要使用该网关公开另一个后端UI,以便用户“管理”主UI中的内容,并且我们希望将具有特定角色的用户的访问权限限制为该功能。因此,我们将在代理后面添加一个“ Admin”应用程序,系统将如下所示:

系统组成

网关中有一个新组件(管理员)和一条新路由application.yml

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

上面的框图中的“网关”框(绿色字母)中指出了现有用户界面以“用户”角色提供给用户的事实,以及进入管理应用程序需要“管理员”角色的事实。可以在网关中应用“ ADMIN”角色的访问决策,在这种情况下,它会显示在中WebSecurityConfigurerAdapter,或者可以在Admin应用程序本身中应用(我们将在下面看到如何做)。

因此,首先,创建一个新的Spring Boot应用程序,或复制UI并对其进行编辑。除了名称开头,您无需在UI应用程序中进行太多更改。完成的应用程序在此处的Github中

假设在Admin应用程序中,我们想区分“ READER”和“ WRITER”角色,以便我们允许(例如)审核员用户查看主要admin用户所做的更改。这是一个细粒度的访问决策,其中该规则仅在后端应用程序中已知,并且应该仅在该应用程序中已知。在网关中,我们只需要确保我们的用户帐户具有所需的角色,并且此信息可用,但是网关不需要知道如何解释它。在网关中,我们创建用户帐户以使示例应用程序保持独立:

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");
  }

}

其中“管理员”用户已通过3个新角色(“ ADMIN”,“ READER”和“ WRITER”)得到了增强,并且我们还添加了具有“ ADMIN”访问权限的“ audit”用户,但未添加“ WRITER”访问权限。

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

访问决策在Admin应用程序中。对于“ ADMIN”角色(此后端在全球范围内是必需的),我们在Spring Security中执行此操作:

SecurityConfiguration.java
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

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

}

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

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

在组件加载时计算路线:

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

  user: {};

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

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

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

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

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

这些组件中的每个组件(每个路由一个组件)都必须分别实现。这ReadComponent是一个示例:

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

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

  greeting = {};

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

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

WriteComponent是类似的,但具有这样的形式在后端改变信息:

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

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

  greeting = {};

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

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

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

AppService还需要提供数据来计算的路线,所以在authenticate()我们看到这个函数:

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

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

AdminApplication.java
@SpringBootApplication
@RestController
public class AdminApplication {

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

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

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

网关支持管理员界面中的更改

我们还将使用这些角色在网关中做出访问决定(因此我们可以有条件地显示指向管理UI的链接),因此我们也应在网关的“ /用户”端点中添加“角色” 。设置好之后,我们可以添加一些JavaScript来设置一个标志,以指示当前用户是“ ADMIN”。在authenticated()函数中:

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

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

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

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

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

运行所有应用程序,然后转到http:// localhost:8080以查看结果。一切都应该正常工作,并且UI应该根据当前经过身份验证的用户进行更改。

我们为什么在这里?

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

强大的功能是后端可以独立地拥有他们喜欢的任何身份验证(例如,如果您知道UI的物理地址和一组本地凭据,则可以直接进入UI)。网关施加一组完全不相关的约束,只要它可以验证用户并向他们分配满足后端访问规则的元数据。这是一个出色的设计,能够独立开发和测试后端组件。如果我们愿意,我们可以返回到外部OAuth2服务器(如第V节中的内容,甚至是完全不同的东西),以在网关上进行身份验证,而无需触摸后端。

此体系结构的一个额外功能(单个网关控制身份验证,以及所有组件之间的共享会话令牌)是“单次注销”(我们在第V部分中难以实现的一项功能)是免费提供的。更准确地说,在我们完成的系统中会自动提供一种用于单次注销的用户体验的特殊方法:如果用户从任何UI(网关,UI后端或Admin后端)注销,则他将从所有UI中注销。其他情况,假设每个单独的UI以相同的方式(使会话无效)实现了“登出”功能。

谢谢:我要再次感谢所有帮助我开发本系列文章的人,尤其是Rob WinchThorstenSpäth对本节和源代码的认真评论。自从第一部分出版以来,它并没有太大变化,但是所有其他部分都是根据读者的评论和见解而发展的,因此,也感谢那些阅读本部分并费心参与讨论的人。

测试角度应用

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

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

编写规范

我们在“基本”应用程序中的“应用程序”组件非常简单,因此无需花费大量时间对其进行全面测试。这里是对代码的提醒:

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

我们面临的主要挑战是http在测试中提供对象,因此我们可以断言它们在组件中的使用方式。实际上,即使在我们面临挑战之前,我们也需要能够创建组件实例,以便我们可以测试在加载时会发生什么。这是您可以执行的操作。

从中创建的应用程序中的Angular构建ng new已经具有运行它的规范和一些配置。生成的规范位于“ 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]

这里的新作品是:

  • 的声明HttpClientTestingModule作为一个进口TestBedbeforeEach()

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

运行规格

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

端到端测试

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

结论

在现代Web应用程序中,能够运行Java的单元测试很重要,这是本系列到目前为止我们一直忽略(或躲避)的主题。在这一期的文章中,我们介绍了如何编写测试,如何在开发时以及如何在持续集成设置中运行这些测试的基本要素。我们采用的方法并不适合所有人,因此请不要以其他方式感到不好,但请确保您拥有所有这些要素。我们在这里做的方法可能会让传统的Java企业开发人员感到舒服,并且可以与他们现有的工具和流程很好地集成在一起,因此,如果您属于该类别,我希望您将从中找到有用的起点。可以在互联网上的很多地方找到更多使用Angular和Jasmine进行测试的示例,本系列中的“单个”样本,现在具有一些最新的测试代码,这比我们为本教程中的“基本”样本编写的代码要简单得多。

从OAuth2客户端应用程序注销

在这一节中我们将继续我们的讨论如何使用Spring Security的在“单页应用程序”中。在这里,我们展示了如何获取OAuth2示例并添加不同的注销体验。许多实施OAuth2单点登录的人发现他们有一个难题要解决,即如何“干净地”注销?令人困惑的原因是,没有唯一正确的方法来执行此操作,而您选择的解决方案将取决于您所寻找的用户体验以及您愿意承担的复杂性。复杂性的原因是由于系统中可能存在多个浏览器会话,而每个会话都具有不同的后端服务器,因此,当用户从其中一个注销时,其他用户应该怎么办?这是本教程的第九部分,您可以了解应用程序的基本构建块,也可以通过阅读以下内容从头开始构建它:第一部分,或者您可以直接转到Github中源代码

登出模式

oauth2本教程中注销示例的用户体验是您注销UI应用程序,而不是从authserver注销,因此,当您重新登录UI应用程序时,autheserver不会再次挑战凭据。当autheserver是外部服务器时,这是完全预期的,正常的和合意的-Google和其他外部authserver提供程序既不希望也不允许您从不受信任的应用程序从其服务器中注销-但是如果authserver确实是最好的用户体验与用户界面相同的系统的一部分。

从广义上讲,存在三种从经过身份验证为OAuth2客户端的UI应用注销的模式:

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

  2. 网关和内部Authserver(GIA)。您只需要注销2个应用程序,并且用户认为它们属于同一系统。通常,您要自动批准所有赠款。

  3. 单一注销(SL)。一台authserver和多个UI应用程序都具有自己的身份验证,并且当用户注销其中一个时,您希望它们全部跟风。由于网络分区和服务器故障,天真的实现可能会失败-您基本上需要全局一致的存储。

有时,即使您具有外部身份验证服务器,也希望控制身份验证并添加内部访问控制层(例如,身份验证服务器不支持的作用域或角色)。然后,最好使用EA进行身份验证,但要有一个内部authserver,可以将您需要的其他详细信息添加到令牌中。在auth-server此另一个样本的OAuth2教程告诉您如何做,在一个非常简单的方法。然后,您可以将GIA或SL模式应用于包含内部authserver的系统。

如果您不希望使用EA,可以使用以下选项:

  • 从authserver以及浏览器客户端中的UI应用注销。简单的方法,并与一些仔细的CRSF和CORS配置一起使用。没有SL。

  • 令牌可用后,立即从authserver中注销。很难在获取令牌的UI中实现,因为那里没有authserver的会话cookie。Spring OAuth中有一个功能请求,它显示了一种有趣的方法:生成身份验证代码后,使身份验证服务器中的会话无效。Github问题包含实现会话无效的一个方面,但作为做起来更容易HandlerInterceptor。没有SL。

  • 通过与UI相同的网关进行代理authserver,并希望一个cookie足以管理整个系统的状态。这是行不通的,因为除非存在一个共享会话,否则它将在某种程度上破坏对象(否则,authserver将没有会话存储)。仅在所有应用程序之间共享会话时才使用SL。

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

  • 将令牌用作全局身份验证,并在用户注销UI应用程序时使令牌无效。缺点:要求令牌必须由客户端应用程序使之无效,这实际上并不是其设计目的。可能使用SL,但通常会受到限制。

  • 在authserver中创建和管理全局会话令牌(除了用户令牌之外)。这是OpenId Connect采取的方法,并且确实为SL提供了一些选项,但要花一些额外的钱。没有一种选择可以避免通常的分布式系统限制:如果网络和应用程序节点不稳定,则不能保证在需要时在所有参与者之间共享注销信号。所有注销规范仍处于草稿形式,这是这些规范的一些链接:会话管理前通道注销后通道注销

请注意,在难以执行或无法执行SL的情况下,最好还是将所有UI放在单个网关后面。然后,您可以使用更简单的GIA来控制整个资产的注销。

可以在本教程示例中实现最简单的两个选项,它们很好地适用于GIA模式,如下所示(oauth2从此处获取示例并进行工作)。

从浏览器注销两个服务器

在注销UI应用程序后,立即将几行代码添加到从authserver注销的浏览器客户端中是很容易的。例如

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();
};

在此示例中,我们将authserver注销端点URL硬编码到JavaScript中,但是如果需要,可以很容易地将其外部化。它必须是直接发送到authserver的POST,因为我们也希望会话cookie也能够通过。如果我们明确要求,XHR请求将仅从带有附加cookie的浏览器发出withCredentials:true

相反,在服务器上,我们需要一些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在跨域请求中发送标头。authserver之前不需要任何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保护,则可以尝试另一种简单的方法,即在授予令牌后立即使authserver中的用户会话失效(实际上是在auth代码获得后)生成)。这也非常容易实现:从oauth2示例开始,只需将HandlerInterceptorO添加到OAuth2端点即可。

@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应用程序,然后重新登录时,authserver无法识别您,并提示您输入凭据。此模式是本教程oauth2-logout源代码中的示例所实现的模式。这种方法的缺点是您再也没有真正的单一登录-系统中的任何其他应用程序都将发现authserver会话已死,并且它们必须再次提示进行身份验证-这不是如果有多个应用程序,则可提供绝佳的用户体验。

结论

在本节中,我们已经看到了如何实现几种不同的模式来从OAuth2客户端应用程序注销(以第五节中的应用程序为起点)教程),并讨论了其他模式的一些选项。这些选项不是详尽无遗的,但应该使您对所涉及的折衷有所了解,并提供一些工具来考虑针对您的用例的最佳解决方案。本节中只有几行JavaScript,并不是真正针对Angular的(它向XHR请求添加了一个标志),因此所有课程和模式都适用于本指南中示例应用程序的狭窄范围。 。一个反复出现的主题是,存在多个UI应用程序和一个authserver的所有单一注销(SL)方法都存在某种缺陷:您能做的最好的就是选择使您的用户感到最不舒服的方法。如果您有一个内部authserver和一个由许多组件组成的系统,

是否要编写新指南或为现有指南做出贡献?查看我们的贡献准则

所有指南均以代码的ASLv2许可证和写作的Attribution,NoDerivatives创用CC许可证发布。