构建微服务-使用OAuth 2.0保护API接口

时间:2021-06-09 12:37:26

微服务操作模型

基于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组件(标识为红色框):

构建微服务-使用OAuth 2.0保护API接口

我们将演示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。

构建微服务-使用OAuth 2.0保护API接口

编译输出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用户界面,用于用户认证和用户准许,详细可以查看源代码:

https://github.com/callistaenterprise/blog-microservices/tree/B3/microservices/support/auth-server/src/main/resources/templates

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种授予方式,获取访问令牌:

构建微服务-使用OAuth 2.0保护API接口

更详细信息,可查看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/