211 lines
7.6 KiB
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;
|
|
}
|
|
}
|