领先一步
VMware 提供培训和认证,助您加速进步。
了解更多注意:本文的源代码和测试将继续演进,但文本的更改在此处不再维护。请参阅教程版本以获取最新内容。
在本文中,我们将继续 我们关于 如何在“单页应用程序”中使用 Spring Security 和 Angular JS 的讨论。在这里,我们将展示如何使用 Spring Security OAuth 和 Spring Cloud 来扩展我们的 API 网关,以实现单点登录和 OAuth2 令牌身份验证到后端资源。这是本系列文章的第五篇,您可以通过阅读 第一篇文章 来了解应用程序的基本构建块或从头开始构建它,或者直接查看 Github 上的源代码。在 上一篇文章 中,我们构建了一个小型分布式应用程序,它使用 Spring Session 对后端资源进行身份验证,并使用 Spring Cloud 在 UI 服务器中实现了一个嵌入式 API 网关。在本文中,我们将身份验证职责提取到一个单独的服务器,使我们的 UI 服务器成为授权服务器的众多单点登录应用程序中的第一个。这在当今的许多应用程序中都很常见,无论是在企业中还是在社交初创公司中。我们将使用 OAuth2 服务器作为身份验证器,以便我们也可以使用它来授予后端资源服务器的令牌。Spring Cloud 将自动将访问令牌中继到我们的后端,并使我们能够进一步简化 UI 和资源服务器的实现。
提醒:如果您正在使用示例应用程序完成本文,请务必清除浏览器缓存中的 cookie 和 HTTP Basic 凭据。在 Chrome 中,针对单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。
我们的第一步是创建一个新的服务器来处理身份验证和令牌管理。按照 第一部分 中的步骤,我们可以从 Spring Boot Initializr 开始。例如,在类 Unix 系统上使用 curl:
$ curl https://start.spring.io/starter.tgz -d style=web \
-d style=security -d name=authserver | tar -xzvf -
然后,您可以将该项目(默认情况下是一个正常的 Maven Java 项目)导入到您喜欢的 IDE 中,或者直接在命令行中使用文件和“mvn”进行处理。
我们需要添加 Spring OAuth 依赖项,所以在我们的 POM 中,我们添加:
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.0.5.RELEASE</version>
</dependency>
授权服务器的实现非常简单。最小版本如下所示:
@SpringBootApplication
public class AuthserverApplication extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(AuthserverApplication.class, args);
}
@Configuration
@EnableAuthorizationServer
protected static class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("acme")
.secret("acmesecret")
.authorizedGrantTypes("authorization_code", "refresh_token",
"password").scopes("openid");
}
}
我们只需要做两件事(在添加 @EnableAuthorizationServer 之后):
注册一个客户端“acme”,并为其设置密钥和一些授权的 grant 类型,包括“authorization_code”。
注入 Spring Boot 自动配置中的默认 AuthenticationManager,并将其连接到 OAuth2 端点。
现在,让我们在端口 9999 上运行它,并设置一个可预测的密码用于测试:
server.port=9999
security.user.password=password
server.contextPath=/uaa
我们还设置了上下文路径,这样它就不会使用默认值(“/”),否则您可能会将 localhost 上其他服务器的 cookie 发送到错误的服务器。所以,运行服务器,我们可以确保它正常工作:
$ mvn spring-boot:run
或者在您的 IDE 中启动 main() 方法。
我们的服务器使用 Spring Boot 的默认安全设置,因此,就像 第一部分 中的服务器一样,它将受到 HTTP Basic 身份验证的保护。要启动一个 授权码令牌授权,您需要访问授权端点,例如在 https://:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com。在您成功身份验证后,您将收到一个重定向到 example.com 的请求,其中附带一个授权码,例如 http://example.com/?code=jYWioI。
注意:对于本示例应用程序,我们创建了一个没有注册重定向 URI 的客户端“acme”,这使得我们可以重定向到 example.com。在生产应用程序中,您应始终注册重定向 URI(并使用 HTTPS)。
可以使用“acme”客户端凭据在令牌端点上将代码兑换为访问令牌:
$ curl acme:acmesecret@localhost:9999/uaa/oauth/token \
-d grant_type=authorization_code -d client_id=acme \
-d redirect_uri=http://example.com -d code=jYWioI
{"access_token":"2219199c-966e-4466-8b7e-12bb9038c9bb","token_type":"bearer","refresh_token":"d193caf4-5643-4988-9a4a-1c03c9d657aa","expires_in":43199,"scope":"openid"}
访问令牌是一个 UUID(“2219199c...”),由服务器中的内存令牌存储提供支持。我们还获得了一个刷新令牌,当当前访问令牌过期时,我们可以使用它来获取新的访问令牌。
注意:由于我们允许“acme”客户端使用“password”授权类型,因此我们也可以使用 curl 和用户凭据直接从令牌端点获取令牌,而不是使用授权码。这不适用于基于浏览器的客户端,但对于测试很有用。
如果您点击了上面的链接,您将看到 Spring OAuth 提供的白标 UI。起初,我们将使用它,稍后我们可以回到这里,像我们在 第二部分 中为独立服务器所做的那样来加强它。
如果我们从 第四部分 继续,我们的资源服务器正在使用 Spring Session 进行身份验证,因此我们可以将其移除并替换为 Spring OAuth。我们还需要移除 Spring Session 和 Redis 依赖项,因此将此替换为:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
<version>1.0.0.RC1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
替换为:
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
然后从 主应用程序类 中移除 session Filter,并用方便的 @EnableOAuth2Resource 注解(来自 Spring Cloud Security)替换它:
@SpringBootApplication
@RestController
@EnableOAuth2Resource
class ResourceApplication {
@RequestMapping('/')
def home() {
[id: UUID.randomUUID().toString(), content: 'Hello World']
}
static void main(String[] args) {
SpringApplication.run ResourceApplication, args
}
}
这足以让我们拥有一个受保护的资源。运行应用程序,并使用命令行客户端访问主页:
$ curl -v localhost:9000
> GET / HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:9000
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
...
< WWW-Authenticate: Bearer realm="null", error="unauthorized", error_description="An Authentication object was not found in the SecurityContext"
< Content-Type: application/json;charset=UTF-8
{"error":"unauthorized","error_description":"An Authentication object was not found in the SecurityContext"}
您将看到一个 401 响应,其中带有“WWW-Authenticate”标头,表明它需要一个 bearer 令牌。我们将添加少量外部配置(在“application.properties”中)以允许资源服务器解码收到的令牌并进行身份验证:
...
spring.oauth2.resource.userInfoUri: https://:9999/uaa/user
这告诉服务器它可以使用该令牌访问“/user”端点并利用该端点派生身份验证信息(这有点像 Facebook API 中的 “/me”端点)。实际上,它为资源服务器提供了一种解码令牌的方式,如 Spring OAuth2 中的 ResourceServerTokenServices 接口所示。
注意:
userInfoUri绝不是将资源服务器与令牌解码方式连接的唯一方法。事实上,它是一种最低通用分母(并且不是规范的一部分),但通常可以从 OAuth2 提供商(如 Facebook、Cloud Foundry、Github)处获得,并且还有其他选择。例如,您可以在令牌本身中编码用户身份验证(例如,使用 JWT),或使用共享的后端存储。CloudFoundry 中还有一个“/token_info”端点,它提供比用户 info 端点更详细的信息,但需要更彻底的身份验证。不同的选项(自然)提供不同的好处和权衡,但对这些选项的全面讨论超出了本文的范围。
在授权服务器上,我们可以轻松地添加该端点:
@SpringBootApplication
@RestController
@EnableResourceServer
public class AuthserverApplication {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
...
}
我们添加了一个 @RequestMapping,与 第二部分 中的 UI 服务器相同,并且还添加了 Spring OAuth 中的 @EnableResourceServer 注解,默认情况下,它保护授权服务器中的所有内容,除了“/oauth/*”端点。
有了该端点后,我们就可以测试它以及问候资源,因为它们现在都接受由授权服务器创建的 bearer 令牌:
$ TOKEN=2219199c-966e-4466-8b7e-12bb9038c9bb
$ curl -H "Authorization: Bearer $TOKEN" localhost:9000
{"id":"03af8be3-2fc3-4d75-acf7-c484d9cf32b1","content":"Hello World"}
$ curl -H "Authorization: Bearer $TOKEN" localhost:9999/uaa/user
{"details":...,"principal":{"username":"user",...},"name":"user"}
(替换您从自己的授权服务器获取的访问令牌的值以自行完成此操作)。
我们需要完成的应用程序的最后一部分是 UI 服务器,提取身份验证部分并委托给授权服务器。因此,与 资源服务器 一样,我们首先需要移除 Spring Session 和 Redis 依赖项,并用 Spring OAuth2 替换它们。
完成后,我们可以移除 session filter 和“/user”端点,并设置应用程序以重定向到授权服务器(使用 @EnableOAuth2Sso 注解):
@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication {
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
回想一下 第四部分,UI 服务器通过 @EnableZuulProxy 的作用,充当 API 网关,我们可以在 YAML 中声明路由映射。因此,“/user”端点可以代理到授权服务器:
zuul:
routes:
resource:
path: /resource/**
url: https://:9000
user:
path: /user/**
url: https://:9999/uaa/user
最后,我们需要将 WebSecurityConfigurerAdapter 更改为 OAuth2SsoConfigurerAdapter,因为它现在将用于修改 @EnableOAuth2Sso 设置的 SSO filter 链中的默认值:
@Configuration
protected static class SecurityConfiguration extends OAuth2SsoConfigurerAdapter {
@Override
public void match(RequestMatchers matchers) {
matchers.anyRequest();
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/index.html", "/home.html", "/")
.permitAll().anyRequest().authenticated().and().csrf()
.csrfTokenRepository(csrfTokenRepository()).and()
.addFilterAfter(csrfHeaderFilter(), CsrfFilter.class);
}
... // the csrf*() methods are the same as the old WebSecurityConfigurerAdapter
}
主要更改(除了基类名称)是 matchers 进入自己的方法,并且不再需要 formLogin()。
对于 @EnableOAuth2Sso 注解成功联系和身份验证正确的授权服务器,还需要一些强制性的外部配置属性。因此,我们需要在 application.yml 中添加:
spring:
oauth2:
sso:
home:
secure: false
path: /,/**/*.html
client:
accessTokenUri: https://:9999/uaa/oauth/token
userAuthorizationUri: https://:9999/uaa/oauth/authorize
clientId: acme
clientSecret: acmesecret
resource:
userInfoUri: https://:9999/uaa/user
其中大部分是关于 OAuth2 客户端(“acme”)和授权服务器的位置。还有一个 userInfoUri(就像在资源服务器中一样),以便用户可以在 UI 应用程序本身中进行身份验证。“home”部分是关于允许匿名访问我们单页应用程序中的静态资源的。
我们仍然需要在前端 UI 应用程序中进行一些小的调整,以触发重定向到授权服务器。第一个是在“index.html”的导航栏中,其中“login”链接从 Angular 路由更改为:
<div ng-controller="navigation" class="container">
<ul class="nav nav-pills" role="tablist">
...
<li><a href="#/login">login</a></li>
...
</ul>
</div>
更改为纯 HTML 链接:
<div ng-controller="navigation" class="container">
<ul class="nav nav-pills" role="tablist">
...
<li><a href="login">login</a></li>
...
</ul>
</div>
此链接指向的“/login”端点由 Spring Security 处理,如果用户未经验证,则会导致重定向到授权服务器。
我们还可以移除“navigation”控制器中 login() 函数的定义,以及 Angular 配置中的“/login”路由,这可以简化实现:
angular.module('hello', [ 'ngRoute' ]).config(function($routeProvider) {
$routeProvider.when('/', {
templateUrl : 'home.html',
controller : 'home'
}).otherwise('/');
}). // ...
.controller('navigation',
function($rootScope, $scope, $http, $location, $route) {
$http.get('user').success(function(data) {
if (data.name) {
$rootScope.authenticated = true;
} else {
$rootScope.authenticated = false;
}
}).error(function() {
$rootScope.authenticated = false;
});
$scope.credentials = {};
$scope.logout = function() {
$http.post('logout', {}).success(function() {
$rootScope.authenticated = false;
$location.path("/");
}).error(function(data) {
$rootScope.authenticated = false;
});
}
});
现在将所有服务器一起运行,并在浏览器中访问 UI,地址为 https://:8080。点击“login”链接,您将被重定向到授权服务器进行身份验证(HTTP Basic 弹出窗口)和批准令牌授权(白标 HTML),然后被重定向到 UI 的主页,该页面显示从 OAuth2 资源服务器获取的问候语,并使用与我们身份验证 UI 相同的令牌。
如果您使用一些开发者工具(通常按 F12 可打开),您可以在浏览器中看到浏览器和后端之间的交互(在 Chrome 中默认可用,在 Firefox 中需要插件)。摘要如下
| 动词 | 路径 | 状态 | 响应 |
|---|---|---|---|
| GET | / | 200 | index.html |
| GET | /css/angular-bootstrap.css | 200 | Twitter Bootstrap CSS |
| GET | /js/angular-bootstrap.js | 200 | Bootstrap 和 Angular JS |
| GET | /js/hello.js | 200 | 应用程序逻辑 |
| GET | /home.html | 200 | 主页的 HTML 部分 |
| GET | /用户 | 302 | 重定向到登录页面 |
| GET | /login | 302 | 重定向到认证服务器 |
| GET | (uaa)/oauth/authorize | 401 | (已忽略) |
| GET | /resource | 302 | 重定向到登录页面 |
| GET | /login | 302 | 重定向到认证服务器 |
| GET | (uaa)/oauth/authorize | 401 | (已忽略) |
| GET | /login | 302 | 重定向到认证服务器 |
| GET | (uaa)/oauth/authorize | 200 | HTTP Basic 身份验证发生在此 |
| POST | (uaa)/oauth/authorize | 302 | 用户批准授权,重定向到 /login |
| GET | /login | 302 | 重定向到主页 |
| GET | /用户 | 200 | (代理的) JSON 身份验证用户 |
| GET | /home.html | 200 | 主页的 HTML 部分 |
| GET | /resource | 200 | (代理)JSON 问候语 |
以 (uaa) 开头的请求是发送到授权服务器的。标记为“ignored”的响应是 Angular 在 XHR 调用中收到的响应,由于我们没有处理这些数据,因此它们被丢弃了。在“/user”资源的情况下,我们会查找一个经过身份验证的用户,但由于它在第一次调用时不存在,因此该响应被丢弃。
在 UI 的“/trace”端点中(向下滚动到最后),您将看到代理到“/user”和“/resource”的后端请求,remote:true 和 bearer 令牌而不是 cookie(正如在 第四部分 中那样)被用于身份验证。Spring Cloud Security 为我们处理了这一点:通过识别我们具有 @EnableOAuth2Sso 和 @EnableZuulProxy,它已经推断出(默认情况下)我们希望将令牌中继到代理的后端。
注意:与之前的文章一样,请尝试为“/trace”使用不同的浏览器,以免发生身份验证交叉(例如,如果您使用 Chrome 测试 UI,请使用 Firefox)。
如果您点击“logout”链接,您会发现主页发生了变化(问候语不再显示),因此用户不再通过 UI 服务器进行身份验证。然而,再次点击“login”时,您实际上不需要再次经历授权服务器的身份验证和批准周期(因为您还没有从那里注销)。关于这是否是一种理想的用户体验,人们可能会有不同的意见,而且这是一个出了名棘手的问题(单点注销:Science Direct 文章 和 Shibboleth 文档)。理想的用户体验可能在技术上不可行,而且您有时也必须怀疑用户是否真的想要他们所说的。声称“我想要‘注销’将我登出”听起来很简单,但显而易见的回应是,“登出什么?您是想从该 SSO 服务器控制的所有系统中登出,还是只想从您点击了‘注销’链接的那个系统登出?”我们没有篇幅在此更广泛地讨论这个主题,但它确实值得更多关注。如果您有兴趣,可以在 Open ID Connect 规范中找到一些关于原则的讨论和一些(相当不吸引人的)实现想法。
这几乎是我们对 Spring Security 和 Angular JS 栈的简短游览的结尾。我们现在有了一个很好的架构,在三个独立的组件中具有清晰的职责:UI/API 网关、资源服务器和授权服务器/令牌授予器。所有层中的非业务代码量现在都已最小化,并且很容易看出在哪里可以扩展和改进业务逻辑的实现。接下来的步骤将是整理我们授权服务器中的 UI,并可能添加一些测试,包括对 JavaScript 客户端的测试。另一个有趣的 MAF 是提取所有样板代码并将其放入一个库中(例如,“spring-security-angular”),该库包含 Spring Security 和 Spring Session 的自动配置,以及 Angular 部分中导航控制器的一些 webjar 资源。读完本系列的文章后,任何希望了解 Angular JS 或 Spring Security 内部机制的人可能会感到失望,但如果您想了解它们如何协同工作以及一点配置就能带来多大的好处,那么希望您会有一个愉快的体验。 Spring Cloud 是新推出的,这些示例在编写时需要快照版本,但现在已经有候选版本可用,并且即将发布 GA 版本,所以请尝试一下,并通过 Github 或 gitter.im 发送一些反馈。
本系列的 下一篇文章 讨论的是访问决策(身份验证之外),并在同一代理后面使用多个 UI 应用程序。
您将在 Github 源代码 中找到此应用程序的另一个版本,它拥有一个漂亮的登录页面和用户批准页面,实现方式与我们在 第二部分 中实现的登录页面类似。它还使用 JWT 对令牌进行编码,因此资源服务器无需使用“/user”端点,而是可以从令牌本身提取足够的信息来进行简单的身份验证。浏览器客户端仍然使用它,通过 UI 服务器进行代理,以便它可以确定用户是否已经验证(与真实应用程序中资源服务器的可能调用次数相比,它不需要非常频繁地执行此操作)。