领先一步
VMware 提供培训和认证,助力你加速进步。
了解更多注意:本博客的源代码和测试仍在不断演进,但此处未维护文本更改。请参阅教程版本以获取最新内容。
在本文中,我们将继续我们的讨论,关于如何在一个“单页应用”中使用Spring Security与Angular JS。在这里,我们首先将应用程序中用作动态内容的“greeting”资源分解到一个单独的服务器中,首先作为一个不受保护的资源,然后使用一个不透明的令牌进行保护。这是本系列文章的第三篇,你可以通过阅读第一篇文章来了解应用程序的基本构建块或从头构建,或者你可以直接查看 Github 中的源代码,它分为两部分:一部分是资源不受保护的,另一部分是受令牌保护的。
提醒:如果你正在使用示例应用程序阅读本文,请务必清除浏览器中关于 cookie 和 HTTP Basic 凭据的缓存。在 Chrome 中,针对单个服务器执行此操作的最佳方法是打开新的隐身窗口。
在客户端,将资源移动到不同的后端并没有太多工作要做。这是上一篇文章中的“home”控制器
angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
$http.get('/resource/').success(function(data) {
$scope.greeting = data;
})
})
...
我们要做的就是修改 URL。例如,如果我们在 localhost 上运行新资源,它看起来像这样
angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
$http.get('http://localhost:9000/').success(function(data) {
$scope.greeting = data;
})
})
...
要修改UI 服务器很简单:我们只需要移除 greeting 资源的 @RequestMapping
(原来是 "/resource")。然后我们需要创建一个新的资源服务器,我们可以像在第一篇文章中那样使用Spring Boot Initializr来创建。例如,在类 UN*X 系统上使用 curl
$ mkdir resource && cd resource
$ curl https://start.spring.io/starter.tgz -d style=web \
-d name=resource -d language=groovy | tar -xzvf -
然后,你可以将该项目(默认是正常的 Maven Java 项目)导入到你喜欢的 IDE 中,或者直接在命令行上使用文件和 "mvn"。我们使用 Groovy 是因为我们可以,但如果你更喜欢 Java,请随意使用。反正代码不会很多。
只需向主应用程序类添加一个 @RequestMapping
,复制旧的 UI 中的实现
@SpringBootApplication
@RestController
class ResourceApplication {
@RequestMapping('/')
def home() {
[id: UUID.randomUUID().toString(), content: 'Hello World']
}
static void main(String[] args) {
SpringApplication.run ResourceApplication, args
}
}
完成这些后,你的应用程序就可以在浏览器中加载了。在命令行上你可以这样做
$ mvn spring-boot:run --server.port=9000
然后前往浏览器地址http://localhost:9000,你应该会看到包含 greeting 的 JSON。你可以在 application.properties
(在 "src/main/resources" 中)中固化端口更改
server.port: 9000
如果你尝试在浏览器中从 UI(端口 8080)加载该资源,你会发现它不起作用,因为浏览器不允许 XHR 请求。
浏览器会尝试与我们的资源服务器协商,以了解是否允许其根据跨域资源共享协议访问。这不是 Angular JS 的责任,因此就像 cookie 契约一样,它将与浏览器中的所有 JavaScript 以这种方式工作。这两个服务器并未声明它们具有共同的源,因此浏览器拒绝发送请求,UI 也就损坏了。
为了解决这个问题,我们需要支持 CORS 协议,这涉及到“预检”OPTIONS 请求和一些列出调用方允许行为的头部。Spring 4.2 可能有一些不错的细粒度 CORS 支持,但在发布之前,我们可以通过使用 Filter
对所有请求发送相同的 CORS 响应来为本应用程序的目的做好足够的工作。我们只需在资源服务器应用程序所在的目录中创建一个类,并确保它是 @Component
(以便将其扫描到 Spring 应用程序上下文中),例如
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class CorsFilter implements Filter {
void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
HttpServletResponse response = (HttpServletResponse) res
response.setHeader("Access-Control-Allow-Origin", "*")
response.setHeader("Access-Control-Allow-Methods", "POST, PUT, GET, OPTIONS, DELETE")
response.setHeader("Access-Control-Allow-Headers", "x-requested-with")
response.setHeader("Access-Control-Max-Age", "3600")
if (request.getMethod()!='OPTIONS') {
chain.doFilter(req, res)
} else {
}
}
void init(FilterConfig filterConfig) {}
void destroy() {}
}
Filter
使用 @Order
定义,以确保它肯定在主 Spring Security 过滤器之前应用。对资源服务器进行此更改后,我们应该能够重新启动它并在 UI 中获取 greeting。
注意:随便使用
Access-Control-Allow-Origin=*
虽然快速且有效,但不安全,也绝不推荐。
太好了!我们有了一个采用新架构的可用应用程序。唯一的问题是资源服务器没有安全性。
我们还可以看看如何像在 UI 服务器中那样,将安全性作为过滤器层添加到资源服务器。这或许更传统,并且在大多数 PaaS 环境中无疑是最佳选择(因为它们通常不向应用程序提供私有网络)。第一步非常简单:只需在 Maven POM 中将 Spring Security 添加到类路径即可
<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
...
我们会被重定向到一个(白标)登录页面,因为 curl 没有发送与我们的 Angular 客户端相同的头部。修改命令以发送更相似的头部
$ curl -v -H "Accept: application/json" \
-H "X-Requested-With: XMLHttpRequest" localhost:9000
< HTTP/1.1 401 Unauthorized
...
所以我们需要做的就是教导客户端在每个请求中发送凭据。
互联网以及人们的 Spring 后端项目充斥着基于自定义令牌的认证解决方案。Spring Security 提供了一个简陋的 Filter
实现,可以帮助你开始自己的工作(例如,参见AbstractPreAuthenticatedProcessingFilter
和TokenService
)。但 Spring Security 中没有规范的实现,其中一个原因可能是存在一种更简单的方法。
回顾本系列第二部分,Spring Security 默认使用 HttpSession
存储认证数据。但它并不直接与会话交互:中间有一个抽象层(SecurityContextRepository
)你可以用它来更改存储后端。如果我们可以将资源服务器中的该存储库指向一个由我们的 UI 验证认证信息的存储,那么我们就有办法在两个服务器之间共享认证信息。UI 服务器已经有这样一个存储(HttpSession
),所以如果我们可以分发该存储并将其开放给资源服务器,我们就有了大部分解决方案。
解决方案的这一部分使用Spring Session非常容易。我们只需要一个共享的数据存储(Redis 开箱即用),以及服务器中的几行配置来设置一个 Filter
。
在 UI 应用程序中,我们需要在POM中添加一些依赖项
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
<version>1.0.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
然后在你的主应用程序中添加 @EnableRedisHttpSession
@SpringBootApplication
@RestController
@EnableRedisHttpSession
public class UiApplication {
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
...
}
@EnableRedisHttpSession
注解来自 Spring Session,Spring Boot 提供 Redis 连接(可以使用环境变量或配置文件配置 URL 和凭据)。
有了这 1 行代码,并且在 localhost 上运行着 Redis 服务器,你就可以运行 UI 应用程序,使用一些有效的用户凭据登录,会话数据(认证和 CSRF 令牌)将被存储在 Redis 中。
提示:如果你没有在本地运行 Redis 服务器,你可以很容易地使用Docker启动一个(在 Windows 或 MacOS 上需要虚拟机)。在Github 中的源代码中有一个
docker-compose.yml
文件,你可以在命令行上使用docker-compose up
非常轻松地运行它。
唯一缺少的部分是存储中数据键的传输机制。该键是 HttpSession
ID,因此如果我们在 UI 客户端中获取到该键,就可以将其作为自定义头部发送给资源服务器。因此,“home”控制器需要更改,以便在 greeting 资源的 HTTP 请求中发送该头部。例如
angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
$http.get('token').success(function(token) {
$http({
url : 'http://localhost:9000',
method : 'GET',
headers : {
'X-Auth-Token' : token.token
}
}).success(function(data) {
$scope.greeting = data;
});
})
});
(一个更优雅的解决方案可能是在需要时获取令牌,并使用 Angular 的拦截器将头部添加到对资源服务器的每个请求中。然后可以对拦截器定义进行抽象,而不是在一个地方完成所有操作,从而简化业务逻辑。)
我们没有直接访问“http://localhost:9000”,而是将该调用包装在对 UI 服务器上新的自定义端点 "/token" 的调用的成功回调中。其实现很简单
@SpringBootApplication
@RestController
@EnableRedisHttpSession
public class UiApplication {
public static void main(String[] args) {
SpringApplication.run(UiApplication.class, args);
}
...
@RequestMapping("/token")
@ResponseBody
public Map<String,String> token(HttpSession session) {
return Collections.singletonMap("token", session.getId());
}
}
所以 UI 应用程序已经准备就绪,并将在所有对后端请求的头部中包含一个名为“X-Auth-Token”的会话 ID。
资源服务器需要做一点小改动才能接受自定义头部。CORS 过滤器必须将该头部指定为允许远程客户端使用的头部,例如
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter implements Filter {
void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
...
response.setHeader("Access-Control-Allow-Headers", "x-auth-token, x-requested-with")
...
}
...
}
剩下的就是资源服务器如何获取自定义令牌并用它来认证我们的用户。这相当直接,因为我们只需要告诉 Spring Security 会话存储库在哪里,以及在传入请求中在哪里查找令牌(会话 ID)。首先我们需要添加 Spring Session 和 Redis 的依赖项,然后就可以设置 Filter
了
@SpringBootApplication
@RestController
@EnableRedisHttpSession
class ResourceApplication {
...
@Bean
HeaderHttpSessionStrategy sessionStrategy() {
new HeaderHttpSessionStrategy();
}
}
这个创建的 Filter
是 UI 服务器中过滤器的镜像,它将 Redis 设置为会话存储。唯一的区别在于它使用一个自定义的 HttpSessionStrategy
,该策略默认在头部(“X-Auth-Token”)而不是默认的 cookie(名为“JSESSIONID”)中查找令牌。我们还需要阻止浏览器在未经认证的客户端中弹出对话框——应用程序是安全的,但默认会发送带有 WWW-Authenticate: Basic
的 401 响应,因此浏览器会弹出用户名和密码对话框。实现这一点的方法不止一种,但我们已经让 Angular 发送了一个“X-Requested-With”头部,因此 Spring Security 默认会为我们处理这个问题。
资源服务器还需要进行最后一项更改,以便与我们的新认证方案配合使用。Spring Boot 默认安全是无状态的,而我们希望它将认证信息存储在会话中,因此我们需要在 application.yml
(或 application.properties
)中明确指定
security:
sessions: NEVER
这告诉 Spring Security“永不创建会话,但如果存在则使用”(由于 UI 中的认证,会话已经存在)。
重新启动资源服务器,并在新的浏览器窗口中打开 UI。
我们不得不使用自定义头部并在客户端编写代码来填充头部,这虽然不太复杂,但似乎与本系列第二部分中的建议相矛盾,即尽可能使用 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 强制它们共享会话)。
我们已经复制了本系列第二部分中的应用程序功能:一个主页,其中包含从远程后端获取的 greeting,并在导航栏中有登录和退出链接。不同之处在于,greeting 来自一个独立的资源服务器,而不是嵌入在 UI 服务器中。这给实现带来了显著的复杂性,但好消息是,我们有一个主要基于配置(几乎是 100% 声明式)的解决方案。我们甚至可以通过将所有新代码提取到库中(Spring 配置和 Angular 自定义指令)来使解决方案完全声明式。我们将把这项有趣的任务推迟到接下来的几篇文章之后。在下一篇文章中,我们将探讨另一种非常好的方法来减少当前实现中的所有复杂性:API 网关模式(客户端将所有请求发送到一个地方,并在那里处理认证)。
注意:我们在这里使用 Spring Session 在两个逻辑上并非同一应用程序的服务器之间共享会话。这是一个巧妙的技巧,传统的 JEE 分布式会话无法实现。