feat: integrate ZYT admin with IAM Hub

This commit is contained in:
2026-09-08 13:26:41 +08:00
parent cf3fbdc5ef
commit a5f4c4472d
856 changed files with 3585 additions and 2371 deletions
@@ -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();
}
}
}
+10 -3
View File
@@ -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;
}
}