183 lines
7.0 KiB
PHP
183 lines
7.0 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | 企油通 · 签名 / 验签 / 请求信封
|
||
// +----------------------------------------------------------------------
|
||
// | 第二阶段 A-1 骨架。标 【TODO 待对接人确认】 处需按企油通实际规则微调。
|
||
// |
|
||
// | 三种签名场景(见《企油通接口对接文档.md》§2.1 / §2.4):
|
||
// | 1. 业务信封:外层 { appId, timestamp, sign, params(JSON字符串) }
|
||
// | 2. 7.15 H5 免登地址:ksort → http_build_query → urldecode → 大写MD5 → SHA1withRSA → base64
|
||
// | 3. 7.16 / 7.17:必须真实 RSA 验签调用(本质同 1,但企油通侧强校验)
|
||
// +----------------------------------------------------------------------
|
||
declare (strict_types=1);
|
||
|
||
namespace app\common\crypto;
|
||
|
||
class Qyt
|
||
{
|
||
/** @var string 我方私钥资源 */
|
||
protected $privateKey;
|
||
|
||
/** @var string|false 企油通平台公钥资源 */
|
||
protected $platformPublicKey;
|
||
|
||
/** @var array config('qyt') */
|
||
protected $conf;
|
||
|
||
public function __construct(array $conf = null)
|
||
{
|
||
$this->conf = $conf ?: (array) config('qyt');
|
||
$this->privateKey = $this->loadPrivateKey($this->conf['private_key'] ?? '');
|
||
$this->platformPublicKey = $this->loadPublicKey($this->conf['platform_public_key'] ?? '');
|
||
}
|
||
|
||
// ================================================================
|
||
// 1. 业务信封
|
||
// ================================================================
|
||
|
||
/**
|
||
* 构建业务请求信封
|
||
* @param array $bizParams 业务参数(会被 json_encode 成字符串放进 params)
|
||
* @return array{appId:string,timestamp:int,sign:string,params:string}
|
||
*/
|
||
public function envelope(array $bizParams): array
|
||
{
|
||
$appId = (string) ($this->conf['app_id'] ?? '');
|
||
$timestamp = time();
|
||
// ⚠️ params 必须是 JSON 字符串(转义),不是对象
|
||
$paramsJson = json_encode($bizParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
|
||
$sign = $this->bizSign([
|
||
'appId' => $appId,
|
||
'timestamp' => $timestamp,
|
||
'params' => $paramsJson,
|
||
]);
|
||
|
||
return [
|
||
'appId' => $appId,
|
||
'timestamp' => $timestamp,
|
||
'sign' => $sign,
|
||
'params' => $paramsJson,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 业务信封签名
|
||
* 【TODO 待对接人确认】业务接口的具体拼签字段与顺序,文档 §2.4 只给了 7.15 的算法,
|
||
* 业务接口写的是「密钥、公私钥与具体拼签字段请向对接人员索取」。
|
||
* 当前默认实现:对参与字段 ksort 后 http_build_query → urldecode → 大写MD5 → SHA1withRSA → base64
|
||
* (与 7.15 同族算法,落地时按实际规则替换本方法即可,调用方无需改动)
|
||
*
|
||
* @param array $fields 参与签名的字段(appId / timestamp / params)
|
||
*/
|
||
public function bizSign(array $fields): string
|
||
{
|
||
$mode = $this->conf['sign_mode'] ?? 'ksort_md5_sha1rsa';
|
||
switch ($mode) {
|
||
// 预留:'raw_concat_sha256rsa' 等其它模式
|
||
case 'ksort_md5_sha1rsa':
|
||
default:
|
||
return $this->md5Sha1RsaSign($fields);
|
||
}
|
||
}
|
||
|
||
// ================================================================
|
||
// 2. 7.15 H5 免登地址
|
||
// ================================================================
|
||
|
||
/**
|
||
* 生成司机免登加油 H5 跳转地址(7.15)
|
||
* @param string $mobile 司机手机号
|
||
* @param string $creditcode 企业营业执照号;传空则不带(不参与签名)
|
||
* @return string 完整可跳转 URL
|
||
*/
|
||
public function h5Url(string $mobile, string $creditcode = ''): string
|
||
{
|
||
$base = rtrim((string) ($this->conf['h5_base_url'] ?? ''), '/');
|
||
$appId = (string) ($this->conf['h5_app_id'] ?: $this->conf['app_id']);
|
||
$timestamp = date('YmdHis');
|
||
|
||
$params = [
|
||
'appId' => $appId,
|
||
'mobile' => $mobile,
|
||
'timestamp' => $timestamp,
|
||
];
|
||
if ($creditcode !== '') {
|
||
$params['creditcode'] = $creditcode; // 传了必须一并入签
|
||
}
|
||
|
||
$params['sign'] = $this->md5Sha1RsaSign($params);
|
||
|
||
// 文档示例形如 https://paytaskpage.worknoya.vip/#/?appId=..&mobile=..×tamp=..&sign=..#/
|
||
return $base . '/?' . http_build_query($params) . '#/';
|
||
}
|
||
|
||
// ================================================================
|
||
// 3. 验签(企油通响应 / 回调)
|
||
// ================================================================
|
||
|
||
/**
|
||
* 验签企油通返回 / 回调报文
|
||
* @param array $data 业务数据(通用响应里的 data,或回调 params)
|
||
* @param string $sign 报文里的 sign
|
||
* 【TODO 待对接人确认】验签串的构造(对 data 的 json 形态 / 是否含外层字段)
|
||
*/
|
||
public function verify($data, string $sign): bool
|
||
{
|
||
if ($sign === '' || $this->platformPublicKey === false) {
|
||
return false;
|
||
}
|
||
$payload = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$ok = openssl_verify($payload, base64_decode($sign), $this->platformPublicKey, OPENSSL_ALGO_SHA256);
|
||
return $ok === 1;
|
||
}
|
||
|
||
// ================================================================
|
||
// 内部:算法与密钥
|
||
// ================================================================
|
||
|
||
/**
|
||
* 同族签名算法:ksort → http_build_query → urldecode → 大写MD5 → SHA1withRSA → base64
|
||
*/
|
||
protected function md5Sha1RsaSign(array $params): string
|
||
{
|
||
ksort($params);
|
||
$raw = urldecode(http_build_query($params));
|
||
$md5Upper = strtoupper(md5($raw));
|
||
$signature = '';
|
||
openssl_sign($md5Upper, $signature, $this->privateKey, OPENSSL_ALGO_SHA1);
|
||
return base64_encode($signature);
|
||
}
|
||
|
||
/**
|
||
* 载入私钥:支持 PEM 全文 / 去头尾的单行 base64
|
||
* @return \OpenSSLAsymmetricKey|resource|string
|
||
*/
|
||
protected function loadPrivateKey(string $key)
|
||
{
|
||
if ($key === '') {
|
||
return '';
|
||
}
|
||
if (strpos($key, '-----BEGIN') === false) {
|
||
$key = "-----BEGIN PRIVATE KEY-----\n" . chunk_split(trim($key), 64, "\n") . "-----END PRIVATE KEY-----\n";
|
||
}
|
||
$res = openssl_pkey_get_private($key);
|
||
return $res === false ? '' : $res;
|
||
}
|
||
|
||
/**
|
||
* 载入公钥:支持 PEM 全文 / 去头尾的单行 base64
|
||
* @return \OpenSSLAsymmetricKey|resource|false
|
||
*/
|
||
protected function loadPublicKey(string $key)
|
||
{
|
||
if ($key === '') {
|
||
return false;
|
||
}
|
||
if (strpos($key, '-----BEGIN') === false) {
|
||
$key = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(trim($key), 64, "\n") . "-----END PUBLIC KEY-----\n";
|
||
}
|
||
return openssl_pkey_get_public($key);
|
||
}
|
||
}
|