使用 OAuth2-Server-php 在 Yii 框架上搭建 OAuth2 Server
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】。