领先一步
VMware提供培训和认证,以快速提升您的进度。
了解更多注意:本博客的源代码和测试仍在不断发展,但此处未维护文本的更改。请参阅教程版本以获取最新内容。
在本文中,我们将继续讨论如何在“单页应用程序”中将Spring Security与AngularJS一起使用。在这里,我们首先将用作应用程序中动态内容的“问候”资源分解成一个单独的服务器,首先作为不受保护的资源,然后由不透明令牌保护。这是本系列文章的第三篇,您可以通过阅读第一篇文章来了解应用程序的基本构建块或从头开始构建它,或者您可以直接转到Github中的源代码,它分为两部分:一个资源不受保护,另一个受令牌保护。
提醒:如果您正在使用示例应用程序阅读本文,请确保清除浏览器缓存中的 Cookie 和 HTTP 基本凭据。在 Chrome 中,为单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。
在客户端方面,将资源移动到不同的后端几乎不需要做任何事情。上一篇文章中的“主页”控制器
angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
$http.get('/resource/').success(function(data) {
$scope.greeting = data;
})
})
...
我们只需要更改 URL。例如,如果我们要在本地主机上运行新资源,它可能如下所示
angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
$http.get('https://127.0.0.1:9000/').success(function(data) {
$scope.greeting = data;
})
})
...
更改UI 服务器非常简单:我们只需要删除问候资源的@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
然后转到https://127.0.0.1:9000的浏览器,您应该会看到带有问候语的 JSON。您可以在application.properties
(位于“src/main/resources”中)中烘焙端口更改
server.port: 9000
如果您尝试从 UI(端口 8080)在浏览器中加载该资源,您会发现它不起作用,因为浏览器不允许 XHR 请求。
浏览器尝试与我们的资源服务器协商,以根据跨域资源共享协议确定是否允许访问它。它不是 AngularJS 的职责,因此就像 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 中获取我们的问候语。
注意:随意使用
Access-Control-Allow-Origin=*
既快速又简便,而且有效,但这并不安全,绝不推荐。
太好了!我们有一个具有新架构的工作应用程序。唯一的问题是资源服务器没有安全性。
我们还可以了解如何像在 UI 服务器中一样将安全性作为过滤器层添加到资源服务器。这也许更传统,当然也是大多数 PaaS 环境中的最佳选择(因为它们通常不向应用程序提供专用网络)。第一步非常简单:只需将 Spring Security 添加到 Maven POM 中的类路径即可
<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: https://127.0.0.1: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和凭据)。
只需一行代码,并在本地运行Redis服务器,您就可以运行UI应用程序,使用一些有效的用户凭据登录,会话数据(身份验证和CSRF令牌)将存储在Redis中。
提示:如果您没有在本地运行Redis服务器,您可以使用Docker轻松启动一个(在Windows或MacOS上,这需要虚拟机)。在Github上的源代码中有一个
docker-compose.yml
文件,您可以使用docker-compose up
命令在命令行上轻松运行。
唯一缺少的部分是存储中密钥的传输机制。密钥是HttpSession
ID,因此,如果我们可以在UI客户端获取该密钥,则可以将其作为自定义标头发送到资源服务器。因此,“主页”控制器需要更改,以便它将标头作为对问候资源的HTTP请求的一部分发送。例如
angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
$http.get('token').success(function(token) {
$http({
url : 'https://127.0.0.1:9000',
method : 'GET',
headers : {
'X-Auth-Token' : token.token
}
}).success(function(data) {
$scope.greeting = data;
});
})
});
(更优雅的解决方案可能是根据需要获取令牌,并使用Angular 拦截器将标头添加到对资源服务器的每个请求。然后可以对拦截器定义进行抽象,而不是将其全部放在一个地方并使业务逻辑混乱。)
我们没有直接转到"https://127.0.0.1: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应用程序已准备好,并将为对后端的全部调用包含会话ID,该ID位于名为“X-Auth-Token”的标头中。
为了使资源服务器能够接受自定义标头,需要对其进行微小的更改。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”),而不是默认值(名为“JSESSIONID”的cookie)。我们还需要阻止浏览器在未经身份验证的客户端弹出对话框——应用程序是安全的,但默认情况下会发送带有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”标头。这两个限制都是为了保护您自己,防止恶意脚本在未经授权的情况下访问您的资源。
简而言之,UI和资源服务器没有共同的来源,因此它们无法共享cookie(即使我们可以使用Spring Session强制它们共享会话)。
我们在本系列的第二部分中复制了应用程序的功能:一个主页,显示从远程后端获取的问候语,导航栏中包含登录和注销链接。不同之处在于,问候语来自一个独立的资源服务器,而不是嵌入在UI服务器中。这增加了实现的复杂性,但好消息是我们有一个主要基于配置(实际上是100%声明式)的解决方案。我们甚至可以通过将所有新代码提取到库中(Spring配置和Angular自定义指令)来使解决方案达到100%声明式。我们将把这个有趣的任务推迟到接下来的几期之后。在下一篇文章中,我们将探讨另一种非常有效的方法来减少当前实现中的所有复杂性:API网关模式(客户端将其所有请求发送到一个地方,并在那里处理身份验证)。
注意:我们在这里使用Spring Session在两个逻辑上并非同一个应用程序的服务器之间共享会话。这是一个巧妙的技巧,使用“常规”JEE分布式会话是无法实现的。