387 lines
15 KiB
PHP
387 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\qywx;
|
|
|
|
use EasyWeChat\OpenWork\Application;
|
|
use EasyWeChat\OpenWork\Message;
|
|
use RuntimeException;
|
|
use think\facade\Db;
|
|
use think\facade\Log;
|
|
|
|
/** 企业微信服务商授权流程及授权企业凭证管理。 */
|
|
class QywxPromotionOpenWorkService
|
|
{
|
|
public static function configurationStatus(): array
|
|
{
|
|
$suiteId = self::configString('suite_id');
|
|
$required = ['provider_corp_id', 'suite_id', 'suite_secret', 'token', 'aes_key'];
|
|
$missing = [];
|
|
foreach ($required as $key) {
|
|
if (self::configString($key) === '') {
|
|
$missing[] = $key;
|
|
}
|
|
}
|
|
if (self::credentialMaterial() === '') {
|
|
$missing[] = 'credential_key';
|
|
}
|
|
|
|
$ticketAt = 0;
|
|
if ($suiteId !== '' && self::tableExists('qywx_promotion_provider_state')) {
|
|
$ticketAt = (int) (Db::name('qywx_promotion_provider_state')
|
|
->where('suite_id', $suiteId)
|
|
->value('ticket_received_at') ?? 0);
|
|
}
|
|
|
|
return [
|
|
'enabled' => (bool) config('qywx_promotion.enabled', false),
|
|
'configured' => $missing === [],
|
|
'ready' => (bool) config('qywx_promotion.enabled', false) && $missing === [] && $ticketAt > 0,
|
|
'missing' => $missing,
|
|
'suite_id_masked' => self::mask($suiteId),
|
|
'ticket_received_at' => $ticketAt,
|
|
];
|
|
}
|
|
|
|
public static function authorizationUrl(int $adminId, string $redirectUri): string
|
|
{
|
|
self::assertReady();
|
|
$redirectUri = self::configuredRedirectUri($redirectUri);
|
|
if ($redirectUri === '') {
|
|
throw new RuntimeException('无法生成企业微信授权回调地址');
|
|
}
|
|
|
|
$app = self::application();
|
|
$suiteAccessToken = $app->getSuiteAccessToken()->getToken();
|
|
$response = $app->getHttpClient()->request('GET', 'cgi-bin/service/get_pre_auth_code', [
|
|
'query' => ['suite_access_token' => $suiteAccessToken],
|
|
])->toArray(false);
|
|
$preAuthCode = trim((string) ($response['pre_auth_code'] ?? ''));
|
|
if ($preAuthCode === '') {
|
|
throw new RuntimeException('获取企业微信预授权码失败:' . (string) ($response['errmsg'] ?? '未知错误'));
|
|
}
|
|
|
|
return 'https://open.work.weixin.qq.com/3rdapp/install?' . http_build_query([
|
|
'suite_id' => self::configString('suite_id'),
|
|
'pre_auth_code' => $preAuthCode,
|
|
'redirect_uri' => $redirectUri,
|
|
'state' => self::makeState($adminId),
|
|
], '', '&', PHP_QUERY_RFC3986);
|
|
}
|
|
|
|
public static function consumeAuthorizationCallback(string $authCode, string $state): array
|
|
{
|
|
$adminId = self::verifyState($state);
|
|
if ($authCode === '') {
|
|
throw new RuntimeException('企业微信未返回临时授权码');
|
|
}
|
|
|
|
return self::exchangePermanentCode($authCode, $adminId);
|
|
}
|
|
|
|
public static function exchangePermanentCode(string $authCode, int $adminId = 0): array
|
|
{
|
|
self::assertConfigured();
|
|
$app = self::application();
|
|
$suiteAccessToken = $app->getSuiteAccessToken()->getToken();
|
|
$response = $app->getHttpClient()->request('POST', 'cgi-bin/service/get_permanent_code', [
|
|
'query' => ['suite_access_token' => $suiteAccessToken],
|
|
'json' => ['auth_code' => $authCode],
|
|
])->toArray(false);
|
|
$permanentCode = trim((string) ($response['permanent_code'] ?? ''));
|
|
$corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : [];
|
|
$corpId = trim((string) ($corpInfo['corpid'] ?? ''));
|
|
if ($permanentCode === '' || $corpId === '') {
|
|
throw new RuntimeException('换取企业永久授权码失败:' . (string) ($response['errmsg'] ?? '返回信息不完整'));
|
|
}
|
|
|
|
return self::saveAuthorization($corpId, $permanentCode, $response, $adminId);
|
|
}
|
|
|
|
public static function verifyAccount(int $accountId): array
|
|
{
|
|
self::assertConfigured();
|
|
$row = Db::name('qywx_promotion_account')->where('id', $accountId)->whereNull('delete_time')->find();
|
|
if (!$row) {
|
|
throw new RuntimeException('授权企业不存在');
|
|
}
|
|
|
|
$permanentCode = QywxPromotionCredentialCipher::decrypt((string) ($row['permanent_code_cipher'] ?? ''));
|
|
$authorization = self::application()->getAuthorization((string) $row['corp_id'], $permanentCode)->toArray();
|
|
self::saveAuthorization((string) $row['corp_id'], $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0));
|
|
|
|
return ['id' => $accountId, 'verified_at' => time()];
|
|
}
|
|
|
|
public static function serveProviderCallback()
|
|
{
|
|
self::assertConfigured();
|
|
$app = self::application();
|
|
$server = $app->getServer();
|
|
|
|
$server->handleAuthCreated(function (Message $message, \Closure $next) {
|
|
$authCode = trim((string) ($message['AuthCode'] ?? ''));
|
|
if ($authCode !== '') {
|
|
try {
|
|
self::exchangePermanentCode($authCode, 0);
|
|
} catch (\Throwable $e) {
|
|
Log::error('企微推广 create_auth 处理失败:' . $e->getMessage(), ['exception' => $e]);
|
|
}
|
|
}
|
|
|
|
return $next($message);
|
|
});
|
|
|
|
$server->handleAuthChanged(function (Message $message, \Closure $next) {
|
|
$corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? ''));
|
|
if ($corpId !== '') {
|
|
try {
|
|
self::refreshByCorpId($corpId);
|
|
} catch (\Throwable $e) {
|
|
Log::error('企微推广 change_auth 处理失败:' . $e->getMessage(), ['exception' => $e]);
|
|
}
|
|
}
|
|
|
|
return $next($message);
|
|
});
|
|
|
|
$server->handleAuthCancelled(function (Message $message, \Closure $next) {
|
|
$corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? ''));
|
|
if ($corpId !== '') {
|
|
Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->update([
|
|
'auth_status' => 0,
|
|
'update_time' => time(),
|
|
]);
|
|
}
|
|
|
|
return $next($message);
|
|
});
|
|
|
|
return $server->serve();
|
|
}
|
|
|
|
public static function configuredRedirectUri(string $fallback): string
|
|
{
|
|
return self::configString('redirect_uri') ?: trim($fallback);
|
|
}
|
|
|
|
public static function configuredAdminReturnUrl(string $fallback): string
|
|
{
|
|
return self::configString('admin_return_url') ?: trim($fallback);
|
|
}
|
|
|
|
public static function isAllowedPromotionUrl(string $url, bool $allowEmpty = false): bool
|
|
{
|
|
$url = trim($url);
|
|
if ($url === '') {
|
|
return $allowEmpty;
|
|
}
|
|
$parts = parse_url($url);
|
|
if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https') {
|
|
return false;
|
|
}
|
|
$host = strtolower(trim((string) ($parts['host'] ?? '')));
|
|
if ($host === '') {
|
|
return false;
|
|
}
|
|
foreach ((array) config('qywx_promotion.allowed_link_hosts', []) as $allowed) {
|
|
$allowed = strtolower(trim((string) $allowed));
|
|
if ($allowed !== '' && ($host === $allowed || str_ends_with($host, '.' . $allowed))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static function application(): Application
|
|
{
|
|
$app = new Application([
|
|
'corp_id' => self::configString('provider_corp_id'),
|
|
'provider_secret' => '',
|
|
'suite_id' => self::configString('suite_id'),
|
|
'suite_secret' => self::configString('suite_secret'),
|
|
'token' => self::configString('token'),
|
|
'aes_key' => self::configString('aes_key'),
|
|
]);
|
|
$app->setSuiteTicket(new QywxPromotionSuiteTicket(self::configString('suite_id')));
|
|
|
|
return $app;
|
|
}
|
|
|
|
private static function refreshByCorpId(string $corpId): void
|
|
{
|
|
$row = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->find();
|
|
if (!$row) {
|
|
return;
|
|
}
|
|
$permanentCode = QywxPromotionCredentialCipher::decrypt((string) $row['permanent_code_cipher']);
|
|
$authorization = self::application()->getAuthorization($corpId, $permanentCode)->toArray();
|
|
self::saveAuthorization($corpId, $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0));
|
|
}
|
|
|
|
private static function saveAuthorization(string $corpId, string $permanentCode, array $response, int $adminId): array
|
|
{
|
|
$corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : [];
|
|
$authInfo = is_array($response['auth_info'] ?? null) ? $response['auth_info'] : [];
|
|
$agents = is_array($authInfo['agent'] ?? null) ? $authInfo['agent'] : [];
|
|
$agent = is_array($agents[0] ?? null) ? $agents[0] : [];
|
|
$now = time();
|
|
$existing = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->find();
|
|
$ownerId = $adminId > 0 ? $adminId : (int) ($existing['owner_admin_id'] ?? 0);
|
|
$deptId = $ownerId > 0 ? self::primaryDeptId($ownerId) : (int) ($existing['dept_id'] ?? 0);
|
|
$data = [
|
|
'corp_name' => trim((string) ($corpInfo['corp_name'] ?? $existing['corp_name'] ?? $corpId)),
|
|
'permanent_code_cipher' => QywxPromotionCredentialCipher::encrypt($permanentCode),
|
|
'agent_id' => trim((string) ($agent['agentid'] ?? $existing['agent_id'] ?? '')),
|
|
// 授权响应可能包含 permanent_code;数据库元数据中只保留脱敏后的授权信息。
|
|
'auth_info_json' => json_encode(self::sanitizeAuthInfo($response), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'auth_status' => 1,
|
|
'owner_admin_id' => $ownerId,
|
|
'dept_id' => $deptId,
|
|
'authorized_at' => (int) ($existing['authorized_at'] ?? 0) ?: $now,
|
|
'last_refresh_at' => $now,
|
|
'update_time' => $now,
|
|
'delete_time' => null,
|
|
];
|
|
if ($existing) {
|
|
Db::name('qywx_promotion_account')->where('id', (int) $existing['id'])->update($data);
|
|
$id = (int) $existing['id'];
|
|
} else {
|
|
$data['corp_id'] = $corpId;
|
|
$data['create_time'] = $now;
|
|
$id = (int) Db::name('qywx_promotion_account')->insertGetId($data);
|
|
}
|
|
|
|
return ['id' => $id, 'corp_id' => $corpId, 'corp_name' => $data['corp_name']];
|
|
}
|
|
|
|
private static function makeState(int $adminId): string
|
|
{
|
|
$payload = self::base64UrlEncode(json_encode([
|
|
'a' => $adminId,
|
|
't' => time(),
|
|
'n' => bin2hex(random_bytes(8)),
|
|
], JSON_THROW_ON_ERROR));
|
|
$signature = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true));
|
|
|
|
return $payload . '.' . $signature;
|
|
}
|
|
|
|
private static function sanitizeAuthInfo(array $data): array
|
|
{
|
|
$sensitiveKeys = ['permanent_code', 'access_token', 'suite_ticket', 'suite_secret', 'provider_secret'];
|
|
foreach ($data as $key => $value) {
|
|
if (in_array(strtolower((string) $key), $sensitiveKeys, true)) {
|
|
unset($data[$key]);
|
|
continue;
|
|
}
|
|
if (is_array($value)) {
|
|
$data[$key] = self::sanitizeAuthInfo($value);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
private static function verifyState(string $state): int
|
|
{
|
|
$parts = explode('.', $state, 2);
|
|
if (count($parts) !== 2) {
|
|
throw new RuntimeException('企业微信授权 state 无效');
|
|
}
|
|
[$payload, $signature] = $parts;
|
|
$expected = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true));
|
|
if (!hash_equals($expected, $signature)) {
|
|
throw new RuntimeException('企业微信授权 state 验证失败');
|
|
}
|
|
$data = json_decode(self::base64UrlDecode($payload), true);
|
|
if (!is_array($data) || time() - (int) ($data['t'] ?? 0) > 1800) {
|
|
throw new RuntimeException('企业微信授权请求已过期,请重新发起');
|
|
}
|
|
|
|
return max(0, (int) ($data['a'] ?? 0));
|
|
}
|
|
|
|
private static function assertReady(): void
|
|
{
|
|
$status = self::configurationStatus();
|
|
if (!$status['enabled']) {
|
|
throw new RuntimeException('企业微信推广授权尚未启用');
|
|
}
|
|
self::assertConfigured();
|
|
if (!$status['ticket_received_at']) {
|
|
throw new RuntimeException('尚未收到 suite_ticket,请先配置企业微信应用指令回调');
|
|
}
|
|
}
|
|
|
|
private static function assertConfigured(): void
|
|
{
|
|
$status = self::configurationStatus();
|
|
if (!$status['configured']) {
|
|
throw new RuntimeException('企业微信服务商配置不完整:' . implode(', ', $status['missing']));
|
|
}
|
|
}
|
|
|
|
private static function stateKey(): string
|
|
{
|
|
$key = self::credentialMaterial();
|
|
if ($key === '') {
|
|
throw new RuntimeException('未配置企业微信推广授权签名密钥');
|
|
}
|
|
|
|
return hash('sha256', 'qywx-promotion-state|' . $key);
|
|
}
|
|
|
|
private static function credentialMaterial(): string
|
|
{
|
|
return self::configString('credential_key') ?: self::configString('suite_secret');
|
|
}
|
|
|
|
private static function configString(string $key): string
|
|
{
|
|
return trim((string) config('qywx_promotion.' . $key, ''));
|
|
}
|
|
|
|
private static function primaryDeptId(int $adminId): int
|
|
{
|
|
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
|
|
}
|
|
|
|
private static function mask(string $value): string
|
|
{
|
|
$length = strlen($value);
|
|
if ($length <= 8) {
|
|
return $value === '' ? '' : str_repeat('*', $length);
|
|
}
|
|
|
|
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
|
|
}
|
|
|
|
private static function base64UrlEncode(string $value): string
|
|
{
|
|
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
|
}
|
|
|
|
private static function base64UrlDecode(string $value): string
|
|
{
|
|
$value = strtr($value, '-_', '+/');
|
|
$padding = strlen($value) % 4;
|
|
if ($padding > 0) {
|
|
$value .= str_repeat('=', 4 - $padding);
|
|
}
|
|
|
|
return (string) base64_decode($value, true);
|
|
}
|
|
|
|
private static function tableExists(string $table): bool
|
|
{
|
|
try {
|
|
return Db::query("SHOW TABLES LIKE '" . config('database.connections.mysql.prefix', '') . $table . "'") !== [];
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|