140 lines
6.6 KiB
PHP
140 lines
6.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use app\common\service\iam\IamAdminIdentityService;
|
|
use app\common\service\iam\IamHttpClient;
|
|
use app\common\service\iam\IamHubClient;
|
|
use app\common\service\iam\IamHubException;
|
|
use app\common\service\iam\IamJwtVerifier;
|
|
use app\common\service\iam\IamOidcService;
|
|
use app\common\service\iam\IamRevocationService;
|
|
use app\common\service\iam\IamSecurity;
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
function iamExpect(bool $condition, string $message): void
|
|
{
|
|
if (!$condition) {
|
|
throw new RuntimeException($message);
|
|
}
|
|
}
|
|
|
|
function iamJwt(array $header, array $payload, OpenSSLAsymmetricKey $privateKey): string
|
|
{
|
|
$head = IamSecurity::base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
|
$body = IamSecurity::base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
|
openssl_sign($head . '.' . $body, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
|
return $head . '.' . $body . '.' . IamSecurity::base64UrlEncode($signature);
|
|
}
|
|
|
|
$secret = 'test-secret';
|
|
$eventBody = '{"id":"event-1"}';
|
|
$signature = IamSecurity::signatureFor($secret, $eventBody);
|
|
iamExpect(IamSecurity::verifySignature($secret, $eventBody, $signature), 'exact webhook HMAC must pass');
|
|
iamExpect(!IamSecurity::verifySignature($secret, $eventBody . ' ', $signature), 'mutated webhook body must fail');
|
|
try {
|
|
(new IamRevocationService([
|
|
'enabled' => true,
|
|
'application_id' => 'zyt',
|
|
'revocation_secret' => $secret,
|
|
]))->apply($eventBody, 'sha256=invalid', 'event-1');
|
|
throw new RuntimeException('invalid webhook signature must be rejected');
|
|
} catch (IamHubException $error) {
|
|
iamExpect($error->httpStatus() === 401, 'invalid webhook signature must return HTTP 401');
|
|
}
|
|
|
|
$privateKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
|
iamExpect($privateKey instanceof OpenSSLAsymmetricKey, 'test RSA key must be created');
|
|
$details = openssl_pkey_get_details($privateKey);
|
|
iamExpect(is_array($details) && isset($details['rsa']['n'], $details['rsa']['e']), 'test RSA public key must be readable');
|
|
$accessToken = 'access-token';
|
|
$nonce = 'nonce-value';
|
|
$issuer = 'https://iam.example.com/realms/iam-hub';
|
|
$clientId = 'zyt';
|
|
$payload = [
|
|
'iss' => $issuer,
|
|
'aud' => $clientId,
|
|
'sub' => 'employee-subject',
|
|
'iat' => time() - 1,
|
|
'exp' => time() + 300,
|
|
'nonce' => $nonce,
|
|
'at_hash' => IamSecurity::base64UrlEncode(substr(hash('sha256', $accessToken, true), 0, 16)),
|
|
];
|
|
$jwt = iamJwt(['alg' => 'RS256', 'kid' => 'test-key'], $payload, $privateKey);
|
|
$jwks = ['keys' => [[
|
|
'kty' => 'RSA',
|
|
'kid' => 'test-key',
|
|
'n' => IamSecurity::base64UrlEncode($details['rsa']['n']),
|
|
'e' => IamSecurity::base64UrlEncode($details['rsa']['e']),
|
|
]]];
|
|
$claims = (new IamJwtVerifier($issuer, $clientId))->verify($jwt, $accessToken, $nonce, $jwks);
|
|
iamExpect($claims['sub'] === 'employee-subject', 'signed OIDC subject must be returned');
|
|
try {
|
|
(new IamJwtVerifier($issuer, $clientId))->verify($jwt, $accessToken, 'wrong-nonce', $jwks);
|
|
throw new RuntimeException('wrong nonce must be rejected');
|
|
} catch (IamHubException $error) {
|
|
iamExpect(str_contains($error->getMessage(), 'nonce'), 'wrong nonce rejection must be explicit');
|
|
}
|
|
|
|
$oidc = new IamOidcService([
|
|
'oidc_issuer' => $issuer,
|
|
'oidc_client_id' => $clientId,
|
|
'oidc_redirect_uri' => 'https://zyt.example.com/adminapi/login/iamCallback',
|
|
]);
|
|
$authorizationUrl = $oidc->authorizationUrl('state-value', $nonce, 'challenge-value');
|
|
iamExpect(str_contains($authorizationUrl, 'code_challenge_method=S256'), 'authorization must require PKCE S256');
|
|
iamExpect(str_contains($authorizationUrl, 'nonce=nonce-value'), 'authorization must carry nonce');
|
|
|
|
$requests = [];
|
|
$http = new IamHttpClient(function (string $method, string $url, array $headers, ?string $body) use (&$requests): array {
|
|
$requests[] = compact('method', 'url', 'headers', 'body');
|
|
if ($method === 'GET') {
|
|
return ['status' => 200, 'body' => ['employee' => ['oidcSubject' => 'employee-subject']]];
|
|
}
|
|
return ['status' => 201, 'body' => ['id' => 'binding-1']];
|
|
});
|
|
$hub = new IamHubClient([
|
|
'base_url' => 'http://iam-hub:8080',
|
|
'application_id' => 'zyt',
|
|
'application_token' => 'application-token',
|
|
], $http);
|
|
$hub->provisioningContext('employee-subject');
|
|
$hub->bindAccount('employee-subject', 42);
|
|
iamExpect(count($requests) === 2, 'provisioning and binding must both call IAM Hub');
|
|
iamExpect(str_contains($requests[0]['url'], '/applications/zyt/employees/employee-subject/provisioning-context'), 'provisioning path must be application scoped');
|
|
iamExpect(str_contains(implode('\n', $requests[1]['headers']), 'Idempotency-Key:'), 'binding must be idempotent');
|
|
iamExpect(
|
|
IamAdminIdentityService::stableAccount('employee-subject') === IamAdminIdentityService::stableAccount('employee-subject'),
|
|
'lazy account mapping must be stable'
|
|
);
|
|
|
|
$root = dirname(__DIR__, 2);
|
|
$sources = [
|
|
'controller' => $root . '/server/app/adminapi/controller/LoginController.php',
|
|
'revocation' => $root . '/server/app/adminapi/controller/IamController.php',
|
|
'validation' => $root . '/server/app/adminapi/validate/LoginValidate.php',
|
|
'migration' => $root . '/server/sql/1.9.20260908/add_iam_hub_admin_identity.sql',
|
|
'frontend' => $root . '/admin/src/views/account/login.vue',
|
|
'store' => $root . '/admin/src/stores/modules/user.ts',
|
|
];
|
|
foreach ($sources as $name => $path) {
|
|
$source = file_get_contents($path);
|
|
iamExpect(is_string($source), "{$name} source must be readable");
|
|
$sources[$name] = $source;
|
|
}
|
|
foreach (['iamStart', 'iamCallback', 'iamExchange', '#iam_code='] as $needle) {
|
|
iamExpect(str_contains($sources['controller'], $needle), "login controller must contain {$needle}");
|
|
}
|
|
iamExpect(str_contains($sources['revocation'], "response('', 204)"), 'successful revocation must return HTTP 204');
|
|
iamExpect(str_contains($sources['validation'], '请使用公司员工统一登录'), 'IAM-managed local password login must be disabled');
|
|
foreach (['iam_subject', 'iam_managed', 'iam_revoked_at', 'uk_iam_revocation_event_id'] as $needle) {
|
|
iamExpect(str_contains($sources['migration'], $needle), "migration must contain {$needle}");
|
|
}
|
|
foreach (['公司员工统一登录', 'window.location.hash', 'iam_code', 'iam_error'] as $needle) {
|
|
iamExpect(str_contains($sources['frontend'], $needle), "frontend callback must contain {$needle}");
|
|
}
|
|
iamExpect(str_contains($sources['store'], 'iamHubExchange'), 'frontend store must consume the one-time exchange code');
|
|
|
|
echo "IAM_HUB_ZYT_INTEGRATION_TEST=PASS\n";
|