Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
352 lines
17 KiB
PHP
352 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* Alibaba Cloud Cloudauth provider.
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Identity\Provider;
|
|
|
|
use AKSRT\Identity\SettingsRuntime;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
final class AliyunProvider implements ProviderInterface {
|
|
|
|
public function key(): string { return 'aliyun'; }
|
|
|
|
public function capabilities(): array { return ['personal' => true, 'enterprise' => true]; }
|
|
|
|
public function validate_config() {
|
|
$cfg = SettingsRuntime::get();
|
|
foreach (['access_key_id', 'access_key_secret'] as $key) {
|
|
if (empty($cfg['credentials'][$key])) {
|
|
return new \WP_Error('aksrt_identity_aliyun_credentials', __('阿里云实人认证凭证不完整', 'aksrt'));
|
|
}
|
|
}
|
|
if (empty($cfg['personal_flow'])) {
|
|
return new \WP_Error('aksrt_identity_aliyun_scene', __('请配置阿里云实人认证 SceneId', 'aksrt'));
|
|
}
|
|
if (!empty($cfg['enterprise_flow']) && preg_match('/[^A-Za-z0-9_-]/', (string) $cfg['enterprise_flow'])) {
|
|
return new \WP_Error('aksrt_identity_aliyun_enterprise_scene', __('阿里云企业认证 SceneCode 格式无效', 'aksrt'));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public function start_personal(array $request): ProviderResult {
|
|
return $this->init_face_verify($request);
|
|
}
|
|
|
|
public function start_enterprise(array $request): ProviderResult {
|
|
$cfg = SettingsRuntime::get();
|
|
// Ref: https://help.aliyun.com/zh/id-verification/enterprise-factor-validation-v2
|
|
foreach (['company_name', 'company_code', 'person_name', 'id_number'] as $key) {
|
|
if (empty($request[$key])) {
|
|
return ProviderResult::failed('aliyun_enterprise_fields', __('企业认证资料不完整', 'aksrt'));
|
|
}
|
|
}
|
|
$response = $this->rpc('cloudauth.aliyuncs.com', '2022-11-25', 'EntElementVerifyV2', [
|
|
'SceneCode' => (string) ($cfg['enterprise_flow'] ?: $cfg['personal_flow']),
|
|
'MerchantBizId' => preg_replace('/[^A-Za-z0-9]/', '', (string) $request['application_uuid']),
|
|
'MerchantUserId' => preg_replace('/[^A-Za-z0-9]/', '', 'U' . (string) ($request['user_id'] ?? '0')),
|
|
'UserAuthorization' => '1',
|
|
'InfoVerifyType' => 'ENT_4META',
|
|
'LicenseNo' => preg_replace('/\s+/', '', (string) $request['company_code']),
|
|
'EntName' => (string) $request['company_name'],
|
|
'LegalPersonName' => (string) $request['person_name'],
|
|
'LegalPersonCertNo' => preg_replace('/\s+/', '', (string) $request['id_number']),
|
|
]);
|
|
if (is_wp_error($response)) {
|
|
return ProviderResult::failed($response->get_error_code(), $response->get_error_message());
|
|
}
|
|
if (!$this->enterprise_passed($response)) {
|
|
return ProviderResult::failed(
|
|
sanitize_text_field((string) ($response['Code'] ?? $response['ResultCode'] ?? 'enterprise_failed')),
|
|
sanitize_text_field((string) ($response['Message'] ?? __('企业要素或法人关系核验未通过', 'aksrt')))
|
|
);
|
|
}
|
|
return $this->init_face_verify($request);
|
|
}
|
|
|
|
public function query(array $application): ProviderResult {
|
|
// Ref: https://api.aliyun.com/document/Cloudauth/2019-03-07/DescribeFaceVerify
|
|
$response = $this->rpc('cloudauth.aliyuncs.com', '2019-03-07', 'DescribeFaceVerify', [
|
|
'SceneId' => (string) ($application['provider_flow'] ?? ''),
|
|
'CertifyId' => (string) ($application['provider_session_id'] ?? ''),
|
|
]);
|
|
if (is_wp_error($response)) {
|
|
return ProviderResult::pending(['result_code' => $response->get_error_code(), 'message' => __('认证结果暂未取回,请稍后重试', 'aksrt')]);
|
|
}
|
|
$code = sanitize_text_field((string) ($response['Code'] ?? ''));
|
|
if ($code === '424') {
|
|
return ProviderResult::pending([
|
|
'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')),
|
|
'result_code' => $code,
|
|
'message' => __('用户尚未在阿里云核身页面提交认证,请完成后再刷新结果', 'aksrt'),
|
|
]);
|
|
}
|
|
if ($code !== '' && $code !== '200') {
|
|
return ProviderResult::failed($code, sanitize_text_field((string) ($response['Message'] ?? '')));
|
|
}
|
|
$object = $this->normalize_result_object($response['ResultObject'] ?? []);
|
|
$passed = $object['Passed'] ?? $object['passed'] ?? $response['Passed'] ?? null;
|
|
$success = $object['Success'] ?? $object['success'] ?? $response['Success'] ?? null;
|
|
if ((string) ($response['Code'] ?? '') === '200' && ($success === true || $success === 'True' || $success === 'true') && $passed === 'T') {
|
|
return ProviderResult::passed(['request_id' => (string) ($response['RequestId'] ?? ''), 'result_code' => 'passed']);
|
|
}
|
|
$status = strtoupper((string) ($object['SubCode'] ?? $object['Status'] ?? $object['subCode'] ?? $object['status'] ?? ''));
|
|
if ((string) ($response['Code'] ?? '') === '200' && $status === '200') {
|
|
return ProviderResult::passed(['request_id' => (string) ($response['RequestId'] ?? ''), 'result_code' => 'passed']);
|
|
}
|
|
$message = $object['MaterialInfo'] ?? $object['Message'] ?? $object['message'] ?? __('阿里云仍在处理认证结果', 'aksrt');
|
|
if ($passed === 'F') {
|
|
$debug = $this->response_debug_summary($response, $object, ['passed' => 'F']);
|
|
$this->log_debug('DescribeFaceVerify not passed', $debug);
|
|
return ProviderResult::failed($status ?: 'not_passed', sanitize_text_field((string) $message));
|
|
}
|
|
if ($object === []) {
|
|
$debug = $this->response_debug_summary($response, $object);
|
|
$this->log_debug('DescribeFaceVerify empty ResultObject', $debug);
|
|
return ProviderResult::pending([
|
|
'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')),
|
|
'result_code' => 'processing',
|
|
'message' => sprintf(__('阿里云已返回成功状态,认证明细仍在生成中:%s', 'aksrt'), $debug),
|
|
]);
|
|
}
|
|
if ($status === '' || in_array($status, ['PROCESSING', 'INIT', 'PENDING'], true)) {
|
|
return ProviderResult::pending(['result_code' => $status ?: 'processing', 'message' => sanitize_text_field((string) $message)]);
|
|
}
|
|
return ProviderResult::failed($status, sanitize_text_field((string) $message));
|
|
}
|
|
|
|
private function is_public_https_url(string $url): bool {
|
|
if ($url === '') {
|
|
return false;
|
|
}
|
|
$parts = wp_parse_url($url);
|
|
if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https') {
|
|
return false;
|
|
}
|
|
$host = strtolower((string) ($parts['host'] ?? ''));
|
|
return $host !== '' && !in_array($host, ['localhost', '127.0.0.1', '::1'], true) && !str_ends_with($host, '.local');
|
|
}
|
|
|
|
public function verify_callback(\WP_REST_Request $request): bool {
|
|
$token = sanitize_text_field((string) ($request->get_param('CallbackToken') ?: $request->get_param('callbackToken')));
|
|
$session = sanitize_text_field((string) ($request->get_param('certifyId') ?: $request->get_param('CertifyId')));
|
|
if ($token === '' || $session === '') {
|
|
return false;
|
|
}
|
|
global $wpdb;
|
|
$table = \AKSRT\Core\DBMigrator::table('identity_application');
|
|
$uuid = (string) $wpdb->get_var($wpdb->prepare(
|
|
"SELECT application_uuid FROM {$table} WHERE provider = 'aliyun' AND provider_session_id = %s LIMIT 1",
|
|
$session
|
|
));
|
|
return $uuid !== '' && hash_equals(hash_hmac('sha256', $uuid, wp_salt('nonce')), $token);
|
|
}
|
|
|
|
public function parse_callback(\WP_REST_Request $request): ProviderResult {
|
|
$session = sanitize_text_field((string) ($request->get_param('certifyId') ?: $request->get_param('CertifyId')));
|
|
$outer_order = sanitize_text_field((string) ($request->get_param('outerOrderNo') ?: $request->get_param('OuterOrderNo')));
|
|
return ProviderResult::pending(['provider_session_id' => $session, 'outer_order_no' => $outer_order, 'result_code' => 'callback_received']);
|
|
}
|
|
|
|
private function init_face_verify(array $request): ProviderResult {
|
|
// Ref: https://api.aliyun.com/document/Cloudauth/2019-03-07/InitFaceVerify
|
|
$cfg = SettingsRuntime::get();
|
|
$callback_token = hash_hmac('sha256', (string) ($request['application_uuid'] ?? ''), wp_salt('nonce'));
|
|
$params = [
|
|
'SceneId' => (string) $cfg['personal_flow'],
|
|
'OuterOrderNo' => (string) ($request['application_uuid'] ?? wp_generate_uuid4()),
|
|
'ProductCode' => (string) $cfg['product_code'],
|
|
'CertType' => 'IDENTITY_CARD',
|
|
'CertName' => (string) ($request['person_name'] ?? ''),
|
|
'CertNo' => preg_replace('/\s+/', '', (string) ($request['id_number'] ?? '')),
|
|
];
|
|
$meta_info = trim((string) ($request['meta_info'] ?? ''));
|
|
if ($meta_info !== '') {
|
|
$params['MetaInfo'] = $meta_info;
|
|
}
|
|
$return_url = esc_url_raw((string) ($request['return_url'] ?? ''));
|
|
if ($this->is_public_https_url($return_url)) {
|
|
$params['ReturnUrl'] = $return_url;
|
|
}
|
|
$callback_url = esc_url_raw(rest_url('aksrt/v1/identity/callback/aliyun'));
|
|
if ($this->is_public_https_url($callback_url)) {
|
|
$params['CallbackUrl'] = $callback_url;
|
|
$params['CallbackToken'] = $callback_token;
|
|
}
|
|
$response = $this->rpc('cloudauth.aliyuncs.com', '2019-03-07', 'InitFaceVerify', $params);
|
|
if (is_wp_error($response)) {
|
|
return ProviderResult::failed($response->get_error_code(), $response->get_error_message());
|
|
}
|
|
$code = sanitize_text_field((string) ($response['Code'] ?? ''));
|
|
if ($code !== '' && $code !== '200') {
|
|
return ProviderResult::failed($code, sanitize_text_field((string) ($response['Message'] ?? '')));
|
|
}
|
|
$object = $this->normalize_result_object($response['ResultObject'] ?? []);
|
|
$certify_id = sanitize_text_field((string) $this->first_response_value($response, [
|
|
['ResultObject', 'CertifyId'],
|
|
['ResultObject', 'certifyId'],
|
|
['CertifyId'],
|
|
['certifyId'],
|
|
['Data', 'CertifyId'],
|
|
['Data', 'certifyId'],
|
|
]));
|
|
$url = esc_url_raw((string) $this->first_response_value(array_merge($response, ['ResultObject' => $object]), [
|
|
['ResultObject', 'CertifyUrl'],
|
|
['ResultObject', 'certifyUrl'],
|
|
['ResultObject', 'Url'],
|
|
['ResultObject', 'url'],
|
|
['CertifyUrl'],
|
|
['certifyUrl'],
|
|
['Url'],
|
|
['url'],
|
|
['Data', 'CertifyUrl'],
|
|
['Data', 'certifyUrl'],
|
|
['Data', 'Url'],
|
|
['Data', 'url'],
|
|
]));
|
|
if ($certify_id === '') {
|
|
$debug = $this->response_debug_summary($response, $object, [
|
|
'has_certify_id' => false,
|
|
'has_certify_url' => $url !== '',
|
|
]);
|
|
$this->log_debug('InitFaceVerify missing CertifyId', $debug);
|
|
return ProviderResult::failed('aliyun_invalid_response', sprintf(
|
|
__('阿里云未返回有效认证会话:%s', 'aksrt'),
|
|
$debug
|
|
));
|
|
}
|
|
if ($url === '') {
|
|
$debug = $this->response_debug_summary($response, $object, [
|
|
'has_certify_id' => true,
|
|
'has_certify_url' => false,
|
|
]);
|
|
$this->log_debug('InitFaceVerify returned CertifyId without URL', $debug);
|
|
return ProviderResult::failed('aliyun_missing_certify_url', sprintf(
|
|
__('阿里云只返回了 CertifyId,未返回可打开的核身链接。请确认 SceneId/ProductCode 对应的是支持 H5 链接的实人认证方案;调试信息:%s', 'aksrt'),
|
|
$debug
|
|
));
|
|
}
|
|
return ProviderResult::pending([
|
|
'provider_session_id' => $certify_id,
|
|
'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')),
|
|
'redirect_url' => $url,
|
|
'result_code' => 'session_created',
|
|
'expires_at' => gmdate('Y-m-d H:i:s', time() + ((int) $cfg['session_ttl_minutes'] * MINUTE_IN_SECONDS)),
|
|
]);
|
|
}
|
|
|
|
/** @return array<string,mixed>|\WP_Error */
|
|
private function rpc(string $host, string $version, string $action, array $business) {
|
|
$cfg = SettingsRuntime::get();
|
|
$endpoint = 'https://' . $host . '/';
|
|
// The official PHP SDK uses ACS3-HMAC-SHA256 by default. This zero-dependency
|
|
// fallback deliberately uses the documented RPC v2 signature until the SDK
|
|
// packages can be installed and locked in this deployment.
|
|
$params = array_merge([
|
|
'Format' => 'JSON',
|
|
'Version' => $version,
|
|
'AccessKeyId' => (string) $cfg['credentials']['access_key_id'],
|
|
'SignatureMethod' => 'HMAC-SHA1',
|
|
'SignatureNonce' => wp_generate_uuid4(),
|
|
'SignatureVersion' => '1.0',
|
|
'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
|
|
'Action' => $action,
|
|
], $business);
|
|
ksort($params);
|
|
$canonical = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
|
$string_to_sign = 'POST&%2F&' . rawurlencode($canonical);
|
|
$params['Signature'] = base64_encode(hash_hmac('sha1', $string_to_sign, (string) $cfg['credentials']['access_key_secret'] . '&', true));
|
|
$response = wp_remote_post($endpoint, [
|
|
'timeout' => 20,
|
|
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
|
'body' => $params,
|
|
]);
|
|
if (is_wp_error($response)) {
|
|
return new \WP_Error('aliyun_network_error', __('阿里云接口请求失败', 'aksrt'));
|
|
}
|
|
$status_code = (int) wp_remote_retrieve_response_code($response);
|
|
$body = (string) wp_remote_retrieve_body($response);
|
|
$decoded = json_decode($body, true);
|
|
if (!is_array($decoded)) {
|
|
$this->log_debug('Aliyun invalid JSON response', [
|
|
'http_status' => $status_code,
|
|
'body_preview' => $this->truncate_debug($body, 800),
|
|
]);
|
|
return new \WP_Error('aliyun_invalid_json', __('阿里云返回格式无效', 'aksrt'));
|
|
}
|
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
|
$this->log_debug('Aliyun RPC response', [
|
|
'action' => $action,
|
|
'http_status' => $status_code,
|
|
'summary' => $this->response_debug_summary($decoded),
|
|
]);
|
|
}
|
|
return $decoded;
|
|
}
|
|
|
|
private function enterprise_passed(array $response): bool {
|
|
$result = is_array($response['Result'] ?? null) ? $response['Result'] : [];
|
|
return (string) ($response['Code'] ?? '') === 'Success'
|
|
&& (string) ($result['BizCode'] ?? '') === '1'
|
|
&& (string) ($result['Status'] ?? '') === '1';
|
|
}
|
|
|
|
/** @param mixed $value @return array<string,mixed> */
|
|
private function normalize_result_object($value): array {
|
|
if (is_array($value)) {
|
|
return $value;
|
|
}
|
|
if (is_string($value) && $value !== '') {
|
|
$decoded = json_decode($value, true);
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/** @param array<string,mixed> $response @param array<int,array<int,string>> $paths */
|
|
private function first_response_value(array $response, array $paths): string {
|
|
foreach ($paths as $path) {
|
|
$value = $response;
|
|
foreach ($path as $segment) {
|
|
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
|
$value = null;
|
|
break;
|
|
}
|
|
$value = $value[$segment];
|
|
}
|
|
if (is_scalar($value) && (string) $value !== '') {
|
|
return (string) $value;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/** @param array<string,mixed> $response @param array<string,mixed> $object @param array<string,mixed> $extra */
|
|
private function response_debug_summary(array $response, array $object = [], array $extra = []): string {
|
|
$summary = array_merge([
|
|
'code' => (string) ($response['Code'] ?? ''),
|
|
'message' => (string) ($response['Message'] ?? ''),
|
|
'request_id' => (string) ($response['RequestId'] ?? ''),
|
|
'top_keys' => implode(',', array_slice(array_keys($response), 0, 20)),
|
|
'result_keys' => $object !== [] ? implode(',', array_slice(array_keys($object), 0, 20)) : '',
|
|
], $extra);
|
|
return wp_json_encode($summary, JSON_UNESCAPED_UNICODE) ?: '';
|
|
}
|
|
|
|
/** @param mixed $data */
|
|
private function log_debug(string $message, $data): void {
|
|
if (!defined('WP_DEBUG') || !WP_DEBUG) {
|
|
return;
|
|
}
|
|
error_log('[AKSRT Identity Aliyun] ' . $message . ': ' . (is_string($data) ? $data : (wp_json_encode($data, JSON_UNESCAPED_UNICODE) ?: '')));
|
|
}
|
|
|
|
private function truncate_debug(string $value, int $length): string {
|
|
return function_exists('mb_substr') ? mb_substr($value, 0, $length) : substr($value, 0, $length);
|
|
}
|
|
}
|