Auth.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. <?php
  2. namespace common\components\delivery\platform\dada;
  3. use common\components\delivery\helpers\HttpClient;
  4. use common\components\util;
  5. use Yii;
  6. /**
  7. * 达达(DaDa)开放平台授权认证类
  8. *
  9. * 处理达达开放平台的授权流程,包括:
  10. * - OAuth2.0 授权
  11. * - 授权码兑换令牌
  12. * - 令牌刷新
  13. */
  14. class Auth
  15. {
  16. // OAuth 参数
  17. const GRANT_TYPE_AUTH_CODE = 'authorization_code';
  18. const GRANT_TYPE_REFRESH = 'refresh_token';
  19. // 授权端点
  20. const AUTHORIZE_URL_PROD = 'https://newopen.imdada.cn/';
  21. const TOKEN_URL_PROD = '';
  22. const AUTHORIZE_URL_TEST = 'https://newopen.qa.imdada.cn/';
  23. const TOKEN_URL_TEST = '';
  24. protected $appKey;
  25. protected $appSecret;
  26. protected $redirectUri;
  27. protected $isSandbox;
  28. protected $authorizeUrl;
  29. protected $tokenUrl;
  30. /**
  31. * 初始化授权类
  32. * 根据环境获取配置信息
  33. */
  34. public function __construct($mainId)
  35. {
  36. $isProduction = getenv('YII_ENV') == 'production';
  37. if ($isProduction) {
  38. // 生产环境配置
  39. $this->appKey = 'dadaf2279d901a5ba40';
  40. $this->appSecret = '828a03677a1c00a3a5b3c59209c4433d';
  41. $this->redirectUri = Yii::$app->params['ghsHost'].'/delivery/dada-auth-callback?mainId='.$mainId; //'https://api.shop.hzghd.com' . '/delivery/dada-auth-callback?mainId='.$mainId;
  42. $this->authorizeUrl = self::AUTHORIZE_URL_PROD;
  43. $this->tokenUrl = self::TOKEN_URL_PROD;
  44. } else {
  45. // 测试环境配置
  46. $this->appKey = 'dadaf2279d901a5ba40';
  47. $this->appSecret = '828a03677a1c00a3a5b3c59209c4433d';
  48. $this->redirectUri = Yii::$app->params['ghsHost'].'/delivery/dada-auth-callback';
  49. $this->authorizeUrl = self::AUTHORIZE_URL_TEST;
  50. $this->tokenUrl = self::TOKEN_URL_TEST;
  51. }
  52. $this->isSandbox = !$isProduction;
  53. }
  54. /**
  55. * 获取授权码
  56. *
  57. * 调用达达 API 获取一次性授权码(ticket),后续授权流程需要此 ticket
  58. * 接口地址:GET /third/party/ticket
  59. *
  60. * 返回结果参数说明:
  61. * - status: 响应状态
  62. * - code: 响应编码
  63. * - msg: 响应描述
  64. * - result: ticket,一次性准入码
  65. * - errorCode: 错误编码
  66. *
  67. * @return string|null ticket 一次性授权码,获取失败返回 null
  68. */
  69. public function getTicket()
  70. {
  71. // 生成随机数
  72. $nonce = $this->generateNonce(15);
  73. // 生成签名
  74. $sign = $this->generateTicketSign($nonce);
  75. // 构建请求参数
  76. $params = [
  77. 'appKey' => $this->appKey,
  78. 'nonce' => $nonce,
  79. 'sign' => $sign,
  80. ];
  81. // 发送 GET 请求
  82. $url = $this->authorizeUrl . 'third/party/ticket';
  83. try {
  84. // 构建完整请求 URL
  85. $requestUrl = $url;
  86. if (!empty($params)) {
  87. $requestUrl .= '?' . http_build_query($params);
  88. }
  89. Yii::info("\n\n\n\n\n\nrequestUrl: " . $requestUrl);
  90. // 初始化 cURL
  91. $ch = curl_init();
  92. // 设置选项
  93. curl_setopt($ch, CURLOPT_URL, $requestUrl);
  94. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  95. curl_setopt($ch, CURLOPT_HEADER, false);
  96. curl_setopt($ch, CURLOPT_TIMEOUT, 15);
  97. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  98. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  99. curl_setopt($ch, CURLOPT_HTTPHEADER, [
  100. 'Content-Type: application/json'
  101. ]);
  102. // 执行请求
  103. $response = curl_exec($ch);
  104. $error = curl_error($ch);
  105. curl_close($ch);
  106. // 检查错误
  107. if ($error) {
  108. throw new \Exception('cURL Request Error: ' . $error);
  109. }
  110. // 解析响应
  111. $resp = json_decode($response, true);
  112. if (json_last_error() !== JSON_ERROR_NONE) {
  113. // 如果解析失败,可能是非 JSON 响应
  114. // throw new \Exception('Invalid JSON Response: ' . $response);
  115. Yii::error('Invalid JSON Response: ' . $response);
  116. util::fail('请求达达获取ticket失败');
  117. }
  118. // 检查响应状态,返回 ticket
  119. if (isset($resp['result']) && !empty($resp['result'])) {
  120. Yii::debug('成功获取授权码:' . $resp['result']);
  121. return $resp['result'];
  122. }
  123. Yii::warning('获取授权码失败:' . json_encode($resp));
  124. return null;
  125. } catch (\Exception $e) {
  126. Yii::error('获取授权码异常:' . $e->getMessage());
  127. return null;
  128. }
  129. }
  130. /**
  131. * 生成获取授权码请求的签名
  132. *
  133. * 签名算法:
  134. * 1. 参与签名的参数按字典排序:appKey、appSecret、nonce
  135. * 2. 拼接参数值
  136. * 3. SHA1 加密
  137. *
  138. * 示例:
  139. * - appKey: dada6c68011157c5f63
  140. * - appSecret: 828a03677a1c00a3a5b3c59209c4433d
  141. * - nonce: RHU3RY4YR234238
  142. * 排序后拼接:dada6c68011157c5f63828a03677a1c00a3a5b3c59209c4433dRHU3RY4YR234238
  143. * SHA1(上述字符串)
  144. *
  145. * @param string $nonce 随机数
  146. * @return string SHA1 签名
  147. */
  148. private function generateTicketSign($nonce)
  149. {
  150. // 参与签名的参数
  151. $signParams = [
  152. 'appKey' => $this->appKey,
  153. 'nonce' => $nonce,
  154. 'appSecret' => $this->appSecret,
  155. ];
  156. // 第一步
  157. sort($signParams, SORT_STRING);
  158. // 第二步:拼接所有参数值
  159. $signString = implode('', $signParams);
  160. // 第三步:SHA1 加密
  161. $hex = sha1($signString);
  162. return $hex;
  163. }
  164. /**
  165. * 生成获取授权码请求的签名
  166. *
  167. * 签名算法:
  168. * 1. 参与签名的参数值按字典排序:appKey、appSecret、nonce
  169. * 2. 拼接参数值
  170. * 3. SHA1 加密
  171. *
  172. * @param string $nonce 随机数
  173. * @return string SHA1 签名
  174. */
  175. function generateTicketSign_234($nonce)
  176. {
  177. // 1. 按字段名升序拼接
  178. $map = [
  179. 'appKey' => $this->appKey,
  180. 'appSecret' =>$this->appSecret,
  181. 'nonce' => $nonce,
  182. ];
  183. ksort($map, SORT_STRING); // 按 key 升序
  184. $raw = '';
  185. foreach ($map as $k => $v) {
  186. $raw .= $k . $v; // 不要加 & = 空格
  187. }
  188. //$raw = implode('', $map);
  189. // 2. SHA1 得到 40 位 16 进制(小写)
  190. $sha1 = sha1($raw, false); // 第二个参数=false 返回十六进制字符串
  191. // 3. 转大写 + 截前 36 位
  192. //return strtoupper(substr($sha1, 0, 36));
  193. return strtoupper($sha1);
  194. return sha1($raw);
  195. }
  196. private function newGenerateTicketSign($nonce)
  197. {
  198. // 参与签名的参数
  199. $signParams = [
  200. 'appKey' => $this->appKey,
  201. 'nonce' => $nonce,
  202. 'appSecret' => $this->appSecret,
  203. ];
  204. // 第一步:将参与签名的参数按照键值(key)进行字典排序
  205. ksort($signParams);
  206. // 第二步:将排序过后的参数,进行key和value字符串拼接
  207. $signString = '';
  208. foreach ($signParams as $key => $value) {
  209. $signString .= $key . $value;
  210. }
  211. // 第三步:将拼接后的字符串首尾加上app_secret秘钥,合成签名字符串
  212. $finalSignString = $this->appSecret . $signString . $this->appSecret;
  213. // 第四步:对签名字符串进行MD5加密,生成32位的字符串
  214. $sign = md5($finalSignString);
  215. // 第五步:将签名生成的32位字符串转换为大写
  216. return strtoupper($sign);
  217. }
  218. /**
  219. * 生成授权链接
  220. *
  221. * 用户需要访问此链接进行授权,授权后会重定向到 redirectUri
  222. * 根据达达文档,授权链接需要以下参数:
  223. * - appKey: 应用 key
  224. * - shopId: 三方门店编号(可选)
  225. * - redirectUrl: 回调地址
  226. * - state: 回调标识(用于 CSRF 防护)
  227. * - nonce: 随机数
  228. * - ticket: 渠道授权码(需要从获取渠道授权码接口获取)
  229. * - sign: 签名字符串
  230. * - resultType: 是否跳转(可选,0-默认结果页,1-跳转)
  231. *
  232. * @param string $ticket 渠道授权码(从"获取渠道授权码"接口获取)
  233. * @param string $state 应用程序定义的不透明值(用于 CSRF 防护)
  234. * @param string $shopId 三方门店编号(可选)
  235. * @param int $resultType 是否跳转(可选,0-默认结果页,1-跳转)
  236. * @return string 授权链接
  237. * @throws \Exception 当缺少必要参数时
  238. */
  239. public function generateAuthUrl($ticket, $state = '', $shopId = '', $resultType = 0)
  240. {
  241. if (empty($ticket)) {
  242. throw new \Exception('ticket 参数不能为空,需要从"获取渠道授权码"接口获取');
  243. }
  244. // 生成随机数 nonce
  245. $nonce = $this->generateNonce();
  246. // 生成签名
  247. $sign = $this->generateAuthSign($ticket, $nonce, $shopId);
  248. // 构建授权链接参数
  249. $params = [
  250. 'appKey' => $this->appKey,
  251. 'redirectUrl' => $this->redirectUri,
  252. 'state' => $state ?: uniqid(),
  253. 'nonce' => $nonce,
  254. 'ticket' => $ticket,
  255. 'sign' => $sign,
  256. ];
  257. // 添加可选参数
  258. if (!empty($shopId)) {
  259. $params['shopId'] = $shopId;
  260. }
  261. if ($resultType != 0) {
  262. $params['resultType'] = $resultType;
  263. }
  264. return $this->authorizeUrl . 'third/party/oauth' . '?' . http_build_query($params);
  265. }
  266. private function generateAuthSign($ticket, $nonce, $shopId = '')
  267. {
  268. // 参与签名的参数
  269. $signParams = [
  270. 'appKey' => $this->appKey,
  271. 'ticket' => $ticket,
  272. 'nonce' => $nonce,
  273. 'appSecret' => $this->appSecret,
  274. ];
  275. if($shopId!=''){
  276. $signParams['shopId'] = $shopId;
  277. }
  278. // 第一步
  279. sort($signParams, SORT_STRING);
  280. // 第二步:拼接所有参数值
  281. $signString = implode('', $signParams);
  282. // 第三步:SHA1 加密
  283. $hex = sha1($signString);
  284. return $hex;
  285. }
  286. /**
  287. * 生成随机数(nonce)
  288. *
  289. * @return string 8位随机字母数字组合
  290. */
  291. private function generateNonce($length = 8)
  292. {
  293. $characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  294. $nonce = '';
  295. for ($i = 0; $i < $length; $i++) {
  296. $nonce .= $characters[rand(0, strlen($characters) - 1)];
  297. }
  298. return $nonce;
  299. }
  300. /**
  301. * 生成签名(sign)
  302. *
  303. * 签名算法:
  304. * 1. 参与签名的参数按字典顺序排列:appKey、nonce、ticket、shopId(如果有)
  305. * 2. 按 key+value 拼接
  306. * 3. 首尾加上 appSecret
  307. * 4. MD5 加密
  308. * 5. 转大写
  309. *
  310. * @param string $ticket 渠道授权码
  311. * @param string $nonce 随机数
  312. * @param string $shopId 三方门店编号(可选)
  313. * @return string 签名字符串(32位大写MD5)
  314. */
  315. private function generateSign($ticket, $nonce, $shopId = '')
  316. {
  317. // 参与签名的参数
  318. $signParams = [
  319. 'appKey' => $this->appKey,
  320. 'nonce' => $nonce,
  321. 'ticket' => $ticket,
  322. ];
  323. // 如果有shopId,也加入签名参数
  324. if (!empty($shopId)) {
  325. $signParams['shopId'] = $shopId;
  326. }
  327. // 第一步:按键值字典排序
  328. ksort($signParams);
  329. // 第二步:拼接 key 和 value
  330. $signString = '';
  331. foreach ($signParams as $key => $value) {
  332. $signString .= $key . $value;
  333. }
  334. // 第三步:首尾加上 appSecret
  335. $finalSignString = $this->appSecret . $signString . $this->appSecret;
  336. // 第四步:MD5 加密
  337. $sign = md5($finalSignString);
  338. // 第五步:转大写
  339. return strtoupper($sign);
  340. }
  341. /**
  342. * 通过授权码获取访问令牌
  343. *
  344. * 用户授权后,使用授权码兑换访问令牌
  345. *
  346. * @param string $code 授权码(从授权回调中获取)
  347. * @return array|null 令牌信息
  348. * [
  349. * 'access_token' => '访问令牌',
  350. * 'token_type' => 'Bearer',
  351. * 'expires_in' => 令牌过期时间(秒),
  352. * 'refresh_token' => '刷新令牌',
  353. * 'scope' => '授权范围'
  354. * ]
  355. */
  356. public function getAccessToken($code)
  357. {
  358. if (empty($code)) {
  359. return null;
  360. }
  361. $params = [
  362. 'grant_type' => self::GRANT_TYPE_AUTH_CODE,
  363. 'code' => $code,
  364. 'client_id' => $this->appKey,
  365. 'client_secret' => $this->appSecret,
  366. 'redirect_uri' => $this->redirectUri,
  367. ];
  368. $resp = HttpClient::post($this->tokenUrl, $params);
  369. // 检查响应是否成功
  370. if (isset($resp['access_token'])) {
  371. return [
  372. 'access_token' => $resp['access_token'],
  373. 'token_type' => $resp['token_type'] ?? 'Bearer',
  374. 'expires_in' => $resp['expires_in'] ?? 3600,
  375. 'refresh_token' => $resp['refresh_token'] ?? null,
  376. 'scope' => $resp['scope'] ?? '',
  377. 'create_time' => time(),
  378. ];
  379. }
  380. Yii::error('Failed to get access token: ' . json_encode($resp));
  381. return null;
  382. }
  383. /**
  384. * 使用刷新令牌获取新的访问令牌
  385. *
  386. * 当访问令牌过期时,使用刷新令牌获取新的访问令牌
  387. *
  388. * @param string $refreshToken 刷新令牌
  389. * @return array|null 新的令牌信息
  390. */
  391. public function refreshAccessToken($refreshToken)
  392. {
  393. if (empty($refreshToken)) {
  394. return null;
  395. }
  396. $params = [
  397. 'grant_type' => self::GRANT_TYPE_REFRESH,
  398. 'refresh_token' => $refreshToken,
  399. 'client_id' => $this->appKey,
  400. 'client_secret' => $this->appSecret,
  401. ];
  402. $resp = HttpClient::post($this->tokenUrl, $params);
  403. // 检查响应是否成功
  404. if (isset($resp['access_token'])) {
  405. return [
  406. 'access_token' => $resp['access_token'],
  407. 'token_type' => $resp['token_type'] ?? 'Bearer',
  408. 'expires_in' => $resp['expires_in'] ?? 3600,
  409. 'refresh_token' => $resp['refresh_token'] ?? $refreshToken,
  410. 'scope' => $resp['scope'] ?? '',
  411. 'create_time' => time(),
  412. ];
  413. }
  414. Yii::error('Failed to refresh access token: ' . json_encode($resp));
  415. return null;
  416. }
  417. /**
  418. * 检查访问令牌是否已过期
  419. *
  420. * @param array $tokenInfo 令牌信息
  421. * [
  422. * 'access_token' => '...',
  423. * 'expires_in' => 3600,
  424. * 'create_time' => 时间戳,
  425. * ]
  426. * @return bool 是否已过期
  427. */
  428. public function isTokenExpired($tokenInfo)
  429. {
  430. if (empty($tokenInfo) || !is_array($tokenInfo)) {
  431. return true;
  432. }
  433. $createTime = $tokenInfo['create_time'] ?? 0;
  434. $expiresIn = $tokenInfo['expires_in'] ?? 0;
  435. $currentTime = time();
  436. // 提前 300 秒(5 分钟)进行刷新,避免临界情况
  437. return ($createTime + $expiresIn - 300) <= $currentTime;
  438. }
  439. }