CaiWuXiTong/app/Services/Auth/DingTalkOAuthClient.php

211 lines
7.6 KiB
PHP

<?php
namespace App\Services\Auth;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class DingTalkOAuthClient
{
public function isConfigured(): bool
{
return (bool) config('dingtalk.enabled')
&& $this->configuredString('dingtalk.client_id') !== ''
&& $this->configuredString('dingtalk.client_secret') !== ''
&& $this->configuredString('dingtalk.redirect_uri') !== '';
}
public function authorizationUrl(string $state): string
{
$this->ensureConfigured();
return rtrim($this->configuredString('dingtalk.authorize_url'), '?') . '?' . http_build_query([
'redirect_uri' => $this->configuredString('dingtalk.redirect_uri'),
'response_type' => 'code',
'client_id' => $this->configuredString('dingtalk.client_id'),
'scope' => 'openid',
'prompt' => 'consent',
'state' => $state,
], '', '&', PHP_QUERY_RFC3986);
}
/**
* @return array{access_token: string, expires_in: int|null}
*/
public function exchangeAuthorizationCode(string $code): array
{
$this->ensureConfigured();
try {
$response = Http::asJson()
->acceptJson()
->timeout($this->timeout())
->post($this->configuredString('dingtalk.token_url'), [
'clientId' => $this->configuredString('dingtalk.client_id'),
'clientSecret' => $this->configuredString('dingtalk.client_secret'),
'code' => $code,
'grantType' => 'authorization_code',
])
->throw();
} catch (RequestException $exception) {
throw new RuntimeException('DingTalk token exchange failed.', 0, $exception);
}
$accessToken = trim((string) $response->json('accessToken'));
if ($accessToken === '') {
throw new RuntimeException('DingTalk token response did not contain an access token.');
}
return [
'access_token' => $accessToken,
'expires_in' => is_numeric($response->json('expireIn')) ? (int) $response->json('expireIn') : null,
];
}
/** @return array{union_id: string, open_id: string|null, nickname: string|null} */
public function currentUser(string $accessToken): array
{
try {
$response = Http::acceptJson()
->withHeaders(['x-acs-dingtalk-access-token' => $accessToken])
->timeout($this->timeout())
->get($this->configuredString('dingtalk.user_url'))
->throw();
} catch (RequestException $exception) {
throw new RuntimeException('DingTalk user lookup failed.', 0, $exception);
}
$unionId = trim((string) $response->json('unionId'));
if ($unionId === '') {
throw new RuntimeException('DingTalk user response did not contain a union id.');
}
return [
'union_id' => $unionId,
'open_id' => $this->nullableString($response->json('openId')),
'nickname' => $this->nullableString($response->json('nick')),
];
}
/**
* Resolve a user through the enterprise application. A personal DingTalk
* account is not considered trusted unless the enterprise application can
* resolve it to an active internal user.
*
* @return array{
* union_id: string,
* open_id: string|null,
* dingtalk_user_id: string,
* nickname: string|null,
* email: string|null,
* mobile: string|null,
* active: bool
* }
*/
public function enterpriseUser(array $oauthProfile): array
{
$unionId = trim((string) ($oauthProfile['union_id'] ?? ''));
if ($unionId === '') {
throw new RuntimeException('DingTalk enterprise lookup requires a union id.');
}
$appToken = $this->appAccessToken();
try {
$userIdResponse = Http::asJson()
->acceptJson()
->timeout($this->timeout())
->withQueryParameters(['access_token' => $appToken])
->post($this->configuredString('dingtalk.user_by_union_id_url'), ['unionid' => $unionId])
->throw();
} catch (RequestException $exception) {
throw new RuntimeException('DingTalk enterprise user lookup failed.', 0, $exception);
}
$userId = trim((string) $userIdResponse->json('result.userid'));
if ((int) $userIdResponse->json('errcode', -1) !== 0 || $userId === '') {
throw new RuntimeException('DingTalk identity is not an enterprise user.');
}
try {
$detailResponse = Http::asJson()
->acceptJson()
->timeout($this->timeout())
->withQueryParameters(['access_token' => $appToken])
->post($this->configuredString('dingtalk.user_detail_url'), ['userid' => $userId])
->throw();
} catch (RequestException $exception) {
throw new RuntimeException('DingTalk enterprise user detail lookup failed.', 0, $exception);
}
$detail = $detailResponse->json('result');
if ((int) $detailResponse->json('errcode', -1) !== 0 || ! is_array($detail)) {
throw new RuntimeException('DingTalk enterprise user detail was unavailable.');
}
$detailUserId = trim((string) ($detail['userid'] ?? ''));
if ($detailUserId !== '' && ! hash_equals($userId, $detailUserId)) {
throw new RuntimeException('DingTalk enterprise user identity was inconsistent.');
}
$active = filter_var($detail['active'] ?? null, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
return [
'union_id' => $unionId,
'open_id' => $oauthProfile['open_id'] ?? null,
'dingtalk_user_id' => $userId,
'nickname' => $this->nullableString($detail['name'] ?? ($oauthProfile['nickname'] ?? null)),
'email' => $this->nullableString($detail['email'] ?? null),
'mobile' => $this->nullableString($detail['mobile'] ?? null),
'active' => $active === true,
];
}
protected function appAccessToken(): string
{
try {
$response = Http::acceptJson()
->timeout($this->timeout())
->get($this->configuredString('dingtalk.app_token_url'), [
'appkey' => $this->configuredString('dingtalk.client_id'),
'appsecret' => $this->configuredString('dingtalk.client_secret'),
])
->throw();
} catch (RequestException $exception) {
throw new RuntimeException('DingTalk application token lookup failed.', 0, $exception);
}
$token = trim((string) $response->json('access_token'));
if ((int) $response->json('errcode', -1) !== 0 || $token === '') {
throw new RuntimeException('DingTalk application token was unavailable.');
}
return $token;
}
protected function ensureConfigured(): void
{
if (! $this->isConfigured()) {
throw new RuntimeException('DingTalk login is not configured.');
}
}
protected function configuredString(string $key): string
{
return trim((string) config($key, ''));
}
protected function timeout(): int
{
return max(3, min(30, (int) config('dingtalk.timeout', 10)));
}
protected function nullableString(mixed $value): ?string
{
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}