使用 OAuth2-Server-php 在 Yii 框架上搭建 OAuth2 Server

追憶丶年華發表於2018-01-16

Yii 有很多 extension 可以使用,在檢視了 Yii 官網上提供的與 OAuth 相關的擴充套件後,發現了幾個 OAuth2 的客戶端擴充套件,但是並沒有找到可以作為 OAuth2 Server 的擴充套件。因為 Yii 是組織良好的易於擴充套件的框架,所以完全可以整合其它的 PHP OAuth2 Server 實現方案。在 OAuth.net/2/ 官網上,提供了幾個 PHP 實現的 OAuth2 Server。這裡使用第一個 OAuth2-Server-php 來作為 Yii 框架的 OAuth2 Server 擴充套件,需要進行一些必要的整合操作,主要是編寫一個類來接受 client 訪問和頒發 access_token 等。

第一部分: 資料庫準備

OAuth2-Server-php 使用的資料庫結構採用 Github 上的 oauth2-server-php README.md 提供的表結構(Schema),一共有五張表:

mysql> show tables;
+--------------------------+
| Tables_in_oauth2 |
+--------------------------+
| oauth_access_token |
| oauth_authorization_code |
| oauth_client |
| oauth_refresh_token |
| user |
+--------------------------+
5 rows in set (0.00 sec)

各表的名字說明了表中存取的內容,表名可自定義,自定義位置為:OAuth2/Storage/Pdo.php 48行的 config 陣列中,因為這裡採用的是 mysql 資料庫,所以需要修改的是 Pdo,若是採用其它的儲存方案,如 Redis,則自行修改對應檔案即可。注意這裡的資料庫名稱是都是單數形式。

使用以下 sql 語句建立這5個表,並新增一個測試 client:
###############################
### oauth2 tables
###############################
drop table if exists `oauth_client`;
drop table if exists `oauth_access_token`;
drop table if exists `oauth_authorization_code`;
drop table if exists `oauth_refresh_token`;
drop table if exists `user`;

CREATE TABLE `oauth_client` (
`client_id` VARCHAR(80) NOT NULL,
`client_secret` VARCHAR(80) NOT NULL,
`redirect_uri` VARCHAR(2000) NOT NULL,
CONSTRAINT client_id_pk PRIMARY KEY (client_id)
);

CREATE TABLE `oauth_access_token` (
`access_token` VARCHAR(40) NOT NULL,
`client_id` VARCHAR(80) NOT NULL,
`user_id` VARCHAR(255),
`expires` TIMESTAMP NOT NULL,
`scope` VARCHAR(2000),
CONSTRAINT access_token_pk PRIMARY KEY (access_token)
);

CREATE TABLE `oauth_authorization_code` (
`authorization_code` VARCHAR(40) NOT NULL,
`client_id` VARCHAR(80) NOT NULL,
`user_id` VARCHAR(255),
`redirect_uri` VARCHAR(2000),
`expires` TIMESTAMP NOT NULL,
`scope` VARCHAR(2000),
CONSTRAINT auth_code_pk PRIMARY KEY (authorization_code)
);

CREATE TABLE `oauth_refresh_token` (
`refresh_token` VARCHAR(40) NOT NULL,
`client_id` VARCHAR(80) NOT NULL,
`user_id` VARCHAR(255),
`expires` TIMESTAMP NOT NULL,
`scope` VARCHAR(2000),
CONSTRAINT refresh_token_pk PRIMARY KEY (refresh_token)
);

--
CREATE TABLE `user` (
`user_id` INT(11) NOT NULL AUTO_INCREMENT,
`username` VARCHAR(255) NOT NULL,
`password` VARCHAR(2000),
`first_name` VARCHAR(255),
`last_name` VARCHAR(255),
CONSTRAINT user_pk PRIMARY KEY (user_id)
);
-- test data
INSERT INTO oauth_client (client_id, client_secret, redirect_uri)
VALUES ("testclient", "testpass", "http://fake/");
INSERT INTO user (username, password, first_name, last_name)
VALUES ('rereadyou', '8551be07bab21f3933e8177538d411e43b78dbcc', 'bo', 'zhang');


第二部分: 認證方案及實現

OAuth2 RFC 6749 規範提供了四種基本認證方案,以下針對這四種認證方案以及它們在本實現中的使用方式進行分別說面。

第一種認證方式: Authorization Code Grant (授權碼認證)

授權碼通過使用授權伺服器做為客戶端與資源所有者的中介而獲得。客戶端不是直接從資源所有者請求授權,而是引導資源所有者至授權伺服器(由在RFC2616中定義的使用者代理),授權伺服器之後引導資源所有者帶著授權碼回到客戶端。

在引導資源所有者攜帶授權碼返回客戶端前,授權伺服器會鑑定資源所有者身份並獲得其授權。由於資源所有者只與授權伺服器進行身份驗證,所以資源所有者的憑據不需要與客戶端分享。

授權碼提供了一些重要的安全益處,例如驗證客戶端身份的能力,以及向客戶端直接的訪問令牌的傳輸而非通過資源所有者的使用者代理來傳送它而潛在暴露給他人(包括資源所有者)。

授權碼許可型別用於獲得訪問令牌和重新整理令牌並未機密客戶端進行了優化。由於這是一個基於重定向的流程,客戶端必須能夠與資源所有者的使用者代理(通常是Web瀏覽器)進行互動並能夠接收來自授權伺服器的傳入請求(通過重定向)。

Authorization Code Grant 過程(又稱為 Web Server Flow) 參見如下:
+----------+
| Resource |
| Owner |
| |
+----------+
^
|
(B)
+----|-----+ Client Identifier +---------------+
| +----(A)-- & Redirection URI ---->| |
| User- | | Authorization |
| Agent +----(B)-- User authenticates --->| Server |
| | | |
| +----(C)-- Authorization Code ---<| |
+-|----|---+ +---------------+
| | ^ v
(A) (C) | |
| | | |
^ v | |
+---------+ | |
| |>---(D)-- Authorization Code ---------' |
| Client | & Redirection URI |
| | |
| |<---(E)----- Access Token -------------------'
+---------+ (w/ Optional Refresh Token)

注:說明步驟(A)、(B)和(C)的直線因為通過使用者代理而被分為兩部分。
圖1:授權碼流程

在圖1中所示的流程包括以下步驟:

(A)客戶端通過向授權端點引導資源所有者的使用者代理開始流程。客戶端包括它的客戶端標識、請求範圍、本地狀態和重定向URI,一旦訪問被許可(或拒絕)授權伺服器將傳送使用者代理回到該URI。
(B)授權伺服器驗證資源擁有者的身份(通過使用者代理),並確定資源所有者是否授予或拒絕客戶端的訪問請求。
(C)假設資源所有者許可訪問,授權伺服器使用之前(在請求時或客戶端註冊時)提供的重定向URI重定向使用者代理回到客戶端。重定向URI包括授權碼和之前客戶端提供的任何本地狀態。
(D)客戶端通過包含上一步中收到的授權碼從授權伺服器的令牌端點請求訪問令牌。當發起請求時,客戶端與授權伺服器進行身份驗證。客戶端包含用於獲得授權碼的重定向URI來用於驗證。
(E)授權伺服器對客戶端進行身份驗證,驗證授權程式碼,並確保接收的重定向URI與在步驟(C)中用於重定向客戶端的URI相匹配。如果通過,授權伺服器響應返回訪問令牌與可選的重新整理令牌。

過程實現:
1. client app 使用 app id 獲取 authorization code:

www.yii.com/oauth2/index.php?r=oauth2/authroize&response_type=code&client_id=testclient&state=xyz

返回:$authcode = authorization code.
Tips: authorization code will expired in 30s,可以修改 OAuth2/ResponseType/AuthorizationCode.php 中的 AuthorizationCode class 的構造方法配置引數來自定義 authorization_code 有效時間。
client_id 是之前註冊在本 Server 上的應用名稱,這屬於客戶端管理範疇。
這一步需要進行使用者(資源所有者)登入 OAuth2 Server 來完成授權操作。使用者登入屬使用者管理範疇,不屬 OAuth2 Server 中應編寫的功能。
使用者登入後可選擇自己可以向 client app 開放的操作(授權)。
這一步繫結過程中,從安全形度來考慮應強制使用者重新輸入使用者名稱密碼確認繫結,不要直接讀取當前使用者session進行繫結。

2. 獲取 access_token:
client app 使用 authorization code 換取 access_token

curl -u testclient:testpass www.yii.com/oauth2/index.php?r=oauth2/token -d "grant_type=authorization_code&code=$authcode

返回:
成功:
{"access_token":"aea4a1059d3194a3dd5e4117bedd6e07ccc3f402",
"expires_in":3600,
"token_type":"bearer",
"scope":null,
"refresh_token":"269a623f54171e8598b1852eefcf115f4882b820"
}

失敗:
{"error":"invalid_grant",
"error_description":"Authorization code doesn't exist or is invalid for the client"
}

Tip: 本步驟需要使用客戶端的 client_id 和 client_secret 以及上一步獲取的 authorization_code 換取 access_code.
access_tokne 有效期為 3600s, refresh_token 有效期為 1209600s,可以在 OAuth2/ResponseType/AccessToken.php 中的 AccessToken class 中的建構函式配置中進行修改。

第二種認證方式: Implicit (隱式認證)

隱式授權型別被用於獲取訪問令牌(它不支援發行重新整理令牌),並對知道操作具體重定向URI的公共客戶端進行優化。這些客戶端通常在瀏覽器中使用諸如JavaScript的指令碼語言實現。

由於這是一個基於重定向的流程,客戶端必須能夠與資源所有者的使用者代理(通常是Web瀏覽器)進行互動並能夠接收來自授權伺服器的傳入請求(通過重定向)。

不同於客戶端分別請求授權和訪問令牌的授權碼許可型別,客戶端收到訪問令牌作為授權請求的結果。

隱式許可型別不包含客戶端身份驗證而依賴於資源所有者在場和重定向URI的註冊。因為訪問令牌被編碼到重定向URI中,它可能會暴露給資源所有者和其他駐留在相同裝置上的應用。

採用Implicit Grant方式獲取Access Token的授權驗證流程又被稱為User-Agent Flow,適用於所有無Server端配合的應用(由於應用往往位於一個User Agent裡,如瀏覽器裡面,因此這類應用在某些平臺下又被稱為Client-Side Application),如手機/桌面客戶端程式、瀏覽器外掛等,以及基於JavaScript等指令碼客戶端指令碼語言實現的應用,他們的一個共同特點是,應用無法妥善保管其應用金鑰(App Secret Key),如果採取Authorization Code模式,則會存在洩漏其應用金鑰的可能性。其流程示意圖如下:

+----------+
| Resource |
| Owner |
| |
+----------+
^
|
(B)
+----|-----+ Client Identifier +---------------+
| +----(A)-- & Redirection URI --->| |
| User- | | Authorization |
| Agent |----(B)-- User authenticates -->| Server |
| | | |
| |<---(C)--- Redirection URI ----<| |
| | with Access Token +---------------+
| | in Fragment
| | +---------------+
| |----(D)--- Redirection URI ---->| Web-Hosted |
| | without Fragment | Client |
| | | Resource |
| (F) |<---(E)------- Script ---------<| |
| | +---------------+
+-|--------+
| |
(A) (G) Access Token
| |
^ v
+---------+
| |
| Client |
| |
+---------+

注:說明步驟(A)和(B)的直線因為通過使用者代理而被分為兩部分。

圖2:隱式許可流程

圖2中的所示流程包含以下步驟:

(A)客戶端通過向授權端點引導資源所有者的使用者代理開始流程。客戶端包括它的客戶端標識、請求範圍、本地狀態和重定向URI,一旦訪問被許可(或拒絕)授權伺服器將傳送使用者代理回到該URI。
(B)授權伺服器驗證資源擁有者的身份(通過使用者代理),並確定資源所有者是否授予或拒絕客戶端的訪問請求。
(C)假設資源所有者許可訪問,授權伺服器使用之前(在請求時或客戶端註冊時)提供的重定向URI重定向使用者代理回到客戶端。重定向URI在URI片段中包含訪問令牌。
(D)使用者代理順著重定向指示向Web託管的客戶端資源發起請求(按RFC2616該請求不包含片段)。使用者代理在本地保留片段資訊。
(E)Web託管的客戶端資源返回一個網頁(通常是帶有嵌入式指令碼的HTML文件),該網頁能夠訪問包含使用者代理保留的片段的完整重定向URI並提取包含在片段中的訪問令牌(和其他引數)。
(F)使用者代理在本地執行Web託管的客戶端資源提供的提取訪問令牌的指令碼。
(G)使用者代理傳送訪問令牌給客戶端。

Tips: 1. 一般不需提供 client_secret,僅需 client_id,單使用者同樣需要認證。
2. Implicit Grant Type 不支援 refresh_token(或可自行實現)機制。
3. THE FIRST TIME THE USER AUTHENTICATES YOUR APP USING IMPLICIT GRANT FLOW STORE THE ACCESS TOKEN! Once you have the access token do not try to re-authenticate. Your access token that you stored should continue to work!
一旦獲取 access_token (存在於 redirect_uri 的 fragment 中, 即 uri 中的 # 部分),Client 需要自己儲存 access_token。
4. 比較適用於 Client-Side Application,如手機/桌面客戶端程式、瀏覽器外掛等

oauth2-server-php 對本授權方式的實現如下:

1. 這種授權方式包含於 Authorization Code Grant (是對 Authorization Code Grant 方式的簡化)。

初始化 OAuth2Controller 時, 只需向 OAuth2 Server 新增 AuthorizationCode 型別的授權即可,如下:
$server->addGrantType(new OAuth2\GrantType\AuthorizationCode($storage));

Authorization Code 預設不支援 Implicit Grant, 需要將 Server.php 第 104 行的 'allow_implicit' 修改為 'true' 以開啟 Implicit 授權。


2. 獲取 access_token

http://www.yii.com/oauth2/index.php?r=oauth2/authorize&response_type=token&client_id=testclient&state=xyz&redirect_uri=www.baidu.com

引數: response_type=token (必須, 固定值)
client_id (必須)
redirect_uri 可選
scope 可選
state 推薦
注意:response_type = token 而不是 code, 因為隱式授權不用獲取 authorization code。
返回:
成功:
需要使用者先點選授權按鈕。
SUCCESS! Authorization Code: www.baidu.com?#access_token=9f0c38b475e51ccd3

出錯: redirect_uri 與註冊的 client redirect_uri 不匹配。
{"error":"redirect_uri_mismatch","error_description":"The redirect URI provided is missing or does not match","error_uri":"http:\/\/tools.ietf.org\/html\/rfc6749#section-3.1.2"}

access_token 存在於 redirect_uri 中的片段(fragment)中, 即‘#’符號之後,client 需要自己提取片段中的 access_token 並注意儲存。開發人員應注意,一些使用者代理不支援在HTTP“Location”HTTP響應標頭欄位中包含片段組成部分。這些客戶端需要使用除了3xx重定向響應以外的其他方法來重定向客戶端——-例如,返回一個HTML頁面,其中包含一個具有連結到重定向URI的動作的“繼續”按鈕。

第三種認證方式: Resource Owner Password Credentials (資源所有者密碼憑證許可)

資源所有者密碼憑據許可型別適合於資源所有者與客戶端具有信任關係的情況,如裝置作業系統或高階特權應用。當啟用這種許可型別時授權伺服器應該特別關照且只有當其他流程都不可用時才可以。

這種許可型別適合於能夠獲得資源所有者憑據(使用者名稱和密碼,通常使用互動的形式)的客戶端。通過轉換已儲存的憑據至訪問令牌,它也用於遷移現存的使用如HTTP基本或摘要身份驗證的直接身份驗證方案的客戶端至OAuth。

+----------+
| Resource |
| Owner |
| |
+----------+
v
| Resource Owner
(A) Password Credentials
|
v
+---------+ +---------------+
| |>--(B)---- Resource Owner ------->| |
| | Password Credentials | Authorization |
| Client | | Server |
| |<--(C)---- Access Token ---------<| |
| | (w/ Optional Refresh Token) | |
+---------+ +---------------+

圖3:資源所有者密碼憑據流程

圖3中的所示流程包含以下步驟:

(A)資源所有者提供給客戶端它的使用者名稱和密碼。
(B)通過包含從資源所有者處接收到的憑據,客戶端從授權伺服器的令牌端點請求訪問令牌。當發起請求時,客戶端與授權伺服器進行身份驗證。

(C)授權伺服器對客戶端進行身份驗證,驗證資源所有者的憑證,如果有效,頒發訪問令牌。


Tips: 客戶端一旦獲得訪問令牌必須丟棄憑據。

oauth2-server-php 對 Resource Owner Password Credentials 的實現如下:

1. 首先在 Oauth2Controller 的建構函式中新增對於 Resource Owner Password Credentials 授權方式的支援,加入以下程式碼:

$server->addGrantType(new OAuth2\GrantType\UserCredentials($storage));

2. 獲取 access_token :

curl -u testclient:testpass www.yii.com/oauth2/index.php?r=oauth2/token -d 'grant_type=password&username=rereadyou&password=rereadyou'

返回:
{"access_token":"66decd1b10891db5f8f63efe7cc352ce326895c6",
"expires_in":3600,
"token_type":"bearer",
"scope":null,
"refresh_token":"b5fa0c24e786e37e7ce7d6e2f911805dc65a0d7c"}

Tips: Github 上 oauth2-server-php 提供的 sql schema user 表裡面沒有 user_id 欄位[12],需要自行新增該欄位(主鍵, auto_increment)。
user 表設計使用 sha1 摘要方式,沒有新增 salt。

在 Pdo.php 中有:
// plaintext passwords are bad! Override this for your application
protected function checkPassword($user, $password)
{
return $user['password'] == sha1($password);
}
對於使用者認證需要改寫這個函式。

 

第四種認證方式: Client Credentials Grant (客戶端憑證許可)

當客戶端請求訪問它所控制的,或者事先與授權伺服器協商(所採用的方法超出了本規範的範圍)的其他資源所有者的受保護資源,客戶端可以只使用它的客戶端憑據(或者其他受支援的身份驗證方法)請求訪問令牌。

客戶端憑據許可型別必須只能由機密客戶端使用。

+---------+ +---------------+
| | | |
| |>--(A)- Client Authentication --->| Authorization |
| Client | | Server |
| |<--(B)---- Access Token ---------<| |
| | | |
+---------+ +---------------+

圖4:客戶端憑證流程

圖4中的所示流程包含以下步驟:

(A)客戶端與授權伺服器進行身份驗證並向令牌端點請求訪問令牌。

(B)授權伺服器對客戶端進行身份驗證,如果有效,頒發訪問令牌。

Tips: 這是最簡單的認證方式。

由於客戶端身份驗證被用作授權許可,所以不需要其他授權請求。

實現如下:
1. 在 Oauth2Controller 中新增對 client credentials 認證方式的支援:

$server->addGrantType(new OAuth2\GrantType\ClientCredentials($storage));

2. 獲取 access_token:

curl -u testclient:testpass www.yii.com/oauth2/index.php?r=oauth2/token -d 'grant_type=client_credentials'

提交引數: grant_type REQUIRED. Value MUST be set to "client_credentials".
scope OPTIONAL.
返回:
{"access_token": "f3c30def0d28c633e34921b65388eb0bbd9d5ff9",
"expires_in":3600,
"token_type":"bearer",
"scope":null}

Tips: Client 直接使用自己的 client id 和 client_secret 獲取 access_token;
RFC6749規範指明[10] clinet crendentials 客戶端認證取得 access_token 時不包括 refresh_token。
不過,oauth2-server-php 提供了控制開關,在 OAuth2/GrantTypes/ClientCredentials.php 第 33 行[11],
預設 $includeRefreshToken = false; 設定為 true, 則可在頒發 access_token 同時頒發 refresh_token。

第三部分: access_token 型別說明
客戶端在運算元據資源時(通過 api)需要向 server 出示 access_token,關於如何出示 access_token 和 access_token 型別由以下部分說明。
IETF rfc 6749 中說明的 access_token 型別有兩種:Bearer type 和 MAC type。
由於 OAuth2-Server-php 對於 MAC 型別的 access_token 尚在開發之中,以下僅對最常使用的 Bearer 型別 access_token 進行說明。

有三種在資源請求中傳送 bearer access_token 資源給資源伺服器的方法[13]。客戶端不能在每次請求中使用超過一個方法傳輸令牌。
a. 當在由HTTP/1.1[RFC2617]定義的“Authorization”請求頭部欄位中傳送訪問令牌時,客戶端使用“Bearer”身份驗證方案來傳輸訪問令牌。

GET /resource HTTP/1.1
Host: server.example.com
Authorization: Bearer mF_9.B5f-4.1JqM

客戶端應該使用帶有“Bearer”HTTP授權方案的“Authorization”請求頭部欄位發起帶有不記名令牌的身份驗證請求。資源伺服器必須支援此方法。

b. 表單編碼的主體引數
當在HTTP請求實體主體中傳送訪問令牌時,客戶端採用“access_token”引數向請求主體中新增訪問令牌。客戶端不能使用此方法,除非符合下列所有條件:
HTTP請求的實體頭部含有設定為“application/x-www-form-urlencoded”的“Content-Type”頭部欄位。
實體主體遵循HTML4.01[W3C.REC-html401-19991224]定義的“application/x-www-form-urlencoded”內容型別的編碼要求。
HTTP請求實體主體是單一的部分。
在實體主體中編碼的內容必須完全由ASCII[USASCII]字元組成。
HTTP請求方法是請求主體定義為其定義的語法。尤其是,這意味著“GET”方法不能被使用。

客戶端採用傳輸層安全發起如下的HTTP請求:

POST /resource HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
access_token=mF_9.B5f-4.1JqM

c. 當在HTTP請求URI中傳送訪問令牌時,客戶端採用“access_token”引數,向“統一資源標示符(URI):通用語法”RFC3986定義的請求URI查詢部分新增訪問令牌。

例如,客戶端採用傳輸層安全發起如下的HTTP請求:

GET /resource?access_token=mF_9.B5f-4.1JqM HTTP/1.1
Host: server.example.com

它不應該被使用,除非不能在“Authorization”請求頭部欄位或HTTP請求實體主體中傳輸訪問令牌。

以上在 rfc6750 規範中提出的三種 access_token 的使用方式。推薦使用第一種方案。Bearer token 的使用需要藉助 TLS 來確保 access_token 傳輸時的安全性。


第四部分: 使用 Bearer access_token 的呼叫 api

1. 使用 refresh_token 換取 access_token:
curl -u testclient:testpass www.yii.com/oauth2/index.php?r=oauth2/token -d "grant_type=refresh_token&refresh_token=1ce1a52dff3b5ab836ae25714c714cb86bf31b6f"

返回:
{"access_token":"50540a7ead3a27cdb458b6cdc38df25f64da18f1",
"expires_in":3600,
"token_type":"bearer",
"scope":null}
這裡沒有新的 refresh_token,需要進行配置以重新獲取 refresh_token,可修改 OAuth2/GrantType/RefreshToken.php 中的 RefreshToken class __construct 方法中的 'always_issue_new_refresh_token' => true 來開啟頒發新的 refresh_token。
Tips: IETF rfc2649 中對於 refresh_token section 的部分說明,
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA

需要提供客戶端的 client_id 和 client_secret, grant_type 值必須是 refresh_token。
access_token 有效期內不能使用 refresh_token 換取新的 access_token。

2. 使用 access_token:
a. client app 使用 access_token 獲取 resource 資訊。
oauth2-server 驗證 access_token:
curl www.yii.com/oauth2/index.php?r=oauth2/verifytoken -d 'access_token=aea4a1059d3194a3dd5e4117bedd6e07ccc3f402'

返回:
{"result":"success",
"message":"your access token is valid."
}

這個部分只是為了驗證 access token 的有效性,client app 並不應該直接呼叫該方法,而是在請求資源時有server自行呼叫,根據判斷結果進行不同處理。
可以在 Oauth2 extension 的 Server.php 中來修改 access_token 的有效期。

3. scope
scope 需要服務端確定具體的可行操作。
scope 用來確定 client 所能進行的操作許可權。專案中操作許可權由 srbac 進行控制, Oauth2 中暫不做處理。

4. state
state 為 client app 在第一步驟中獲取 authorization code 時向 OAuth2 Server 傳遞並由 OAuth2 Server 返回的隨機雜湊引數。state 引數主要用來防止跨站點請求偽造(Cross Site Request Forgery, CSRF),相關討論可參見本文最後的參考【7】和【8】。

相關文章