feat: integrate ZYT admin with IAM Hub
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller;
|
||||
|
||||
use app\common\service\iam\IamHubException;
|
||||
use app\common\service\iam\IamRevocationService;
|
||||
use think\facade\Log;
|
||||
|
||||
class IamController extends BaseAdminController
|
||||
{
|
||||
public array $notNeedLogin = ['revocation'];
|
||||
|
||||
public function revocation()
|
||||
{
|
||||
try {
|
||||
(new IamRevocationService())->apply(
|
||||
(string) $this->request->getContent(),
|
||||
(string) $this->request->header('X-IAM-Signature', ''),
|
||||
(string) $this->request->header('X-IAM-Event-ID', '')
|
||||
);
|
||||
return response('', 204);
|
||||
} catch (IamHubException $error) {
|
||||
return json([
|
||||
'code' => 0,
|
||||
'show' => 0,
|
||||
'msg' => $error->getMessage(),
|
||||
'data' => [],
|
||||
], $error->httpStatus());
|
||||
} catch (\Throwable $error) {
|
||||
Log::error('IAM Hub revocation failed: ' . get_class($error));
|
||||
return json([
|
||||
'code' => 0,
|
||||
'show' => 0,
|
||||
'msg' => 'IAM Hub revocation failed',
|
||||
'data' => [],
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ namespace app\adminapi\controller;
|
||||
|
||||
use app\adminapi\logic\LoginLogic;
|
||||
use app\adminapi\validate\LoginValidate;
|
||||
use app\common\service\iam\IamAdminIdentityService;
|
||||
use app\common\service\iam\IamExchangeService;
|
||||
use app\common\service\iam\IamHubException;
|
||||
use app\common\service\iam\IamOidcService;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 管理员登录控制器
|
||||
@@ -24,7 +29,57 @@ use app\adminapi\validate\LoginValidate;
|
||||
*/
|
||||
class LoginController extends BaseAdminController
|
||||
{
|
||||
public array $notNeedLogin = ['account', 'workWechatConfig', 'workWechatLogin', 'checkDbColumn', 'changeFirstPassword'];
|
||||
public array $notNeedLogin = [
|
||||
'account', 'workWechatConfig', 'workWechatLogin', 'checkDbColumn', 'changeFirstPassword',
|
||||
'iamConfig', 'iamStart', 'iamCallback', 'iamExchange',
|
||||
];
|
||||
|
||||
public function iamConfig()
|
||||
{
|
||||
return $this->data([
|
||||
'enabled' => (new IamOidcService())->enabled(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function iamStart()
|
||||
{
|
||||
try {
|
||||
return redirect((new IamOidcService())->begin());
|
||||
} catch (IamHubException $error) {
|
||||
return json(['code' => 0, 'show' => 1, 'msg' => $error->getMessage(), 'data' => []], $error->httpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
public function iamCallback()
|
||||
{
|
||||
$successUrl = (string) config('iam_hub.login_success_url', '/admin/login');
|
||||
try {
|
||||
$code = trim((string) $this->request->get('code', ''));
|
||||
$state = trim((string) $this->request->get('state', ''));
|
||||
if ($code === '' || $state === '') {
|
||||
throw new IamHubException('统一身份登录回调参数缺失');
|
||||
}
|
||||
$claims = (new IamOidcService())->callback($code, $state);
|
||||
$login = (new IamAdminIdentityService())->login($claims, 1);
|
||||
$exchange = new IamExchangeService((int) config('iam_hub.exchange_ttl_seconds', 60));
|
||||
$exchangeCode = $exchange->store($login);
|
||||
return redirect($successUrl . '#iam_code=' . rawurlencode($exchangeCode));
|
||||
} catch (\Throwable $error) {
|
||||
Log::error('IAM Hub admin login callback failed: ' . get_class($error));
|
||||
return redirect($successUrl . '#iam_error=1');
|
||||
}
|
||||
}
|
||||
|
||||
public function iamExchange()
|
||||
{
|
||||
$code = trim((string) $this->request->post('code', ''));
|
||||
$exchange = new IamExchangeService((int) config('iam_hub.exchange_ttl_seconds', 60));
|
||||
$login = $exchange->consume($code);
|
||||
if ($login === null) {
|
||||
return $this->fail('统一身份登录凭证无效或已过期');
|
||||
}
|
||||
return $this->data($login);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 账号登录
|
||||
@@ -161,4 +216,4 @@ class LoginController extends BaseAdminController
|
||||
(new LoginLogic())->logout($this->adminInfo);
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ class LoginValidate extends BaseValidate
|
||||
// 任一不满足都走原始密码校验。每次免密登录都会记一条 warning 日志,便于发现误开。
|
||||
if (self::isLocalDebugLoginBypassEnabled()) {
|
||||
$adminInfo = Admin::where('account', '=', $data['account'])
|
||||
->field(['password,disable'])
|
||||
->field(['password,disable,iam_managed'])
|
||||
->findOrEmpty();
|
||||
if ($adminInfo->isEmpty()) {
|
||||
return '账号不存在';
|
||||
@@ -68,6 +68,9 @@ class LoginValidate extends BaseValidate
|
||||
if ((int)($adminInfo['disable'] ?? 0) === 1) {
|
||||
return '账号已禁用';
|
||||
}
|
||||
if ((int) ($adminInfo['iam_managed'] ?? 0) === 1) {
|
||||
return '请使用公司员工统一登录';
|
||||
}
|
||||
Log::warning(sprintf(
|
||||
'[LOCAL_DEBUG] login bypass password: account=%s, ip=%s',
|
||||
(string)($data['account'] ?? ''),
|
||||
@@ -96,7 +99,7 @@ class LoginValidate extends BaseValidate
|
||||
}
|
||||
|
||||
$adminInfo = Admin::where('account', '=', $data['account'])
|
||||
->field(['password,disable'])
|
||||
->field(['password,disable,iam_managed'])
|
||||
->findOrEmpty();
|
||||
|
||||
if ($adminInfo->isEmpty()) {
|
||||
@@ -107,6 +110,10 @@ class LoginValidate extends BaseValidate
|
||||
return '账号已禁用';
|
||||
}
|
||||
|
||||
if ((int) ($adminInfo['iam_managed'] ?? 0) === 1) {
|
||||
return '请使用公司员工统一登录';
|
||||
}
|
||||
|
||||
if (empty($adminInfo['password'])) {
|
||||
$adminAccountSafeCache->record();
|
||||
return '账号不存在';
|
||||
@@ -143,4 +150,4 @@ class LoginValidate extends BaseValidate
|
||||
return in_array($flag, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\iam;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
class IamRevocationEvent extends BaseModel
|
||||
{
|
||||
protected $json = ['payload'];
|
||||
protected $jsonAssoc = true;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\iam;
|
||||
|
||||
use app\adminapi\logic\LoginLogic;
|
||||
use app\adminapi\service\AdminTokenService;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\service\FileService;
|
||||
use RuntimeException;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
|
||||
class IamAdminIdentityService
|
||||
{
|
||||
private array $config;
|
||||
private ?IamHubClient $client;
|
||||
|
||||
public function __construct(?array $config = null, ?IamHubClient $client = null)
|
||||
{
|
||||
$this->config = $config ?? (array) config('iam_hub');
|
||||
$this->client = $client;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function login(array $claims, int $terminal): array
|
||||
{
|
||||
$subject = trim((string) ($claims['sub'] ?? ''));
|
||||
if ($subject === '') {
|
||||
throw new RuntimeException('IAM subject is missing');
|
||||
}
|
||||
$client = $this->client ?? new IamHubClient($this->config);
|
||||
$context = $client->provisioningContext($subject);
|
||||
$employee = $context['employee'] ?? null;
|
||||
if (!is_array($employee) || !hash_equals($subject, (string) ($employee['oidcSubject'] ?? ''))) {
|
||||
throw new RuntimeException('IAM provisioning context does not match the login identity');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$admin = Admin::where('iam_subject', '=', $subject)->find();
|
||||
$externalId = trim((string) ($context['externalAccountId'] ?? ''));
|
||||
if (!$admin && ctype_digit($externalId) && (int) $externalId > 0) {
|
||||
$admin = Admin::find((int) $externalId);
|
||||
if ($admin && trim((string) ($admin->iam_subject ?? '')) !== ''
|
||||
&& !hash_equals($subject, (string) $admin->iam_subject)) {
|
||||
throw new RuntimeException('本地管理员账号已绑定其他统一身份');
|
||||
}
|
||||
}
|
||||
|
||||
if (!$admin) {
|
||||
$account = self::stableAccount($subject);
|
||||
$collision = Admin::where('account', '=', $account)->find();
|
||||
if ($collision) {
|
||||
throw new RuntimeException('统一身份账号映射冲突');
|
||||
}
|
||||
$admin = Admin::create([
|
||||
'account' => $account,
|
||||
'name' => (string) ($employee['displayName'] ?? $claims['name'] ?? $claims['preferred_username'] ?? $account),
|
||||
'password' => '',
|
||||
'avatar' => Config::get('project.default_image.admin_avatar'),
|
||||
'root' => 0,
|
||||
'disable' => 0,
|
||||
'multipoint_login' => 1,
|
||||
'is_paw' => 1,
|
||||
'iam_subject' => $subject,
|
||||
'iam_managed' => 1,
|
||||
'iam_revoked_at' => 0,
|
||||
]);
|
||||
foreach ($this->defaultRoleIds() as $roleId) {
|
||||
AdminRole::create(['admin_id' => $admin->id, 'role_id' => $roleId]);
|
||||
}
|
||||
} else {
|
||||
if ((int) ($admin->disable ?? 0) === 1 && (int) ($admin->iam_revoked_at ?? 0) === 0) {
|
||||
throw new RuntimeException('本地管理员账号已被平台禁用');
|
||||
}
|
||||
$admin->iam_subject = $subject;
|
||||
$admin->iam_managed = 1;
|
||||
if ((int) ($admin->iam_revoked_at ?? 0) > 0) {
|
||||
$admin->disable = 0;
|
||||
$admin->iam_revoked_at = 0;
|
||||
}
|
||||
$admin->save();
|
||||
}
|
||||
$admin->login_time = time();
|
||||
$admin->login_ip = request()->ip();
|
||||
$admin->save();
|
||||
Db::commit();
|
||||
} catch (\Throwable $error) {
|
||||
Db::rollback();
|
||||
throw $error;
|
||||
}
|
||||
|
||||
$client->bindAccount($subject, (int) $admin->id);
|
||||
$adminInfo = AdminTokenService::setToken($admin->id, $terminal, $admin->multipoint_login);
|
||||
if (!is_array($adminInfo) || empty($adminInfo['token'])) {
|
||||
throw new RuntimeException('本地管理员会话创建失败');
|
||||
}
|
||||
$avatar = $admin->avatar ?: Config::get('project.default_image.admin_avatar');
|
||||
return [
|
||||
'name' => $adminInfo['name'],
|
||||
'avatar' => FileService::getFileUrl($avatar),
|
||||
'role_name' => $adminInfo['role_name'],
|
||||
'token' => $adminInfo['token'],
|
||||
'is_paw' => 1,
|
||||
'need_bind_work_wechat' => LoginLogic::adminMustBindWorkWechat([
|
||||
'root' => (int) ($admin->root ?? 0),
|
||||
'work_wechat_userid' => (string) ($admin->work_wechat_userid ?? ''),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public static function stableAccount(string $subject): string
|
||||
{
|
||||
return 'iam_' . substr(hash('sha256', $subject), 0, 20);
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function defaultRoleIds(): array
|
||||
{
|
||||
$values = explode(',', (string) ($this->config['default_role_ids'] ?? ''));
|
||||
$roles = array_values(array_unique(array_filter(
|
||||
array_map('intval', $values),
|
||||
static fn (int $value): bool => $value > 0
|
||||
)));
|
||||
sort($roles, SORT_NUMERIC);
|
||||
return $roles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\iam;
|
||||
|
||||
use think\facade\Cache;
|
||||
|
||||
class IamExchangeService
|
||||
{
|
||||
private int $ttlSeconds;
|
||||
|
||||
public function __construct(int $ttlSeconds = 60)
|
||||
{
|
||||
$this->ttlSeconds = $ttlSeconds;
|
||||
}
|
||||
|
||||
public function store(array $loginResult): string
|
||||
{
|
||||
$code = IamSecurity::base64UrlEncode(random_bytes(32));
|
||||
Cache::set($this->key($code), $loginResult, max(30, $this->ttlSeconds));
|
||||
return $code;
|
||||
}
|
||||
|
||||
public function consume(string $code): ?array
|
||||
{
|
||||
if (strlen($code) < 20 || strlen($code) > 200) {
|
||||
return null;
|
||||
}
|
||||
$value = Cache::pull($this->key($code));
|
||||
return is_array($value) ? $value : null;
|
||||
}
|
||||
|
||||
private function key(string $code): string
|
||||
{
|
||||
return 'iam_login_exchange_' . hash('sha256', $code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\iam;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class IamHttpClient
|
||||
{
|
||||
/** @var callable|null */
|
||||
private $transport;
|
||||
private int $timeoutSeconds;
|
||||
|
||||
public function __construct(?callable $transport = null, int $timeoutSeconds = 10)
|
||||
{
|
||||
$this->transport = $transport;
|
||||
$this->timeoutSeconds = $timeoutSeconds;
|
||||
}
|
||||
|
||||
/** @return array{status:int,body:array<string,mixed>} */
|
||||
public function json(string $method, string $url, array $headers = [], ?array $json = null): array
|
||||
{
|
||||
$headers[] = 'Accept: application/json';
|
||||
$body = null;
|
||||
if ($json !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
$body = json_encode($json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
return $this->send($method, $url, $headers, $body);
|
||||
}
|
||||
|
||||
/** @return array{status:int,body:array<string,mixed>} */
|
||||
public function form(string $url, array $form): array
|
||||
{
|
||||
return $this->send(
|
||||
'POST',
|
||||
$url,
|
||||
['Accept: application/json', 'Content-Type: application/x-www-form-urlencoded'],
|
||||
http_build_query($form, '', '&', PHP_QUERY_RFC3986)
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{status:int,body:array<string,mixed>} */
|
||||
private function send(string $method, string $url, array $headers, ?string $body): array
|
||||
{
|
||||
if ($this->transport !== null) {
|
||||
$result = ($this->transport)($method, $url, $headers, $body);
|
||||
if (!is_array($result) || !isset($result['status'], $result['body'])) {
|
||||
throw new RuntimeException('IAM HTTP transport returned an invalid response');
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('Unable to initialize IAM HTTP request');
|
||||
}
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT => min(5, $this->timeoutSeconds),
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$raw = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('IAM HTTP request failed: ' . $error);
|
||||
}
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
return [
|
||||
'status' => $status,
|
||||
'body' => is_array($decoded) ? $decoded : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\iam;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class IamHubClient
|
||||
{
|
||||
private array $config;
|
||||
private IamHttpClient $http;
|
||||
|
||||
public function __construct(?array $config = null, ?IamHttpClient $http = null)
|
||||
{
|
||||
$this->config = $config ?? (array) config('iam_hub');
|
||||
$this->http = $http ?? new IamHttpClient(null, (int) ($this->config['http_timeout_seconds'] ?? 10));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function provisioningContext(string $subject): array
|
||||
{
|
||||
return $this->request(
|
||||
'GET',
|
||||
sprintf(
|
||||
'%s/internal/v1/applications/%s/employees/%s/provisioning-context',
|
||||
$this->baseUrl(),
|
||||
rawurlencode($this->applicationId()),
|
||||
rawurlencode($subject)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function bindAccount(string $subject, int $adminId): void
|
||||
{
|
||||
$this->request(
|
||||
'POST',
|
||||
sprintf(
|
||||
'%s/internal/v1/applications/%s/bindings',
|
||||
$this->baseUrl(),
|
||||
rawurlencode($this->applicationId())
|
||||
),
|
||||
[
|
||||
'employeeSubject' => $subject,
|
||||
'externalAccountId' => (string) $adminId,
|
||||
'verificationMethod' => 'oidc-lazy-provision',
|
||||
],
|
||||
$this->bindingHeaders($subject, $adminId)
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function request(string $method, string $url, ?array $body = null, array $extraHeaders = []): array
|
||||
{
|
||||
$token = trim((string) ($this->config['application_token'] ?? ''));
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('IAM Hub application credential is not configured');
|
||||
}
|
||||
$result = $this->http->json(
|
||||
$method,
|
||||
$url,
|
||||
array_merge(['Authorization: Bearer ' . $token], $extraHeaders),
|
||||
$body
|
||||
);
|
||||
if ($result['status'] < 200 || $result['status'] >= 300) {
|
||||
$message = (string) ($result['body']['error'] ?? $result['body']['message'] ?? 'IAM Hub request failed');
|
||||
throw new RuntimeException($message . ' (HTTP ' . $result['status'] . ')');
|
||||
}
|
||||
return $result['body'];
|
||||
}
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
return rtrim((string) ($this->config['base_url'] ?? ''), '/');
|
||||
}
|
||||
|
||||
private function applicationId(): string
|
||||
{
|
||||
return (string) ($this->config['application_id'] ?? 'zyt');
|
||||
}
|
||||
|
||||
private function bindingHeaders(string $subject, int $adminId): array
|
||||
{
|
||||
$requestId = hash('sha256', $this->applicationId() . ':' . $subject . ':' . $adminId);
|
||||
return ['X-Request-ID: ' . $requestId, 'Idempotency-Key: ' . $requestId];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\iam;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class IamHubException extends RuntimeException
|
||||
{
|
||||
private int $httpStatus;
|
||||
|
||||
public function __construct(string $message, int $httpStatus = 400)
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->httpStatus = $httpStatus;
|
||||
}
|
||||
|
||||
public function httpStatus(): int
|
||||
{
|
||||
return $this->httpStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\iam;
|
||||
|
||||
class IamJwtVerifier
|
||||
{
|
||||
private string $issuer;
|
||||
private string $clientId;
|
||||
private int $clockSkewSeconds;
|
||||
|
||||
public function __construct(
|
||||
string $issuer,
|
||||
string $clientId,
|
||||
int $clockSkewSeconds = 30
|
||||
) {
|
||||
$this->issuer = $issuer;
|
||||
$this->clientId = $clientId;
|
||||
$this->clockSkewSeconds = $clockSkewSeconds;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function verify(string $jwt, string $accessToken, string $nonce, array $jwks): array
|
||||
{
|
||||
$parts = explode('.', $jwt);
|
||||
if (count($parts) !== 3) {
|
||||
throw new IamHubException('Invalid ID token');
|
||||
}
|
||||
[$encodedHeader, $encodedPayload, $encodedSignature] = $parts;
|
||||
$header = $this->decodeJson($encodedHeader);
|
||||
$claims = $this->decodeJson($encodedPayload);
|
||||
if (($header['alg'] ?? '') !== 'RS256' || trim((string) ($header['kid'] ?? '')) === '') {
|
||||
throw new IamHubException('Unsupported ID token algorithm');
|
||||
}
|
||||
$key = $this->matchingKey((string) $header['kid'], $jwks);
|
||||
$verified = openssl_verify(
|
||||
$encodedHeader . '.' . $encodedPayload,
|
||||
IamSecurity::base64UrlDecode($encodedSignature),
|
||||
$this->jwkToPem($key),
|
||||
OPENSSL_ALGO_SHA256
|
||||
);
|
||||
if ($verified !== 1) {
|
||||
throw new IamHubException('Invalid ID token signature');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
if (!hash_equals($this->issuer, (string) ($claims['iss'] ?? ''))) {
|
||||
throw new IamHubException('Invalid ID token issuer');
|
||||
}
|
||||
$audience = $claims['aud'] ?? [];
|
||||
$audiences = is_array($audience) ? $audience : [$audience];
|
||||
if (!in_array($this->clientId, $audiences, true)) {
|
||||
throw new IamHubException('Invalid ID token audience');
|
||||
}
|
||||
if ((int) ($claims['exp'] ?? 0) < $now - $this->clockSkewSeconds) {
|
||||
throw new IamHubException('Expired ID token');
|
||||
}
|
||||
if ((int) ($claims['iat'] ?? 0) > $now + $this->clockSkewSeconds) {
|
||||
throw new IamHubException('Invalid ID token issue time');
|
||||
}
|
||||
if ($nonce === '' || !hash_equals($nonce, (string) ($claims['nonce'] ?? ''))) {
|
||||
throw new IamHubException('Invalid ID token nonce');
|
||||
}
|
||||
if (trim((string) ($claims['sub'] ?? '')) === '') {
|
||||
throw new IamHubException('ID token subject is missing');
|
||||
}
|
||||
if (isset($claims['at_hash'])) {
|
||||
$expected = IamSecurity::base64UrlEncode(substr(hash('sha256', $accessToken, true), 0, 16));
|
||||
if (!hash_equals($expected, (string) $claims['at_hash'])) {
|
||||
throw new IamHubException('Invalid access token hash');
|
||||
}
|
||||
}
|
||||
return $claims;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function decodeJson(string $value): array
|
||||
{
|
||||
$decoded = json_decode(IamSecurity::base64UrlDecode($value), true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new IamHubException('Invalid ID token JSON');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function matchingKey(string $kid, array $jwks): array
|
||||
{
|
||||
foreach (($jwks['keys'] ?? []) as $key) {
|
||||
if (is_array($key) && ($key['kid'] ?? '') === $kid && ($key['kty'] ?? '') === 'RSA') {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
throw new IamHubException('ID token signing key was not found');
|
||||
}
|
||||
|
||||
private function jwkToPem(array $key): string
|
||||
{
|
||||
$modulus = IamSecurity::base64UrlDecode((string) ($key['n'] ?? ''));
|
||||
$exponent = IamSecurity::base64UrlDecode((string) ($key['e'] ?? ''));
|
||||
if ($modulus === '' || $exponent === '') {
|
||||
throw new IamHubException('Invalid RSA signing key');
|
||||
}
|
||||
$rsaKey = $this->asn1Sequence($this->asn1Integer($modulus) . $this->asn1Integer($exponent));
|
||||
$algorithm = hex2bin('300d06092a864886f70d0101010500');
|
||||
$publicKey = $this->asn1Sequence($algorithm . "\x03" . $this->asn1Length(strlen($rsaKey) + 1) . "\x00" . $rsaKey);
|
||||
return "-----BEGIN PUBLIC KEY-----\n"
|
||||
. chunk_split(base64_encode($publicKey), 64, "\n")
|
||||
. "-----END PUBLIC KEY-----\n";
|
||||
}
|
||||
|
||||
private function asn1Integer(string $value): string
|
||||
{
|
||||
$value = ltrim($value, "\x00") ?: "\x00";
|
||||
if ((ord($value[0]) & 0x80) !== 0) {
|
||||
$value = "\x00" . $value;
|
||||
}
|
||||
return "\x02" . $this->asn1Length(strlen($value)) . $value;
|
||||
}
|
||||
|
||||
private function asn1Sequence(string $value): string
|
||||
{
|
||||
return "\x30" . $this->asn1Length(strlen($value)) . $value;
|
||||
}
|
||||
|
||||
private function asn1Length(int $length): string
|
||||
{
|
||||
if ($length < 128) {
|
||||
return chr($length);
|
||||
}
|
||||
$bytes = ltrim(pack('N', $length), "\x00");
|
||||
return chr(0x80 | strlen($bytes)) . $bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\iam;
|
||||
|
||||
use RuntimeException;
|
||||
use think\facade\Cache;
|
||||
|
||||
class IamOidcService
|
||||
{
|
||||
private array $config;
|
||||
private IamHttpClient $http;
|
||||
|
||||
public function __construct(?array $config = null, ?IamHttpClient $http = null)
|
||||
{
|
||||
$this->config = $config ?? (array) config('iam_hub');
|
||||
$this->http = $http ?? new IamHttpClient(null, (int) ($this->config['http_timeout_seconds'] ?? 10));
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) ($this->config['enabled'] ?? false)
|
||||
&& trim((string) ($this->config['application_token'] ?? '')) !== ''
|
||||
&& trim((string) ($this->config['oidc_client_secret'] ?? '')) !== ''
|
||||
&& trim((string) ($this->config['oidc_redirect_uri'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
public function begin(): string
|
||||
{
|
||||
$this->assertEnabled();
|
||||
$state = IamSecurity::base64UrlEncode(random_bytes(32));
|
||||
$nonce = IamSecurity::base64UrlEncode(random_bytes(32));
|
||||
$verifier = IamSecurity::base64UrlEncode(random_bytes(64));
|
||||
$challenge = IamSecurity::base64UrlEncode(hash('sha256', $verifier, true));
|
||||
Cache::set($this->stateKey($state), [
|
||||
'nonce' => $nonce,
|
||||
'verifier' => $verifier,
|
||||
], (int) ($this->config['state_ttl_seconds'] ?? 300));
|
||||
|
||||
return $this->authorizationUrl($state, $nonce, $challenge);
|
||||
}
|
||||
|
||||
public function authorizationUrl(string $state, string $nonce, string $challenge): string
|
||||
{
|
||||
return rtrim((string) ($this->config['oidc_issuer'] ?? ''), '/')
|
||||
. '/protocol/openid-connect/auth?'
|
||||
. http_build_query([
|
||||
'client_id' => (string) ($this->config['oidc_client_id'] ?? ''),
|
||||
'redirect_uri' => (string) ($this->config['oidc_redirect_uri'] ?? ''),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'nonce' => $nonce,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function callback(string $code, string $state): array
|
||||
{
|
||||
$this->assertEnabled();
|
||||
$stateData = Cache::pull($this->stateKey($state));
|
||||
if (!is_array($stateData) || empty($stateData['nonce']) || empty($stateData['verifier'])) {
|
||||
throw new IamHubException('统一身份登录状态无效或已过期');
|
||||
}
|
||||
|
||||
$tokenResult = $this->http->form($this->internalBaseUrl() . '/protocol/openid-connect/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => (string) ($this->config['oidc_client_id'] ?? ''),
|
||||
'client_secret' => (string) ($this->config['oidc_client_secret'] ?? ''),
|
||||
'redirect_uri' => (string) ($this->config['oidc_redirect_uri'] ?? ''),
|
||||
'code' => $code,
|
||||
'code_verifier' => (string) $stateData['verifier'],
|
||||
]);
|
||||
if ($tokenResult['status'] !== 200) {
|
||||
throw new IamHubException('统一身份授权码交换失败', 502);
|
||||
}
|
||||
$idToken = (string) ($tokenResult['body']['id_token'] ?? '');
|
||||
$accessToken = (string) ($tokenResult['body']['access_token'] ?? '');
|
||||
if ($idToken === '' || $accessToken === '') {
|
||||
throw new IamHubException('统一身份令牌响应不完整', 502);
|
||||
}
|
||||
$jwksResult = $this->http->json('GET', $this->internalBaseUrl() . '/protocol/openid-connect/certs');
|
||||
if ($jwksResult['status'] !== 200) {
|
||||
throw new IamHubException('统一身份签名密钥获取失败', 502);
|
||||
}
|
||||
|
||||
return (new IamJwtVerifier(
|
||||
(string) ($this->config['oidc_issuer'] ?? ''),
|
||||
(string) ($this->config['oidc_client_id'] ?? '')
|
||||
))->verify($idToken, $accessToken, (string) $stateData['nonce'], $jwksResult['body']);
|
||||
}
|
||||
|
||||
private function assertEnabled(): void
|
||||
{
|
||||
if (!$this->enabled()) {
|
||||
throw new IamHubException('统一身份登录尚未配置', 503);
|
||||
}
|
||||
}
|
||||
|
||||
private function internalBaseUrl(): string
|
||||
{
|
||||
$internal = trim((string) ($this->config['oidc_internal_base_url'] ?? ''));
|
||||
return rtrim($internal !== '' ? $internal : (string) ($this->config['oidc_issuer'] ?? ''), '/');
|
||||
}
|
||||
|
||||
private function stateKey(string $state): string
|
||||
{
|
||||
return 'iam_oidc_state_' . hash('sha256', $state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\iam;
|
||||
|
||||
use app\adminapi\logic\auth\AdminLogic;
|
||||
use app\common\cache\AdminAuthCache;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminSession;
|
||||
use app\common\model\iam\IamRevocationEvent;
|
||||
use think\facade\Db;
|
||||
|
||||
class IamRevocationService
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$this->config = $config ?? (array) config('iam_hub');
|
||||
}
|
||||
|
||||
public function apply(string $body, string $signature, string $eventHeader): void
|
||||
{
|
||||
if (!(bool) ($this->config['enabled'] ?? false)) {
|
||||
throw new IamHubException('IAM Hub integration is disabled', 503);
|
||||
}
|
||||
$secret = (string) ($this->config['revocation_secret'] ?? '');
|
||||
if (!IamSecurity::verifySignature($secret, $body, $signature)) {
|
||||
throw new IamHubException('invalid IAM Hub signature', 401);
|
||||
}
|
||||
$event = json_decode($body, true);
|
||||
if (!is_array($event)) {
|
||||
throw new IamHubException('invalid IAM Hub event JSON');
|
||||
}
|
||||
$eventId = trim((string) ($event['id'] ?? ''));
|
||||
$subject = trim((string) ($event['oidcSubject'] ?? ''));
|
||||
$applications = $event['applicationIds'] ?? [];
|
||||
if ($eventId === '' || $subject === '' || !is_array($applications)) {
|
||||
throw new IamHubException('invalid IAM Hub event');
|
||||
}
|
||||
if (!hash_equals($eventId, trim($eventHeader))) {
|
||||
throw new IamHubException('IAM Hub event ID header mismatch');
|
||||
}
|
||||
if (!in_array((string) ($this->config['application_id'] ?? 'zyt'), $applications, true)) {
|
||||
throw new IamHubException('IAM Hub event is not addressed to this application', 403);
|
||||
}
|
||||
if (IamRevocationEvent::where('event_id', '=', $eventId)->find()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$admin = Admin::where('iam_subject', '=', $subject)->find();
|
||||
$status = 'admin_not_found';
|
||||
$adminId = 0;
|
||||
if ($admin) {
|
||||
$adminId = (int) $admin->id;
|
||||
$admin->disable = 1;
|
||||
$admin->iam_revoked_at = time();
|
||||
$admin->save();
|
||||
$sessions = AdminSession::where('admin_id', '=', $adminId)->select()->toArray();
|
||||
foreach ($sessions as $session) {
|
||||
AdminLogic::expireToken((string) $session['token']);
|
||||
}
|
||||
(new AdminAuthCache($adminId))->clearAuthCache();
|
||||
$status = 'applied';
|
||||
}
|
||||
IamRevocationEvent::create([
|
||||
'event_id' => $eventId,
|
||||
'iam_subject' => $subject,
|
||||
'admin_id' => $adminId,
|
||||
'status' => $status,
|
||||
'reason' => (string) ($event['reason'] ?? ''),
|
||||
'payload' => $event,
|
||||
]);
|
||||
Db::commit();
|
||||
} catch (\Throwable $error) {
|
||||
Db::rollback();
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\iam;
|
||||
|
||||
class IamSecurity
|
||||
{
|
||||
public static function signatureFor(string $secret, string $body): string
|
||||
{
|
||||
return 'sha256=' . hash_hmac('sha256', $body, $secret);
|
||||
}
|
||||
|
||||
public static function verifySignature(string $secret, string $body, string $provided): bool
|
||||
{
|
||||
return $secret !== '' && hash_equals(self::signatureFor($secret, $body), trim($provided));
|
||||
}
|
||||
|
||||
public static function base64UrlEncode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public static function base64UrlDecode(string $value): string
|
||||
{
|
||||
$padding = (4 - strlen($value) % 4) % 4;
|
||||
$decoded = base64_decode(strtr($value . str_repeat('=', $padding), '-_', '+/'), true);
|
||||
if ($decoded === false) {
|
||||
throw new IamHubException('Invalid base64url value');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user