基於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/