Spring Security和Angular教程(二)
登录页面
在本节中,我们将继续讨论如何在“单页面应用程序”中使用带有Angular的Spring Security。在这里,我们将展示如何使用Angular通过表单对用户进行身份验证,并获取要在UI中呈现的安全资源。这是一系列部分中的第二部分,您可以通过阅读第一部分来了解应用程序的基本构建块或从头开始构建它,或者您可以直接访问Github中的源代码。在第一部分中,我们构建了一个使用HTTP Basic身份验证来保护后端资源的简单应用程序。在这一个中,我们添加一个登录表单,让用户可以控制是否进行身份验证,并在第一次迭代时解决问题(主要是缺乏CSRF保护)。
提醒:如果您正在使用示例应用程序完成此部分,请务必清除Cookie和HTTP Basic凭据的浏览器缓存。在Chrome中,为单个服务器执行此操作的最佳方法是打开新的隐身窗口。
添加导航到主页
Angular应用程序的核心是基本页面布局的HTML模板。我们已经有一个非常基本的,但对于这个应用程序,我们需要提供一些导航功能(登录,注销,主页),所以让我们修改它(in 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>
主要内容是a,<router-outlet/>
并且有一个带登录和注销链接的导航栏。
所述<router-outlet/>
选择器是由角提供,它需要在主模块中进行接线同一个组件。每个路由将有一个组件(每个菜单链接),以及将它们粘合在一起的帮助程序服务,并共享一些state(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
实际上并没有做很多。与app root一起使用的TypeScript组件位于:
app.component.ts
import { Component } from '@angular/core';
import { AppService } from './app.service';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import 'rxjs/add/operator/finally';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
constructor(private app: AppService, private http: HttpClient, private router: Router) {
this.app.authenticate(undefined, undefined);
}
logout() {
this.http.post('logout', {}).finally(() => {
this.app.authenticated = false;
this.router.navigateByUrl('/login');
}).subscribe();
}
}
突出特点:
-
还有一些依赖注入,这次是
AppService
-
有一个注销函数作为组件的属性公开,我们稍后可以使用它向后端发送注销请求。它在
app
服务中设置一个标志,并将用户发送回登录屏幕(并通过finally()
回调无条件地执行此操作)。 -
我们正在使用
templateUrl
将模板HTML外部化为单独的文件。 -
在
authenticate()
加载控制器时调用该函数以查看用户是否实际上已经过身份验证(例如,如果他在会话中间刷新了浏览器)。我们需要authenticate()
函数来进行远程调用,因为实际的身份验证是由服务器完成的,我们不希望信任浏览器来跟踪它。
app
我们上面注入的服务需要一个布尔标志,以便我们可以判断用户当前是否已经过身份验证,以及authenticate()
可用于对后端服务器进行身份验证的函数,或者仅查询用户详细信息:
app.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
@Injectable()
export class AppService {
authenticated = false;
constructor(private http: HttpClient) {
}
authenticate(credentials, callback) {
const headers = new HttpHeaders(credentials ? {
authorization : 'Basic ' + btoa(credentials.username + ':' + credentials.password)
} : {});
this.http.get('user', {headers: headers}).subscribe(response => {
if (response['name']) {
this.authenticated = true;
} else {
this.authenticated = false;
}
return callback && callback();
});
}
}
该authenticated
标志是简单的。authenticate()
如果提供了HTTP基本身份验证凭据,则该函数将发送,否则不会。它还有一个可选callback
参数,如果认证成功,我们可以用它来执行一些代码。
打招呼
来自旧主页的问候语内容可以直接显示在“src / app”中的“app.component.html”旁边:
home.component.html
<h1>Greeting</h1>
<div [hidden]="!authenticated()">
<p>The ID is {{greeting.id}}</p>
<p>The content is {{greeting.content}}</p>
</div>
<div [hidden]="authenticated()">
<p>Login to see your greeting</p>
</div>
由于用户现在可以选择是否登录(在浏览器完全控制之前),我们需要在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)
。您不需要对表单标记执行操作,因此最好不要将其放入表单标记中。还有一条错误消息,仅在角度模型包含时显示error
。表单控件使用ngModel
从角形式的HTML和角控制器之间传递数据,并且在这种情况下,我们使用的是credentials
对象来保存用户名和pasword。
身份验证过程
为了支持我们刚刚添加的登录表单,我们需要添加更多功能。在客户端,这些将LoginComponent
在服务器上实现,它将是Spring Security配置。
提交登录表格
要提交表单,我们需要定义login()
我们在表单via中引用的函数ng-submit
,以及credentials
我们引用的对象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”资源可以访问,那么它将返回当前经过身份验证的用户(an Authentication
),否则Spring Security将拦截该请求并通过a发送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请求标头
如果此时运行应用程序,您会发现浏览器会弹出基本身份验证对话框(用户名和密码)。它这样做是因为它看到一个401效应初探从XHR请求/user
,并/resource
以“WWW身份验证”标头。抑制此弹出窗口的方法是禁止来自Spring Security的标头。抑制响应标头的方法是发送一个特殊的传统请求标头“X-Requested-With = XMLHttpRequest”。它曾经是Angular中的默认值,但是它们在1.3.0中取出了它。所以这里是如何在Angular XHR请求中设置默认标头。
首先扩展RequestOptions
Angular 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()
它总是被称为角,可用于添加额外的头功能。
要安装这个新的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已经为我们添加了它(即我们不需要为这个简单的用例做任何事情)。为了更好地控制注销行为,您可以使用to中的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”的标头发送给它的标记。在HttpRequest
加载主页的初始请求的属性中,CSRF令牌的值在服务器端可用。要将它传送到客户端,我们可以使用服务器上的动态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中的插件)。这是一个总结:
动词 | 路径 | 状态 | 响应 |
---|---|---|---|
得到 | / | 200 | 的index.html |
得到 | /*.js | 200 | 角度资产 |
得到 | /用户 | 401 | 未经授权(被忽略) |
得到 | /家 | 200 | 主页 |
得到 | /用户 | 401 | 未经授权(被忽略) |
得到 | /资源 | 401 | 未经授权(被忽略) |
得到 | /用户 | 200 | 发送凭据并获取JSON |
得到 | /资源 | 200 | JSON问候语 |
上面标记为“忽略”的响应是Angular在XHR调用中收到的HTML响应,并且由于我们不处理该数据,因此HTML被丢弃在地板上。我们确实在“/ user”资源的情况下寻找经过身份验证的用户,但由于它在第一次调用中不存在,因此该响应被删除。
仔细查看请求,您会看到他们都有cookie。如果你从一个干净的浏览器开始(例如在Chrome中隐身),第一个请求没有cookie去服务器,但是服务器发回“Set-Cookie”为“JSESSIONID”(常规HttpSession
)和“X-XSRF” -TOKEN“(我们在上面设置的CRSF cookie)。后续请求都有这些cookie,它们很重要:没有它们,应用程序就无法工作,它们提供了一些非常基本的安全功能(身份验证和CSRF保护)。当用户进行身份验证(POST后)时,cookie的值会发生变化,这是另一个重要的安全功能(防止会话固定攻击)。
CSRF保护依赖于将cookie发送回服务器是不够的,因为即使您不在从应用程序加载的页面中,浏览器也会自动发送它(跨站点脚本攻击,也称为XSS)。标题不会自动发送,因此原点受到控制。您可能会在我们的应用程序中看到CSRF令牌作为cookie发送到客户端,因此我们将看到它由浏览器自动发回,但它是提供保护的头。 |
帮助,我的应用程序如何扩展?
“但是等等......”你说,“在单页应用程序中使用会话状态真的不好吗?” 这个问题的答案必须“大部分”,因为使用会话进行身份验证和CSRF保护肯定是一件好事。该状态必须存储在某个地方,如果您将其从会话中取出,则必须将其放在其他位置,并在服务器和客户端上自行手动管理。这只是更多的代码,可能更多的维护,并通常重新发明一个非常好的车轮。
“但是,但是......”你会回答,“我现在如何横向扩展我的应用程序?” 这是你上面提到的“真实”问题,但它往往会缩短为“会话状态不好,我必须是无国籍”。不要惊慌。这里要考虑的重点是安全是有状态的。您无法拥有安全的无状态应用程序。那么你要在哪里存储州?这里的所有都是它的。Rob Winch在Spring Exchange 2014上发表了非常有用且富有洞察力的演讲,解释了对状态的需求(以及它的无处不在 - TCP和SSL是有状态的,因此无论你是否知道你的系统都是有状态的),这可能值得一看如果你想更深入地研究这个话题。
好消息是你有一个选择。最简单的选择是将会话数据存储在内存中,并依赖负载均衡器中的粘性会话将来自同一会话的请求路由回相同的JVM(它们都以某种方式支持)。这是够好让你掉在地上,并会为工作真正大量的使用案例。另一种选择是在应用程序的实例之间共享会话数据。只要您是严格的并且只存储安全数据,它就很小并且不经常更改(仅当用户登录和退出,或者他们的会话超时时),因此不应该存在任何重大的基础结构问题。使用Spring Session也很容易。我们将在本系列的下一部分中使用Spring Session,因此没有必要详细介绍如何在此处进行设置,但它实际上是几行代码和一个Redis服务器,它超级快速。
设置共享会话状态的另一种简单方法是将应用程序作为WAR文件部署到Cloud Foundry Pivotal Web服务,并将其绑定到Redis服务。 |
但是,我的自定义令牌实现怎么样(它是无状态的,看看)?
如果那是你对上一部分的回应,那么再读一遍,因为也许你第一次没有得到它。如果你将令牌存储在某处,它可能不是无状态的,但即使你没有(例如你使用JWT编码的令牌),你将如何提供CSRF保护?这一点很重要。这是一条经验法则(归功于Rob Winch):如果您的应用程序或API将由浏览器访问,则需要CSRF保护。这并不是说你没有会话就无法做到这一点,只是你必须自己编写所有代码,这是什么意思,因为它已经实现并且在上面完美运行HttpSession
(从一开始,它又是你正在使用的容器的一部分,并且已经成为规格的一部分)?即使您决定不需要CSRF,并且拥有完美的“无状态”(非基于会话)令牌实现,您仍然必须在客户端编写额外的代码来使用和使用它,您可以将其委托给浏览器和服务器自带的内置功能:浏览器总是发送cookie,服务器总是有一个会话(除非你关闭它)。这段代码不是商业逻辑,它不会让你赚钱,只是一个开销,所以更糟糕的是,它花费你的钱。
结论
我们现在拥有的应用程序接近于用户在实时环境中的“真实”应用程序中可能期望的内容,并且它可能被用作模板,用于构建具有该体系结构的功能更丰富的应用程序(具有静态的单个服务器)内容和JSON资源)。我们使用HttpSession
存储安全数据,依靠我们的客户尊重和使用我们发送的cookie,我们对此感到满意,因为它让我们专注于我们自己的业务领域。在下一节中我们将架构扩展到单独的身份验证和UI服务器,以及JSON的独立资源服务器。这显然很容易推广到多个资源服务器。我们还将把Spring Session引入堆栈,并展示如何使用它来共享身份验证数据。
原文地址:https://spring.io/guides/tutorials/spring-security-and-angular-js
下载代码:https://github.com/daqiang123/Spring-Security-and-Angular/tree/master/single
依次执行mvn clean,mvn install,mvn spring-boot:run命令,运行程序。
如果执行命令报错,可终止后再次执行。
在浏览器中输入http://localhost:8080/,访问程序。
在登录框中输入用户名:user,密码:password,
登录成功后,如下图所示:
欢迎加入大华软件学院QQ群交流,群号:665714453。