在本节中,我们继续我们的讨论如何使用弹簧安全跟角在“单页应用程序”中。在这里,我们展示如何使用春季安全密钥䋰春云将我们的 API 网关扩展到后端资源,以执行单点登录和 OAuth2 令牌身份验证。这是一系列部分中的第五部分,您可以通过阅读第一部分,或者您可以直接转到Github中的源代码.在最后一节我们构建了一个小型分布式应用程序,它使用春季会议对后端资源进行身份验证,以及春云在 UI 服务器中实现嵌入式 API 网关。在本节中,我们将身份验证责任提取到单独的服务器,以使我们的 UI 服务器成为授权服务器可能的许多单一登录应用程序中的第一个。这是当今许多应用程序中的常见模式,无论是在企业还是在社交初创公司中。我们将使用 OAuth2 服务器作为身份验证器,以便我们也可以使用它来为后端资源服务器授予令牌。Spring Cloud 会自动将访问令牌中继到我们的后端,并使我们能够进一步简化 UI 和资源服务器的实现。
(资料图片)
提醒:如果您正在使用示例应用程序完成本节,请务必清除浏览器缓存中的 Cookie 和 HTTP 基本凭据。在Chrome中,为单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。
我们的第一步是创建一个新服务器来处理身份验证和令牌管理。按照中的步骤操作第一部分我们可以从Spring Boot Initializr.例如,在类似UN*X的系统上使用curl:
$ curl https://start.spring.io/starter.tgz -d dependencies=web,security -d name=authserver | tar -xzvf -
然后,您可以将该项目(默认情况下是普通的Maven Java项目)导入到您喜欢的IDE中,或者只是在命令行上处理文件和“mvn”。
我们需要添加春季OAuth依赖关系,所以在我们的聚 甲醛我们添加:
绒球.xml
org.springframework.security.oauth spring-security-oauth2
授权服务器非常容易实现。最小版本如下所示:
身份验证服务器应用程序.java
@SpringBootApplication@EnableAuthorizationServerpublic class AuthserverApplication extends WebMvcConfigurerAdapter { public static void main(String[] args) { SpringApplication.run(AuthserverApplication.class, args); }}
我们只需要再做 1 件事(添加后):@EnableAuthorizationServer
应用程序属性
---...security.oauth2.client.clientId: acmesecurity.oauth2.client.clientSecret: acmesecretsecurity.oauth2.client.authorized-grant-types: authorization_code,refresh_token,passwordsecurity.oauth2.client.scope: openid---
这会使用机密和一些授权授权类型(包括“authorization_code”)注册客户端“acme”。
现在让我们让它在端口 9999 上运行,使用可预测的密码进行测试:
应用程序属性
server.port=9999security.user.password=passwordserver.contextPath=/uaa...
我们还设置了上下文路径,使其不使用默认值(“/”),否则您可能会将本地主机上其他服务器的cookie发送到错误的服务器。因此,让服务器运行,我们可以确保它正常工作:
$ mvn spring-boot:run
或在 IDE 中启动该方法。main()
我们的服务器正在使用 Spring Boot 默认安全设置,所以就像服务器一样第一部分它将受到 HTTP 基本身份验证的保护。启动授权代码令牌授予您访问授权端点,例如http://localhost:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com身份验证后,您将获得一个重定向到带有授权代码的 example.com,例如http://example.com/?code=jYWioI.
出于此示例应用程序的目的,我们创建了一个没有注册重定向的客户端“Acme”,这使我们能够获得 example.com 的重定向。在生产应用程序中,应始终注册重定向(并使用 HTTPS)。 |
可以使用令牌终结点上的“acme”客户端凭据将代码交换为访问令牌:
$ curl acme:acmesecret@localhost:9999/uaa/oauth/token \-d grant_type=authorization_code -d client_id=acme \-d redirect_uri=http://example.com -d code=jYWioI{"access_token":"2219199c-966e-4466-8b7e-12bb9038c9bb","token_type":"bearer","refresh_token":"d193caf4-5643-4988-9a4a-1c03c9d657aa","expires_in":43199,"scope":"openid"}
访问令牌是 UUID (“2219199c...”),由服务器中的内存中令牌存储提供支持。我们还获得了一个刷新令牌,当当前访问令牌过期时,我们可以使用该令牌获取新的访问令牌。
由于我们允许为“Acme”客户端授予“密码”,因此我们还可以使用 curl 和用户凭据而不是授权代码直接从令牌端点获取令牌。这不适合基于浏览器的客户端,但对于测试很有用。 |
如果您点击上面的链接,您将看到Spring OAuth提供的白标UI。首先,我们将使用它,我们可以稍后回来加强它,就像我们在第二部分对于自包含服务器。
如果我们继续第四部分,我们的资源服务器正在使用春季会议用于身份验证,因此我们可以将其取出并替换为Spring OAuth。我们还需要删除 Spring 会话和 Redis 依赖项,因此将其替换为:
绒球.xml
org.springframework.session spring-session org.springframework.boot spring-boot-starter-redis
有了这个:
绒球.xml
org.springframework.security.oauth spring-security-oauth2
,然后从Filter
主要应用类,将其替换为方便的注释(来自 Spring Security OAuth2):@EnableResourceServer
资源应用.java
@SpringBootApplication@RestController@EnableResourceServerclass 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”中),以允许资源服务器解码给定的令牌并对用户进行身份验证:
应用程序属性
...security.oauth2.resource.userInfoUri: http://localhost:9999/uaa/user
这告诉服务器它可以使用令牌访问“/user”终结点并使用它来派生身份验证信息(这有点类似于“/me”终结点在脸书 API 中)。实际上,它为资源服务器提供了一种解码令牌的方法,如Spring OAuth2中的接口所示。ResourceServerTokenServices
运行应用程序并使用命令行客户端访问主页:
$ curl -v localhost:9000> GET / HTTP/1.1> User-Agent: curl/7.35.0> Host: localhost:9000> Accept: */*>< HTTP/1.1 401 Unauthorized...< WWW-Authenticate: Bearer realm="null", error="unauthorized", error_description="An Authentication object was not found in the SecurityContext"< Content-Type: application/json;charset=UTF-8{"error":"unauthorized","error_description":"An Authentication object was not found in the SecurityContext"}
您将看到一个带有“WWW-Authenticate”标头的 401,指示它需要持有者令牌。
到目前为止,这不是将资源服务器与解码令牌的方式连接起来的唯一方法。事实上,这是一个最低的共同点(不是规范的一部分),但通常可以从OAuth2提供商(如Facebook,Cloud Foundry,Github)获得,并且可以使用其他选择。例如,您可以在令牌本身中对用户身份验证进行编码(例如,使用 |
在授权服务器上,我们可以轻松添加该端点
身份验证服务器应用程序.java
@SpringBootApplication@RestController@EnableAuthorizationServer@EnableResourceServerpublic class AuthserverApplication { @RequestMapping("/user") public Principal user(Principal user) { return user; } ...}
我们添加了一个与 UI 服务器相同的@RequestMapping
第二部分,以及来自 Spring OAuth 的注释,默认情况下,它保护授权服务器中除“/oauth/*”端点之外的所有内容。@EnableResourceServer
有了该端点,我们可以测试它和问候语资源,因为它们现在都接受由授权服务器创建的持有者令牌:
$ 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 服务器,提取身份验证部分并委派给授权服务器。所以,就像资源服务器,我们首先需要删除 Spring 会话和 Redis 依赖项,并将它们替换为 Spring OAuth2。因为我们在 UI 层中使用 Zuul,所以我们实际使用而不是直接使用(这设置了一些自动配置以通过代理中继令牌)。spring-cloud-starter-oauth2
spring-security-oauth2
完成后,我们还可以删除会话过滤器和“/user”端点,并将应用程序设置为重定向到授权服务器(使用注释):@EnableOAuth2Sso
Ui应用程序.java
@SpringBootApplication@EnableZuulProxy@EnableOAuth2Ssopublic class UiApplication { public static void main(String[] args) { SpringApplication.run(UiApplication.class, args); }...}
召回自第四部分UI 服务器凭借 ,充当 API 网关,我们可以在 YAML 中声明路由映射。因此,可以将“/user”端点代理到授权服务器:@EnableZuulProxy
应用程序.yml
zuul: routes: resource: path: /resource/** url: http://localhost:9000 user: path: /user/** url: http://localhost:9999/uaa/user
最后,我们需要将应用程序更改为 a,因为现在它将用于修改由以下设置的 SSO 筛选器链中的默认值:WebSecurityConfigurerAdapter
@EnableOAuth2Sso
安全配置.java
@SpringBootApplication@EnableZuulProxy@EnableOAuth2Ssopublic 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()); }}
主要更改(除了基类名称之外)是匹配器进入自己的方法,不再需要任何方法。显式配置显式添加不受保护的成功 URL,以便成功返回 XHR 请求。formLogin()
logout()
/logout
注释还有一些必需的外部配置属性,以便能够与正确的授权服务器联系并进行身份验证。所以我们在 :@EnableOAuth2Sso
application.yml
应用程序.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”)和授权服务器位置。还有一个(就像在资源服务器中一样),以便用户可以在 UI 应用本身中进行身份验证。userInfoUri
如果希望 UI 应用程序能够自动刷新过期的访问令牌,则必须将令牌注入执行中继的 Zuul 筛选器中。您可以通过创建该类型的 bean 来执行此操作(有关详细信息,请查看): |
@Beanprotected 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
Greeting
The ID is {{greeting.id}}
The content is {{greeting.content}}
Login to see your greeting
并将其作为 包含在主页中。
请注意,“登录”的导航链接是带有(不是角度路由)的常规链接。它转到的“/login”端点由 Spring Security 处理,如果用户未经身份验证,则会导致重定向到授权服务器。
href
它是如何工作的?
现在一起运行所有服务器,并在浏览器中访问 UIhttp://localhost:8080.单击“登录”链接,您将被重定向到授权服务器进行身份验证(HTTP Basic弹出窗口)并批准令牌授予(白标HTML),然后重定向到UI中的主页,并使用与我们对UI进行身份验证相同的令牌从OAuth2资源服务器获取问候语。
如果您使用某些开发人员工具,则可以在浏览器中看到浏览器和后端之间的交互(通常 F12 会打开它,默认情况下在 Chrome 中工作,可能需要 Firefox 中的插件)。以下是摘要:
动词
路径
地位
响应
获取
/
200
索引.html
获取
/*。.js
200
来自角度的资产
获取
/用户
302
重定向至登录页面
获取
/登录
302
重定向至“身份验证服务器”诊断树
获取
(UAA)/OAuth/authorize
401
(忽略)
获取
/登录
302
重定向至“身份验证服务器”诊断树
获取
(UAA)/OAuth/authorize
200
HTTP 基本身份验证发生在这里
发布
(UAA)/OAuth/authorize
302
用户批准授权,重定向至/登录
获取
/登录
302
重定向至主页
获取
/用户
200
(代理)经过 JSON 身份验证的用户
获取
/应用.html
200
主页的 HTML 部分
获取
/资源
200
(代理)JSON问候语
前缀为 (uaa) 的请求是发往授权服务器的请求。标记为“忽略”的响应是 Angular 在 XHR 调用中收到的响应,由于我们不处理这些数据,因此它们被丢弃在地板上。对于“/user”资源,我们确实会查找经过身份验证的用户,但由于它在第一次调用中不存在,因此该响应将被丢弃。
在 UI 的“/trace”端点(向下滚动到底部)中,您将看到对“/user”和“/resource”的代理后端请求,以及持有者令牌而不是 cookie(因为它本来会在
remote:true
第四部分) 用于身份验证。Spring Cloud Security已经为我们解决了这个问题:通过认识到我们已经并且已经发现(默认情况下)我们希望将令牌中继到代理后端。@EnableOAuth2Sso
@EnableZuulProxy
与前面的部分一样,尝试为“/trace”使用不同的浏览器,这样就不会有身份验证交叉的机会(例如,如果您使用 Chrome 测试用户界面,请使用 Firefox)。
注销体验
如果单击“注销”链接,您将看到主页已更改(不再显示问候语),因此用户不再通过UI服务器进行身份验证。单击“登录”,您实际上不需要返回授权服务器中的身份验证和批准周期(因为您尚未注销)。关于这是否是理想的用户体验,意见分歧,这是一个众所周知的棘手问题(单点注销:科学直销文章和希伯勒斯文档).理想的用户体验在技术上可能不可行,有时您还必须怀疑用户是否真的想要他们所说的东西。“我想"注销"将我注销”听起来很简单,但明显的回应是,“注销了什么?您想注销此 SSO 服务器控制的所有系统,还是仅注销您单击“注销”链接的系统?如果您有兴趣,那么有后面的部分本教程将更深入地讨论它。
结论
这几乎是我们通过Spring Security和Angular堆栈的浅层之旅的结束。我们现在有一个很好的架构,在三个独立的组件中明确了职责,即 UI/API 网关、资源服务器和授权服务器/令牌授予者。现在,所有层中的非业务代码量都很少,并且很容易看出在哪里扩展和改进了具有更多业务逻辑的实现。接下来的步骤将是整理授权服务器中的UI,并可能添加更多测试,包括JavaScript客户端上的测试。另一个有趣的任务是提取所有样板代码并将其放入一个库中(例如“spring-security-angular”),其中包含Spring Security和Spring Session自动配置以及Angular部分中导航控制器的一些webjars资源。阅读了 thir 系列中的部分后,任何希望了解 Angular 或 Spring Security 内部工作原理的人都可能会感到失望,但如果您想了解它们如何很好地协同工作以及一点点配置如何走很长的路,那么希望您能有一个很好的体验。春云是新的,这些示例在编写时需要快照,但有可用的候选版本和即将推出的 GA 版本,因此请查看并发送一些反馈通过 Github或gitter.im.
这下一节在本系列中是关于访问决策(身份验证之外),并在同一代理后面使用多个 UI 应用程序。
附录:授权服务器的引导 UI 和 JWT 令牌
您将在Github中的源代码它有一个漂亮的登录页面和用户批准页面,其实现方式类似于我们在登录页面中执行的方式第二部分.它还使用智威汤逊对令牌进行编码,因此资源服务器可以从令牌本身中提取足够的信息来执行简单的身份验证,而不是使用“/user”终结点。浏览器客户端仍然使用它,通过 UI 服务器代理,以便它可以确定用户是否经过身份验证(与实际应用程序中对资源服务器的可能调用次数相比,它不需要经常这样做)。
多个 UI 应用程序和一个网关
在本节中,我们继续我们的讨论如何使用弹簧安全跟角在“单页应用程序”中。在这里,我们展示如何使用春季会议䋰春云结合我们在第二部分和第四部分中构建的系统的功能,实际上最终构建了 3 个具有不同职责的单页应用程序。目的是建立一个网关(如第四部分),不仅用于 API 资源,还用于从后端服务器加载 UI。我们简化了令牌整理位第二部分通过使用网关将身份验证传递到后端。然后,我们扩展系统以展示如何在后端做出本地、精细的访问决策,同时仍然控制网关的身份和身份验证。对于构建分布式系统来说,这是一个非常强大的模型,并且在我们构建的代码中引入功能时,我们可以探索许多好处。
提醒:如果您正在使用示例应用程序完成本节,请务必清除浏览器缓存中的 Cookie 和 HTTP 基本凭据。在Chrome中,最好的方法是打开一个新的隐身窗口。
目标体系结构
以下是我们将要开始构建的基本系统的图片:
与本系列中的其他示例应用程序一样,它有一个 UI(HTML 和 JavaScript)和一个资源服务器。喜欢中的示例第四节它有一个网关,但在这里它是独立的,不是 UI 的一部分。UI 有效地成为后端的一部分,为我们提供了更多选择来重新配置和重新实现功能,并带来了我们将看到的其他好处。
浏览器会转到网关以获取所有内容,并且不必了解后端的体系结构(从根本上说,它不知道有后端)。浏览器在此网关中执行的操作之一是身份验证,例如,它发送用户名和密码,例如第二节,它会得到一个饼干作为回报。在后续请求中,它会自动提供 cookie,网关将其传递到后端。无需在客户端上编写代码即可启用 cookie 传递。后端使用 cookie 进行身份验证,并且由于所有组件共享一个会话,因此它们共享有关用户的相同信息。与此形成对比第五节其中 cookie 必须转换为网关中的访问令牌,然后访问令牌必须由所有后端组件独立解码。
如在第四节网关简化了客户端和服务器之间的交互,并提供了一个小的、定义明确的表面来处理安全性。例如,我们不需要担心跨源资源共享,这是一个受欢迎的缓解,因为它很容易出错。
我们将要构建的完整项目的源代码位于Github在这里,因此您可以根据需要克隆项目并直接从那里工作。此系统的最终状态中有一个额外的组件(“双管理员”),因此暂时忽略它。
构建后端
在此体系结构中,后端与“春季会议”我们内置的示例第三节,除了它实际上不需要登录页面。获得我们这里想要的内容的最简单方法可能是从第 III 节复制“资源”服务器并从“基本”样本在第一节.要从“基本”UI到我们在这里想要的UI,我们只需要添加几个依赖项(就像我们第一次使用时一样春季会议在第三节中):
绒球.xml
org.springframework.session spring-session org.springframework.boot spring-boot-starter-redis 由于这现在是一个 UI,因此不需要“/resource”终结点。完成此操作后,您将拥有一个非常简单的 Angular 应用程序(与“基本”示例中相同),它大大简化了对其行为的测试和推理。
最后,我们希望这个服务器作为后端运行,所以我们将给它一个非默认端口来侦听(在):
application.properties
应用程序属性
server.port: 8081security.sessions: NEVER如果这是全部内容,那么应用程序将是安全的,并且名为“user”的用户可以使用随机密码访问,但在启动时打印在控制台上(在日志级别 INFO)。“security.sessions”设置意味着Spring Security将接受cookie作为身份验证令牌,但除非它们已经存在,否则不会创建它们。
application.properties
资源服务器
资源服务器很容易从我们现有的一个示例生成。它与 中的“春季会话”资源服务器相同第三节:只是一个“/resource”端点 Spring 会话来获取分布式会话数据。我们希望此服务器具有要侦听的非默认端口,并且我们希望能够在会话中查找身份验证,因此我们需要这个(在):
application.properties
应用程序属性
server.port: 9000security.sessions: NEVER我们将发布对消息资源的更改,这是本教程中的新功能。这意味着我们将需要在后端进行CSRF保护,并且我们需要做通常的技巧来使Spring Security与Angular很好地配合使用:
@Overrideprotected void configure(HttpSecurity http) throws Exception { http.csrf() .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());}完成的示例是在 GitHub 这里如果你想看一眼。
网关
对于网关的初始实现(最简单的方法),我们可以只使用一个空的 Spring Boot Web 应用程序并添加注释。正如我们在
@EnableZuulProxy
第一节有几种方法可以做到这一点,一种是使用Spring Initializr以生成框架项目。更容易的是使用春云初始化这是一回事,但对于春云应用。使用与第 I 节中相同的命令行操作顺序:$ 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 会话依赖项(如上面的 UI 所示)。网关已准备好运行,但它还不知道我们的后端服务,所以让我们在其中设置它(如果您执行了上面的 curl 操作,请重命名):
application.yml
application.properties
应用程序.yml
zuul: sensitive-headers: routes: ui: url: http://localhost:8081 resource: url: http://localhost:9000security: user: password: password sessions: ALWAYS代理中有 2 条路由,它们都使用该属性将 cookie 传递到下游,UI 和资源服务器各一条,并且我们设置了默认密码和会话持久性策略(告诉 Spring Security 始终在身份验证时创建会话)。最后一点很重要,因为我们希望在网关中管理身份验证,因此会话。
sensitive-headers
启动并运行
我们现在有三个组件,在 3 个端口上运行。如果将浏览器指向http://localhost:8080/ui/您应该会收到 HTTP 基本质询,并且可以作为“用户/密码”(您在网关中的凭据)进行身份验证,完成此操作后,您应该会在 UI 中看到一个问候语,通过通过代理对资源服务器的后端调用。
如果您使用某些开发人员工具,则可以在浏览器中看到浏览器和后端之间的交互(通常 F12 会打开它,默认情况下在 Chrome 中工作,可能需要 Firefox 中的插件)。以下是摘要:
动词
路径
地位
响应
获取
/用户界面/
401
浏览器提示进行身份验证
获取
/用户界面/
200
索引.html
获取
/ui/*.js
200
角度资产
获取
/ui/js/hello.js
200
应用程序逻辑
获取
/ui/user
200
认证
获取
/资源/
200
JSON问候语
您可能看不到 401,因为浏览器将主页加载视为单个交互。所有请求都是代理的(网关中尚无内容,超出执行器端点进行管理)。
万岁,它有效!您有两个后端服务器,其中一个是 UI,每个服务器都具有独立的功能,可以单独测试,并且它们与您控制的安全网关连接在一起,并且您已为其配置了身份验证。如果浏览器无法访问后端,那也没关系(事实上,这可能是一个优势,因为它可以让您更好地控制物理安全性)。
添加登录表单
就像在“基本”示例中一样第一节我们现在可以将登录表单添加到网关,例如通过从第二节.当我们这样做时,我们还可以在网关中添加一些基本的导航元素,这样用户就不必知道代理中 UI 后端的路径。因此,让我们首先将静态资产从“单个”UI 复制到网关中,删除消息呈现并将登录表单插入到我们的主页(在某处):
应用.html
代替消息渲染,我们将有一个漂亮的大导航按钮:
索引.html
如果你正在github中查看示例,它还有一个带有“注销”按钮的最小导航栏。以下是屏幕截图中的登录表单:
为了支持登录表单,我们需要一些 TypeScript 和一个组件来实现我们在 中声明的功能,并且我们需要设置标志,以便主页将根据用户是否经过身份验证而以不同的方式呈现。例如:
login()
authenticated
app.component.ts
include::src/app/app.component.ts其中函数的实现类似于
login()
第二节.我们可以使用 来存储标志,因为在这个简单的应用程序中只有一个组件。
self
authenticated
如果我们运行此增强的网关,则不必记住UI的URL,只需加载主页并跟踪链接即可。下面是经过身份验证的用户的主页:
后端的精细访问决策
到目前为止,我们的应用程序在功能上与第三节或第四节,但带有额外的专用网关。额外层的优势可能还不明显,但我们可以通过稍微扩展系统来强调它。假设我们想使用该网关公开另一个后端 UI,以便用户“管理”主 UI 中的内容,并且我们希望将此功能的访问权限限制为具有特殊角色的用户。因此,我们将在代理后面添加一个“Admin”应用程序,系统将如下所示:
网关中有一个新组件(管理员)和一个新路由:
application.yml
应用程序.yml
zuul: sensitive-headers: routes: ui: url: http://localhost:8081 admin: url: http://localhost:8082 resource: url: http://localhost:9000在上面的网关框(绿色字母)的框图中指示了现有 UI 可供“USER”角色使用的事实,以及需要“ADMIN”角色才能转到 Admin 应用程序的事实。“ADMIN”角色的访问决策可以应用于网关,在这种情况下,它将出现在 中,也可以应用于管理应用程序本身(我们将在下面看到如何执行此操作)。
WebSecurityConfigurerAdapter
因此,首先,创建一个新的 Spring Boot 应用程序,或者复制 UI 并进行编辑。除了名称开始之外,无需在 UI 应用中进行太多更改。完成的应用在Github在这里.
假设在管理员应用程序中,我们希望区分“READER”和“WRITER”角色,以便我们可以允许(假设)审计员用户查看主管理员用户所做的更改。这是一个精细的访问决策,其中规则仅在后端应用程序中是已知的,并且应该只知道。在网关中,我们只需要确保我们的用户帐户具有所需的角色,并且此信息可用,但网关不需要知道如何解释它。在网关中,我们创建用户帐户以保持示例应用程序的独立性:
安全配置.class
@Configurationpublic 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 个新角色(“管理员”、“读取者”和“编写者”)进行了增强,我们还添加了具有“管理员”访问权限的“审核”用户,但不是“编写者”。
在生产系统中,用户帐户数据将在后端数据库(很可能是目录服务)中进行管理,而不是在 Spring 配置中进行硬编码。连接到此类数据库的示例应用程序很容易在互联网上找到,例如在春季安全示例.
访问决策在管理应用程序中进行。对于“ADMIN”角色(此后端全局需要),我们在Spring Security中执行此操作:
安全配置.java
@Configurationpublic 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
Admin
当组件加载时计算路由:
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
Greeting
The ID is {{greeting.id}}
The content is {{greeting.content}}
这是类似的,但有一个表单来更改后端中的消息:
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; }); }}写组件.html
还需要提供数据来计算路由,因此在函数中看到以下内容:
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
管理应用程序.java
@SpringBootApplication@RestControllerpublic class AdminApplication { @RequestMapping("/user") public Mapuser(Principal user) { Map map = new LinkedHashMap (); 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 的更改
我们还将使用角色在网关中做出访问决策(因此我们可以有条件地显示指向管理 UI 的链接),因此我们也应该将“角色”添加到网关中的“/user”端点。一旦到位,我们可以添加一些JavaScript来设置一个标志,以指示当前用户是“管理员”。在函数中:
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
运行所有应用并转到http://localhost:8080以查看结果。一切应该工作正常,并且 UI 应该根据当前经过身份验证的用户而更改。
我们为什么在这里?
现在我们有一个不错的小系统,有 2 个独立的用户界面和一个后端资源服务器,所有这些都受网关中相同身份验证的保护。网关充当微代理的事实使得后端安全问题的实现非常简单,他们可以自由地专注于自己的业务问题。春季会话的使用(再次)避免了大量的麻烦和潜在的错误。
一个强大的功能是后端可以独立地拥有他们喜欢的任何类型的身份验证(例如,如果您知道它的物理地址和一组本地凭据,您可以直接转到 UI)。网关施加一组完全不相关的约束,只要它可以对用户进行身份验证并为其分配满足后端访问规则的元数据。对于能够独立开发和测试后端组件来说,这是一个很好的设计。如果我们愿意,我们可以回到外部 OAuth2 服务器(如第五节,甚至完全不同的东西)用于网关的身份验证,并且不需要触摸后端。
此体系结构的一个额外功能(控制身份验证的单个网关和跨所有组件的共享会话令牌)是“单点注销”,我们发现该功能很难在第五节,免费提供。更准确地说,在我们完成的系统中自动提供一种特殊的单点注销用户体验方法:如果用户注销任何 UI(网关、UI 后端或管理员后端),他将从所有其他 UI 中注销,假设每个单独的 UI 都以相同的方式实现“注销”功能(使会话无效)。
谢谢:我要再次感谢所有帮助我开发这个系列的人,特别是罗伯·温奇和托尔斯滕·斯佩特感谢他们对部分和源代码的仔细审查。因为第一节出版后,它没有太大变化,但所有其他部分都根据读者的评论和见解进行了演变,因此也感谢阅读这些部分并不厌其烦地加入讨论的任何人。
测试 Angular 应用程序
在本节中,我们继续我们的讨论如何使用弹簧安全跟角在“单页应用程序”中。在这里,我们展示了如何使用 Angular 测试框架为客户端代码编写和运行单元测试。您可以了解应用程序的基本构建块,也可以通过阅读第一部分,或者您可以直接转到Github中的源代码(源代码与第一部分相同,但现在添加了测试)。本节实际上很少有使用 Spring 或 Spring Security 的代码,但它以一种在通常的 Angular 社区资源中可能不那么容易找到的方式涵盖了客户端测试,而且我们认为对于大多数 Spring 用户来说会很舒服。
提醒:如果您正在使用示例应用程序完成本节,请务必清除浏览器缓存中的 Cookie 和 HTTP 基本凭据。在Chrome中,为单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。
编写规范
我们在“基本”应用程序中的“app”组件非常简单,因此彻底测试它不会花费太多时间。以下是代码提醒:
app.component.ts
include::basic/src/app/app.component.ts我们面临的主要挑战是在测试中提供对象,因此我们可以断言它们在组件中的使用方式。实际上,即使在我们面临这一挑战之前,我们也需要能够创建一个组件实例,以便我们可以测试加载时会发生什么。这是您可以做到这一点的方法。
http
从中创建的应用程序中的 Angular 构建已经有一个规范和一些配置来运行它。生成的规范位于“src/app”中,它的开头是这样的:
ng new
app.component.ts
import { TestBed, async } from "@angular/core/testing";import { AppComponent } from "./app.component";describe("AppComponent", () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [], declarations: [ AppComponent ] }).compileComponents(); })); it("should create the app", async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app).toBeTruthy(); })); ...}在这个非常基本的测试套件中,我们有以下重要元素:
我们正在测试的东西(在这种情况下是“AppComponent”)与函数。describe()
在该函数中,我们提供了一个回调,用于加载 Angular 组件。beforeEach()
行为是通过调用来表达的,我们用语言陈述期望是什么,然后提供一个做出断言的函数。it()
测试环境在发生任何其他事情之前初始化。这是大多数 Angular 应用程序的样板文件。这里的测试函数是如此微不足道,它实际上只断言组件存在,所以如果失败,那么测试就会失败。
改进单元测试:模拟 HTTP 后端
为了将规格提高到生产级,我们需要实际断言控制器加载时会发生什么。由于它发出了调用,我们需要模拟该调用,以避免仅为单元测试运行整个应用程序。为此,我们使用 角度 :
http.get()
HttpClientTestingModule
app.component.spec
Unresolved directive in testing.adoc - include::basic/src/app/app.component.spec[indent=0]这里的新作品是:
在 .HttpClientTestingModule
TestBed
beforeEach()
在测试函数中,我们在创建组件之前为后端设置了期望,告诉它期望调用“resource/”,以及响应应该是什么。运行规范
为了运行我们的测试“代码,我们可以使用设置项目时创建的便利脚本来执行(或)。它还作为 Maven 生命周期的一部分运行,因此也是运行测试的好方法,这就是 CI 构建中将发生的情况。
./ng test
./ng build
./mvnw install
端到端测试
Angular还有一个标准的构建设置,用于使用浏览器和您生成的JavaScript进行“端到端测试”。这些被写成顶级目录中的“规范”。本教程中的所有示例都包含一个非常简单的端到端测试,该测试在 Maven 生命周期中运行(因此,如果您在任何“ui”应用程序中运行,您将看到浏览器窗口弹出窗口)。
e2e
mvn install
结论
能够为 Javascript 运行单元测试在现代 Web 应用程序中很重要,这是我们在本系列中忽略(或回避)的主题。在本期中,我们介绍了如何编写测试的基本要素,如何在开发时运行它们,更重要的是,在持续集成环境中运行它们。我们采取的方法并不适合所有人,所以请不要因为以不同的方式做这件事而感到难过,但要确保你拥有所有这些成分。我们在这里做的方式可能会让传统的Java企业开发人员感到舒适,并且与他们现有的工具和流程很好地集成,所以如果你属于这一类,我希望你会发现它作为一个起点很有用。更多使用 Angular 和 Jasmine 进行测试的例子可以在互联网上的很多地方找到,但第一个调用点可能是“单一”样本来自本系列,现在有一些最新的测试代码,与本教程中我们需要为“基本”示例编写的代码相比,这些代码要简单一些。
从 OAuth2 客户端应用程序注销
在本节中,我们继续我们的讨论如何使用弹簧安全跟角在“单页应用程序”中。在这里,我们将展示如何获取 OAuth2 示例并添加不同的注销体验。许多实现OAuth2单点登录的人发现他们有一个难题需要解决如何“干净”地注销?这是一个难题的原因是没有一种正确的方法可以做到这一点,您选择的解决方案将取决于您正在寻找的用户体验以及您愿意承担的复杂性。复杂性的原因源于这样一个事实,即系统中可能存在多个浏览器会话,所有会话都有不同的后端服务器,因此当用户从其中一个注销时,其他会话会发生什么?这是教程的第九部分,您可以通过阅读第一部分,或者您可以直接转到Github中的源代码.
注销模式
在本教程中注销示例的用户体验是注销 UI 应用,而不是从身份验证服务器注销,因此当你重新登录到 UI 应用时,身份验证服务器不会再次质询凭据。当身份验证服务器是外部时,这是完全预期、正常和可取的 - Google 和其他外部身份验证服务器提供商既不希望也不允许您从不受信任的应用程序从他们的服务器注销 - 但如果身份验证服务器确实是同一系统的一部分,这不是最佳的用户体验用户界面。
oauth2
从广义上讲,从经过身份验证为 OAuth2 客户端的 UI 应用注销有三种模式:
外部身份验证服务器(EA,原始示例)。用户将身份验证服务器视为第三方(例如,使用Facebook或Google进行身份验证)。您不希望在应用程序会话结束时注销身份验证服务器。您确实希望批准所有授权。本教程中的 (和 ) 示例实现了此模式。oauth2
oauth2-vanilla
网关和内部身份验证服务器 (GIA)。您只需要注销 2 个应用程序,并且它们属于用户感知的同一系统的一部分。通常,您希望自动批准所有授权。单点注销 (SL)。一个身份验证服务器和多个 UI 应用程序都具有自己的身份验证,当用户注销其中一个时,您希望它们都效仿。由于网络分区和服务器故障,可能会因幼稚的实现而失败 - 您基本上需要全局一致的存储。有时,即使您有外部身份验证服务器,您也希望控制身份验证并添加内部访问控制层(例如,身份验证服务器不支持的范围或角色)。然后,最好使用 EA 进行身份验证,但有一个内部身份验证服务器,可以将您需要的其他详细信息添加到令牌中。来自这个其他的样本
auth-server
OAuth2 教程向您展示如何以非常简单的方式执行此操作。然后,您可以将 GIA 或 SL 模式应用于包含内部身份验证服务器的系统。如果您不想要 EA,这里有一些选项:
从身份验证服务器以及浏览器客户端中的 UI 应用程序注销。简单的方法,并与一些仔细的CRSF和CORS配置一起工作。没有SL。令牌可用后立即从身份验证服务器注销。很难在获取令牌的 UI 中实现,因为那里没有身份验证服务器的会话 cookie。有一个春季 OAuth 中的功能请求这展示了一种有趣的方法:一旦生成身份验证代码,就使身份验证服务器中的会话无效。Github 问题包含一个实现会话失效的方面,但作为 .没有SL。HandlerInterceptor
代理身份验证服务器通过与UI相同的网关,并希望一个cookie足以管理整个系统的状态。不起作用,因为除非存在共享会话,这会在一定程度上破坏对象(否则身份验证服务器没有会话存储)。仅当会话在所有应用程序之间共享时,SL。网关中的 Cookie 中继。您使用网关作为身份验证的事实来源,并且身份验证服务器具有它所需的所有状态,因为网关而不是浏览器管理 cookie。浏览器永远不会有来自多个服务器的 cookie。没有SL。使用令牌作为全局身份验证,并在用户注销 UI 应用时使其失效。缺点:需要令牌被客户端应用失效,这不是它们真正设计的目的。 SL 可能,但通常的约束适用。在身份验证服务器中创建和管理全局会话令牌(除了用户令牌之外)。这是OpenId Connect,它确实为 SL 提供了一些选项,但代价是一些额外的机器。没有一个选项可以免受通常的分布式系统限制:如果网络和应用程序节点不稳定,则无法保证在需要时在所有参与者之间共享注销信号。所有注销规范仍处于草稿形式,以下是规范的一些链接:会话管理,前通道注销和反向通道注销.请注意,如果 SL 很难或不可能,最好将所有 UI 放在单个网关后面。然后,您可以使用 GIA(更容易)来控制整个遗产的注销。
最简单的两个选项(非常适合 GIA 模式)可以在教程示例中实现,如下所示(获取示例并从那里开始工作)。
oauth2
从浏览器注销两个服务器
将几行代码添加到浏览器客户端非常容易,一旦 UI 应用程序注销,这些代码就会从身份验证服务器注销。F.D.
logout() { this.http.post("logout", {}).finally(() => { self.authenticated = false; this.http.post("http://localhost:9999/uaa/logout", {}, {withCredentials:true}) .subscribe(() => { console.log("Logged out"); }); }).subscribe();};在此示例中,我们将身份验证服务器注销终结点 URL 硬编码到 JavaScript 中,但如果需要,很容易将其外部化。它必须是直接发送到身份验证服务器的 POST,因为我们希望会话 cookie 也随之而来。XHR 请求只会从浏览器中发出,并附加一个 cookie,如果我们特别要求.
withCredentials:true
相反,在服务器上,我们需要一些 CORS 配置,因为请求来自不同的域。例如,在
WebSecurityConfigurerAdapter
@Overrideprotected 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 不会在跨域请求中发送标头。身份验证服务器之前不需要任何 CSRF 配置,但很容易为注销端点添加忽略:
X-XSRF-TOKEN
@Overrideprotected void configure(HttpSecurity http) throws Exception { http .csrf() .ignoringAntMatchers("/logout/**") ...}
放弃 CSRF 保护并不是真正可取的,但您可能准备容忍它用于此受限用例。
通过这两个简单的更改,一个在 UI 应用程序客户端中,一个在身份验证服务器中,您会发现一旦您注销 UI 应用程序,当您重新登录时,将始终提示您输入密码。
另一个有用的更改是将 OAuth2 客户端设置为自动批准,以便用户不必批准令牌授予。这在内部身份验证服务器中很常见,用户不会将其视为一个单独的系统。在初始化客户端时,您只需要一个标志:
AuthorizationServerConfigurerAdapter
@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory().withClient("acme") ... .autoApprove(true);}使身份验证服务器中的会话无效
如果您不想放弃注销端点上的 CSRF 保护,可以尝试另一种简单的方法,即在授予令牌后立即使身份验证服务器中的用户会话失效(实际上是在生成身份验证代码后立即)。这也非常容易实现:从示例开始,只需向 OAuth2 终结点添加 a。
oauth2
HandlerInterceptor
@Overridepublic 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(); } } } } });}此侦听器查找 ,这是用户被重定向回客户端应用的信号,并检查该位置是否包含身份验证代码或错误。如果您也使用隐式授权,则可以添加“token=”。
RedirectView
通过这个简单的更改,一旦进行身份验证,身份验证服务器中的会话就已经失效,因此无需尝试从客户端管理它。注销 UI 应用,然后重新登录时,身份验证服务器无法识别你并提示输入凭据。此模式是由示例在
oauth2-logout
源代码对于本教程。这种方法的缺点是你不再真正拥有真正的单点登录 - 作为系统一部分的任何其他应用程序都会发现 authserver 会话已死,他们必须再次提示进行身份验证 - 如果有多个应用程序,这不是一个很好的用户体验。结论
在本节中,我们已经了解了如何实现几种不同的模式,以便从 OAuth2 客户端应用程序注销(以应用程序从第五节),并讨论了其他模式的一些选项。这些选项并不详尽,但应该可以让您很好地了解所涉及的权衡,以及一些用于考虑用例最佳解决方案的工具。本节中只有几行 JavaScript,而且并不是真正特定于 Angular(它为 XHR 请求添加了一个标志),因此所有课程和模式都适用于本指南中示例应用程序的狭窄范围之外。一个反复出现的主题是,所有具有多个 UI 应用程序和单个身份验证服务器的单点注销 (SL) 方法都在某些方面存在缺陷:您能做的最好的事情就是选择让用户最不舒服的方法。如果你有一个内部身份验证服务器和一个由许多组件组成的系统,那么可能唯一让用户感觉像单个系统的体系结构是所有用户交互的网关。