构建微服务-使用OAuth 2.0保护API接口
基于Spring Cloud和Netflix OSS 构建微服务-Part 1
基于Spring Cloud和Netflix OSS构建微服务,Part 2
在本文中,我们将使用OAuth 2.0,创建一个的安全API,可供外部访问Part 1和Part 2完成的微服务。
关于OAuth 2.0的更多信息,可以访问介绍文档:Parecki - OAuth 2 Simplified 和 Jenkov - OAuth 2.0 Tutorial ,或者规范文档 IETF RFC 6749。
我们将创建一个新的微服务,命名为product-api,作为一个外部API(OAuth 术语为资源服务器-Resource Server),并通过之前介绍过的Edge Server暴露为微服务,作为Token Relay,也就是转发Client端的OAuth访问令牌到资源服务器(Resource Server)。另外添加OAuth Authorization Server和一个OAuth Client,也就是服务消费方。
继续完善Part 2的系统全貌图,添加新的OAuth组件(标识为红色框):
我们将演示Client端如何使用4种标准的授权流程,从授权服务器(Authorization Server)获取访问令牌(Access Token),接着使用访问令牌对资源服务器发起安全访问,如API。
备注:
1/ 保护外部API并不是微服务的特殊需求,因此本文适用于任何使用OAuth 2.0保护外部API的架构;
2/ 我们使用的轻量级OAuth授权系统仅适用于开发和测试环境。在实际应用中,需要替换为一个API平台,或者委托给社交网络Facebook或Twitter的登录、授权流程。
3/ 为了降低复杂度,我们特意采用了HTTP协议。在实际的应用中,OAuth通信需要使用TLS,如HTTPS保护通信数据。
4/ 在前面的文章中,我们为了强调微服务和单体应用的差异性,每一个微服务单独运行在独立的进程中。
1. 编译源码
和在Part 2中一样,我们使用Java SE 8、Git和Gradle访问源代码,并进行编译:
git clone https://github.com/callistaenterprise/blog-microservices.git
cd blog-microservices
git checkout -b B3 M3.1
./build-all.sh
如果运行在Windows平台,则执行相应的bat文件-build-all.bat。
在Part 2的基础中,新增了2个组件源码,分别为OAuth Authorization Server,项目名为auth-server;另一个为OAuth Resource Server,项目名为product-api-service。
编译输出10条log消息:
BUILD SUCCESSFUL
2. 分析源代码
查看2个新组件是如何实现的,以及Edge Server是如何更新并支持传递OAuth访问令牌的。我们也会修改API的URL,以便于使用。
2.1 Gradle 依赖
为了使用OAuth 2.0,我们将引入开源项目:spring-cloud-security和spring-security-oauth2,添加如下依赖。
auth-server项目:
compile("org.springframework.boot:spring-boot-starter-security")
compile("org.springframework.security.oauth:spring-security-oauth2:2.0.6.RELEASE")
完整代码,可查看auth-server/build.gradle文件。
product-api-service项目:
compile("org.springframework.cloud:spring-cloud-starter-security:1.0.0.RELEASE")
compile("org.springframework.security.oauth:spring-security-oauth2:2.0.6.RELEASE")
完整代码,可以查看product-api-service/build.gradle文件。
2.2 AUTH-SERVER
授权服务器(Authorization Server)的实现比较简单直接。可直接使用@EnableAuthorizationServer标注。接着使用一个配置类注册已批准的Client端应用,指定client-id、client-secret、以及允许的授予流程和范围:
@EnableAuthorizationServer
protected static class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("acme")
.secret("acmesecret")
.authorizedGrantTypes("authorization_code", "refresh_token", "implicit", "password", "client_credentials")
.scopes("webshop");
}
}
显然这一方法仅适用于开发和测试场景模拟Client端应用的注册流程,实际应用中采用OAuth Authorization Server,如LinkedIn或GitHub。
完整的代码,可以查看AuthserverApplication.java。
模拟真实环境中Identity Provider的用户注册(OAuth术语称为Resource Owner),通过在文件application.properties中,为每一个用户添加一行文本,如:
security.user.password=password
完整代码,可以查看application.properties文件。
实现代码也提供了2个简单的web用户界面,用于用户认证和用户准许,详细可以查看源代码:
2.3 PRODUCT-API-SERVICE
为了让API代码实现OAuth Resource Server的功能,我们只需要在main方法上添加@EnableOAuth2Resource标注:
@EnableOAuth2Resource
public class ProductApiServiceApplication {
完整代码,可以查看ProductApiServiceApplication.java。
API服务代码的实现和Part 2中的组合服务代码的实现很相似。为了验证OAuth工作正常,我们添加了user-id和access token的日志输出:
@RequestMapping("/{productId}")
@HystrixCommand(fallbackMethod = "defaultProductComposite")
public ResponseEntity<String> getProductComposite(
@PathVariable int productId,
@RequestHeader(value="Authorization") String authorizationHeader,
Principal currentUser) {
LOG.info("ProductApi: User={}, Auth={}, called with productId={}",
currentUser.getName(), authorizationHeader, productId);
...
备注:
1/ Spring MVC 将自动填充额外的参数,如current user和authorization header。
2/ 为了URL更简洁,我们从@RequestMapping中移除了/product。当使用Edge Server时,它会自动添加一个/product前缀,并将请求路由到正确的服务。
3/ 在实际的应用中,不建议在log中输出访问令牌(access token)。
2.4 更新Edge Server
最后,我们需要让Edge Server转发OAuth访问令牌到API服务。非常幸运的是,这是默认的行为,我们不必做任何事情。
为了让URL更简洁,我们修改了Part 2中的路由配置:
zuul:
ignoredServices: "*"
prefix: /api
routes:
productapi: /product/**
这样,可以使用URL:http://localhost:8765/api/product/123,而不必像前面使用的URL:http://localhost:8765/productapi/product/123。
我们也替换了到composite-service的路由为到api-service的路由。
完整的代码,可以查看application.yml文件。
3. 启动系统
首先启动RabbitMQ:
$ ~/Applications/rabbitmq_server-3.4.3/sbin/rabbitmq-server
如在Windows平台,需要确认RabbitMQ服务已经启动。
接着启动基础设施微服务:
$ cd support/auth-server; ./gradlew bootRun
$ cd support/discovery-server; ./gradlew bootRun
$ cd support/edge-server; ./gradlew bootRun
$ cd support/monitor-dashboard; ./gradlew bootRun
$ cd support/turbine; ./gradlew bootRun
最后,启动业务微服务:
$ cd core/product-service; ./gradlew bootRun
$ cd core/recommendation-service; ./gradlew bootRun
$ cd core/review-service; ./gradlew bootRun
$ cd composite/product-composite-service; ./gradlew bootRun
$ cd api/product-api-service; ./gradlew bootRun
如在Windows平台,可以执行相应的bat文件-start-all.bat。
一旦微服务启动完成,并注册到服务发现服务器(Service Discovery Server),会输出如下日志:
DiscoveryClient ... - registration status: 204
现在已经准备好尝试获取访问令牌,并使用它安全地调用API接口。
4. 尝试4种OAuth授权流程
OAuth 2.0规范定义了4种授予方式,获取访问令牌:
更详细信息,可查看Jenkov - OAuth 2.0 Authorization。
备注:Authorization Code 和Implicit是最常用的2种方式。如前面2种方式不使用,其他2种适用于一个特殊场景。
接下来看看每一个授予流程是如何获取访问令牌的。
4.1 授权代码许可(Authorization Code Grant)
首先,我们通过浏览器获取一个代码许可:
http://localhost:9999/uaa/oauth/authorize? response_type=code& client_id=acme& redirect_uri=http://example.com& scope=webshop& state=97536
先登录(user/password),接着重定向到类似如下URL:
http://example.com/?
code=IyJh4Y&
state=97536
备注:在请求中state参数设置为一个随机值,在响应中进行检查,避免cross-site request forgery攻击。
从重定向的URL中获取code参数,并保存在环境变量中:
CODE=IyJh4Y
现在作为一个安全的web服务器,使用code grant获取访问令牌:
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=$CODE -s | jq .
{
"access_token": "eba6a974-3c33-48fb-9c2e-5978217ae727",
"token_type": "bearer",
"refresh_token": "0eebc878-145d-4df5-a1bc-69a7ef5a0bc3",
"expires_in": 43105,
"scope": "webshop"
}
在环境变量中保存访问令牌,为随后访问API时使用:
TOKEN=eba6a974-3c33-48fb-9c2e-5978217ae727
再次尝试使用相同的代码获取访问令牌,应该会失败。因为code实际上是一次性密码的工作方式。
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=$CODE -s | jq .
{
"error": "invalid_grant",
"error_description": "Invalid authorization code: IyJh4Y"
}
4.2 隐式许可(Implicit Grant)
通过Implicit Grant,可以跳过前面的Code Grant。可通过浏览器直接请求访问令牌。在浏览器中使用如下URL地址:
http://localhost:9999/uaa/oauth/authorize? response_type=token& client_id=acme& redirect_uri=http://example.com& scope=webshop& state=48532
登录(user/password)并验证通过,浏览器重定向到类似如下URL:
http://example.com/#
access_token=00d182dc-9f41-41cd-b37e-59de8f882703&
token_type=bearer&
state=48532&
expires_in=42704
备注:在请求中state参数应该设置为一个随机,以便在响应中检查,避免cross-site request forgery攻击。
在环境变量中保存访问令牌,以便随后访问API时使用:
TOKEN=00d182dc-9f41-41cd-b37e-59de8f882703
4.3 资源所有者密码凭证许可(Resource Owner Password Credentials Grant)
在这一场景下,用户不必访问web浏览器,用户在Client端应用中输入凭证,通过该凭证获取访问令牌(从安全角度而言,如果你不信任Client端应用,这不是一个好的办法):
curl -s acme:acmesecret@localhost:9999/uaa/oauth/token \
-d grant_type=password \
-d client_id=acme \
-d scope=webshop \
-d username=user \
-d password=password | jq .
{
"access_token": "62ca1eb0-b2a1-4f66-bcf4-2c0171bbb593",
"token_type": "bearer",
"refresh_token": "920fd8e6-1407-41cd-87ad-e7a07bd6337a",
"expires_in": 43173,
"scope": "webshop"
}
在环境变量中保存访问令牌,以便在随后访问API时使用:
TOKEN=62ca1eb0-b2a1-4f66-bcf4-2c0171bbb593
4.4 Client端凭证许可(Client Credentials Grant)
在最后一种情况下,我们假定用户不必准许就可以访问API。在这种情况下,Client端应用进行验证自己的授权服务器,并获取访问令牌:
curl -s acme:acmesecret@localhost:9999/uaa/oauth/token \
-d grant_type=client_credentials \
-d scope=webshop | jq .
{
"access_token": "8265eee1-1309-4481-a734-24a2a4f19299",
"token_type": "bearer",
"expires_in": 43189,
"scope": "webshop"
}
在环境变量中保存访问令牌,以便在随后访问API时使用:
TOKEN=8265eee1-1309-4481-a734-24a2a4f19299
5.访问API
现在,我们已经获取到了访问令牌,可以开始访问实际的API了。
首先在没有获取到访问令牌时,尝试访问API,将会失败:
curl 'http://localhost:8765/api/product/123' -s | jq .
{
"error": "unauthorized",
"error_description": "Full authentication is required to access this resource"
}
OK,这符合我们的预期。
接着,我们尝试使用一个无效的访问令牌,仍然会失败:
curl 'http://localhost:8765/api/product/123' \
-H "Authorization: Bearer invalid-access-token" -s | jq .
{
"error": "access_denied",
"error_description": "Unable to obtain a new access token for resource 'null'. The provider manager is not configured to support it."
}
再一次如期地拒绝了访问请求。
现在,我们尝试使用许可流程返回的访问令牌,执行正确的请求:
curl 'http://localhost:8765/api/product/123' \
-H "Authorization: Bearer $TOKEN" -s | jq .
{
"productId": 123,
"name": "name",
"weight": 123,
"recommendations": [...],
"reviews": [... ]
}
OK,这次工作正常了!
可以查看一下api-service(product-api-service)输出的日志记录。
2015-04-23 18:39:59.014 INFO 79321 --- [ XNIO-2 task-20] o.s.c.s.o.r.UserInfoTokenServices : Getting user info from: http://localhost:9999/uaa/user
2015-04-23 18:39:59.030 INFO 79321 --- [ctApiService-10] s.c.m.a.p.service.ProductApiService : ProductApi: User=user, Auth=Bearer a0f91d9e-00a6-4b61-a59f-9a084936e474, called with productId=123
2015-04-23 18:39:59.381 INFO 79321 --- [ctApiService-10] s.c.m.a.p.service.ProductApiService : GetProductComposite http-status: 200
我们看到API 联系Authorization Server,获取用户信息,并在log中打印出用户名和访问令牌。
最后,我们尝试使访问令牌失效,模拟它过期了。可以通过重启auth-server(仅在内存中存储了该信息)来进行模拟,接着再次执行前面的请求:
curl 'http://localhost:8765/api/product/123' \
-H "Authorization: Bearer $TOKEN" -s | jq .
{
"error": "access_denied",
"error_description": "Unable to obtain a new access token for resource 'null'. The provider manager is not configured to support it."
}
如我们的预期一样,之前可以接受的访问令牌现在被拒绝了。
6. 总结
多谢开源项目spring-cloud-security和spring-security-auth,我们可以基于OAuth 2.0轻松设置安全API。然后,请记住我们使用的Authorization Server仅适用于开发和测试环境。
7. 下一步
在随后的文章中,将使用ELK 技术栈(Elasticsearch、LogStash和Kibana)实现集中的log管理。
英文原文链接:
构建微服务(Blog Series - Building Microservices)
http://callistaenterprise.se/blogg/teknik/2015/05/20/blog-series-building-microservices/