105 lines
12 KiB
PHP
105 lines
12 KiB
PHP
<?php
|
|
/** Disposable SQLite only: no .env, provider calls or real identities. */
|
|
declare(strict_types=1);
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
require dirname(__DIR__) . '/app/common.php';
|
|
use think\facade\Db;
|
|
use app\adminapi\service\iam\v2\{IamV2Source, IamV2Flow, IamV2Client, IamV2Manager, IamV2Events};
|
|
function ok(bool $x, string $m): void { if (!$x) { throw new RuntimeException('FAIL ' . $m); } }
|
|
function denies(callable $fn, string $message): void { try { $fn(); } catch (RuntimeException $e) { ok($e->getMessage() === $message, $message . ' got ' . $e->getMessage()); return; } throw new RuntimeException('accepted ' . $message); }
|
|
$app = new think\App(dirname(__DIR__) . '/'); think\Container::setInstance($app);
|
|
$file = tempnam(sys_get_temp_dir(), 'zyt-iamv2-');
|
|
$app->config->set(['default'=>'fixture','connections'=>['fixture'=>['type'=>'sqlite','database'=>$file,'prefix'=>'','fields_strict'=>true]]], 'database');
|
|
$manager = new think\DbManager(); $manager->setConfig($app->config->get('database')); $app->instance('think\Db', $manager);
|
|
$app->config->set(['data_scope'=>['enabled'=>false], 'unique_identification'=>'fixture'], 'project');
|
|
$app->config->set(['application_id'=>'zyt'], 'iam');
|
|
try {
|
|
foreach ([
|
|
'CREATE TABLE admin (id INTEGER PRIMARY KEY,account TEXT,name TEXT,password TEXT,disable INTEGER,root INTEGER,delete_time INTEGER)',
|
|
'CREATE TABLE admin_session (id INTEGER PRIMARY KEY,admin_id INTEGER,terminal INTEGER,token TEXT,expire_time INTEGER,update_time INTEGER)',
|
|
'CREATE TABLE admin_role (admin_id INTEGER,role_id INTEGER)',
|
|
'CREATE TABLE system_role (id INTEGER PRIMARY KEY,name TEXT,delete_time INTEGER)',
|
|
'CREATE TABLE system_role_menu (role_id INTEGER,menu_id INTEGER)',
|
|
'CREATE TABLE system_menu (id INTEGER PRIMARY KEY,is_disable INTEGER,perms TEXT)',
|
|
'CREATE TABLE iam_v2_source_session (session_id TEXT PRIMARY KEY,admin_id INTEGER,business_session_id INTEGER,token_hash TEXT,account_stamp TEXT,auth_method TEXT,auth_time INTEGER,version INTEGER,revoked INTEGER,expires_at INTEGER)',
|
|
'CREATE TABLE iam_v2_proof (proof_hash TEXT PRIMARY KEY,purpose TEXT,session_id TEXT,payload TEXT,expires_at INTEGER,consumed INTEGER)',
|
|
'CREATE TABLE iam_v2_flow (flow_key TEXT PRIMARY KEY,kind TEXT,payload TEXT,expires_at INTEGER,consumed INTEGER)',
|
|
'CREATE TABLE iam_v2_binding (admin_id INTEGER PRIMARY KEY,employee_id TEXT,subject TEXT,state TEXT,version INTEGER,payload TEXT,checked_at INTEGER)',
|
|
'CREATE TABLE iam_v2_event (event_id TEXT PRIMARY KEY, digest TEXT, received_at INTEGER)',
|
|
'CREATE TABLE iam_v2_resource (resource_key TEXT PRIMARY KEY,kind TEXT,external_id TEXT,actor_id INTEGER,payload TEXT,created_at INTEGER)',
|
|
] as $sql) { Db::execute($sql); }
|
|
Db::name('admin')->insertAll([['id'=>1,'account'=>'operator','name'=>'Synthetic Operator','password'=>'not-real','disable'=>0,'root'=>1],['id'=>2,'account'=>'employee','name'=>'Synthetic Employee','password'=>'not-real','disable'=>0,'root'=>0]]);
|
|
Db::name('admin_session')->insertAll([['id'=>1,'admin_id'=>1,'terminal'=>1,'token'=>'fixture-token-1','expire_time'=>time()+3600],['id'=>2,'admin_id'=>2,'terminal'=>1,'token'=>'fixture-token-2','expire_time'=>time()+3600]]);
|
|
denies(fn()=>IamV2Source::current('fixture-token-1'),'REAUTH_REQUIRED');
|
|
$s1=IamV2Source::enroll('fixture-token-1','password',false); $s2=IamV2Source::enroll('fixture-token-1','password',false);
|
|
ok($s1['session_id']!==$s2['session_id'],'independent browser sessions despite same business token');
|
|
$_COOKIE[IamV2Source::COOKIE]=$s1['session_id'];
|
|
ok(IamV2Source::current('fixture-token-1')['auth_method']==='password','immutable method');
|
|
denies(fn()=>IamV2Source::current('fixture-token-2'),'ACCOUNT_MISMATCH');
|
|
denies(fn()=>IamV2Source::enroll('fixture-token-1','legacy',false),'REAUTH_REQUIRED');
|
|
IamV2Source::authorize($s1,'bind',[2]);
|
|
denies(fn()=>IamV2Source::authorize($s1,'bind',[1]),'PROTECTED_ACCOUNT_REVIEW_REQUIRED');
|
|
$proof=IamV2Source::proof($s1,'launch',[1],[],'tx-fixture');
|
|
ok(!str_contains(json_encode(Db::name('iam_v2_proof')->select()->toArray()),$proof),'proof plaintext never stored');
|
|
denies(fn()=>IamV2Source::introspect($proof,'management','tx-fixture'),'TRANSACTION_EXPIRED');
|
|
denies(fn()=>IamV2Source::introspect($proof,'launch','other'),'ACCOUNT_MISMATCH');
|
|
$claims=IamV2Source::introspect($proof,'launch','tx-fixture');
|
|
ok($claims['active'] && $claims['actorLocalId']==='1' && !isset($claims['token']),'service claims no business token');
|
|
denies(fn()=>IamV2Source::introspect($proof,'launch','tx-fixture'),'TRANSACTION_EXPIRED');
|
|
IamV2Flow::put('launch','intent',['sessionId'=>$s1['session_id']]);
|
|
denies(fn()=>IamV2Flow::take('launch','intent',$s2['session_id']),'ACCOUNT_MISMATCH');
|
|
IamV2Flow::take('launch','intent',$s1['session_id']); denies(fn()=>IamV2Flow::take('launch','intent',$s1['session_id']),'TRANSACTION_EXPIRED');
|
|
$expired=IamV2Source::proof($s1,'launch'); Db::name('iam_v2_proof')->where('proof_hash',hash('sha256',$expired))->update(['expires_at'=>time()-1]);
|
|
denies(fn()=>IamV2Source::introspect($expired,'launch'),'TRANSACTION_EXPIRED');
|
|
$oldAuth=$s1['auth_time']; Db::name('admin_session')->where('id',1)->update(['update_time'=>time()+30,'expire_time'=>time()+7200]);
|
|
ok(IamV2Source::check($s1['session_id'])['auth_time']===$oldAuth,'business renewal never freshens authentication');
|
|
Db::name('admin')->where('id',1)->update(['disable'=>1]); denies(fn()=>IamV2Source::check($s1['session_id']),'ACCOUNT_DISABLED'); Db::name('admin')->where('id',1)->update(['disable'=>0]);
|
|
Db::name('admin_session')->where('id',1)->update(['token'=>'rotated']); denies(fn()=>IamV2Source::check($s1['session_id']),'REAUTH_REQUIRED'); Db::name('admin_session')->where('id',1)->update(['token'=>'fixture-token-1']);
|
|
IamV2Source::revokeSession($s1['session_id']); denies(fn()=>IamV2Source::check($s1['session_id']),'REAUTH_REQUIRED'); ok(IamV2Source::check($s2['session_id'])['revoked']===0,'revoking one browser preserves other independent session');
|
|
$calls=[];$cfg=['v2_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','v2_token'=>str_repeat('v',32),'application_id'=>'zyt'];
|
|
$client=new IamV2Client($cfg,function($method,$url,$headers,$body)use(&$calls){$calls[]=compact('method','url','headers','body');return ['status'=>200,'body'=>json_encode(['binding'=>null,'employee'=>null,'access'=>['allowed'=>false]])];});
|
|
$service=new IamV2Manager($client);$service->binding(2);$service->binding(2);ok(count($calls)===1,'30 second read cache');
|
|
Db::name('iam_v2_binding')->where('admin_id',2)->update(['checked_at'=>time()-31]);$service->binding(2);ok(count($calls)===2,'missed revocation compensation after 30 seconds');
|
|
$binding=['binding'=>['state'=>'verified','version'=>2,'externalAccountId'=>'2'],'employee'=>['id'=>'employee-fixture','status'=>'active','oidcSubject'=>'subject-fixture'],'access'=>['allowed'=>true]];
|
|
Db::name('iam_v2_binding')->where('admin_id',2)->update(['employee_id'=>'employee-fixture','state'=>'verified','version'=>2,'payload'=>json_encode($binding)]);
|
|
$secret=str_repeat('e',32);$revoked=[];
|
|
$event=['id'=>'evt-fixture','employeeId'=>'employee-fixture','applicationIds'=>['zyt'],'reason'=>'revoked','occurredAt'=>gmdate('Y-m-d\TH:i:s\Z'),'bindingVersion'=>1];
|
|
$raw=json_encode($event);$sig='sha256='.hash_hmac('sha256',$raw,$secret);
|
|
denies(fn()=>IamV2Events::accept($raw,'invalid','evt-fixture',$secret),'FORBIDDEN');
|
|
denies(fn()=>IamV2Events::accept($raw,$sig,'another-event',$secret),'INVALID_EVENT');
|
|
$revoke=function($id)use(&$revoked){$revoked[]=$id;};
|
|
IamV2Events::accept($raw,$sig,'evt-fixture',$secret,$revoke);ok($revoked===[],'older event cannot revoke newer binding');
|
|
ok(IamV2Events::accept($raw,$sig,'evt-fixture',$secret,$revoke)['duplicate'],'signed event idempotence');
|
|
$event['id']='evt-new';$event['bindingVersion']=3;$raw=json_encode($event);$sig='sha256='.hash_hmac('sha256',$raw,$secret);
|
|
IamV2Events::accept($raw,$sig,'evt-new',$secret,$revoke);ok($revoked===[2],'valid event revokes exact identity');
|
|
$event['reason']='changed';$other=json_encode($event);denies(fn()=>IamV2Events::accept($other,'sha256='.hash_hmac('sha256',$other,$secret),'evt-new',$secret,$revoke),'INVALID_EVENT');
|
|
$event['id']='future';$event['occurredAt']=gmdate('Y-m-d\TH:i:s\Z',time()+120);$future=json_encode($event);denies(fn()=>IamV2Events::accept($future,'sha256='.hash_hmac('sha256',$future,$secret),'future',$secret,$revoke),'TRANSACTION_EXPIRED');
|
|
$binding['binding']['version']=3;
|
|
$stale=new IamV2Manager(new IamV2Client($cfg,fn()=>['status'=>200,'body'=>json_encode($binding)]));
|
|
denies(fn()=>$stale->binding(2,true),'BINDING_CONFLICT');
|
|
$beforeUsers=Db::name('admin')->select()->toArray();$batchCalls=[];
|
|
$batchClient=new IamV2Client($cfg,function($method,$url,$headers,$body)use(&$batchCalls){
|
|
$data=json_decode($body,true);$batchCalls[]=$data;
|
|
$claims=IamV2Source::introspect($data['proof'],'management');
|
|
ok($claims['targetLocalIds']===['2'],'only authorized safe target in central proof');
|
|
ok(in_array('iam:bind',$claims['capabilities'],true),'explicit binding capability');
|
|
if(str_ends_with($url,'/batch/preflight'))return ['status'=>200,'body'=>json_encode(['previewId'=>'preview-fixture','items'=>[['index'=>0,'externalAccountId'=>'2','status'=>'ready']],'expiresAt'=>time()+300])];
|
|
ok($data['indexes']===[0],'original selected index mapped to central filtered index');
|
|
return ['status'=>200,'body'=>json_encode(['id'=>'job-fixture','status'=>'pending','items'=>[]])];
|
|
});
|
|
$batchService=new IamV2Manager($batchClient);
|
|
$preview=$batchService->preflight($s2,[['kind'=>'bind','user_id'=>1,'employee_id'=>'protected','reason'=>'fixture'],['kind'=>'bind','user_id'=>2,'employee_id'=>'normal','reason'=>'fixture']]);
|
|
ok($preview['items'][0]['status']==='review' && $preview['items'][1]['index']===1,'bulk protected skipped, original indexes retained');
|
|
denies(fn()=>$batchService->batch($s2,'preview-fixture',[0],'fixture-key'),'INVALID_INPUT');
|
|
$job=$batchService->batch($s2,'preview-fixture',[1],'fixture-key');ok($job['id']==='job-fixture','explicit safe selection starts job');
|
|
ok(Db::name('admin')->select()->toArray()===$beforeUsers,'preflight/job orchestration never changes local accounts/roles');
|
|
denies(fn()=>IamV2Manager::input(['kind'=>'rebind','user_id'=>2,'reason'=>'fixture']),'BINDING_CONFLICT');
|
|
denies(fn()=>IamV2Manager::input(['kind'=>'create_and_bind','user_id'=>2,'reason'=>'fixture','new_employee'=>['username'=>'invalid /','display_name'=>'Name']]),'INVALID_INPUT');
|
|
Db::name('admin')->where('id',1)->update(['password'=>'new-credential']);
|
|
denies(fn()=>IamV2Source::check($s2['session_id']),'REAUTH_REQUIRED');
|
|
ok((int) Db::name('iam_v2_source_session')->where('session_id',$s2['session_id'])->value('version')===2,'credential change increments source revocation version');
|
|
$legacy=$cfg; unset($legacy['v2_token']); $legacy['application_token']=str_repeat('l',32); denies(fn()=>new IamV2Client($legacy),'SERVICE_UNAVAILABLE');
|
|
denies(fn()=>IamV2Client::https('https://iam.example.test/path?override=1'),'INVALID_FIXED_URL');
|
|
} finally { @unlink($file); }
|
|
echo "PASS IamV2SourceTest: independent browser SID, legacy reauth, live DB, scope, protected accounts, single-use proof/intent, expiry, immutable authTime, disabled/rotated/revoked sessions, 30s compensation, signed event replay/digest/future/version, revoked tombstone, batch protected filtering/index mapping, selected-only execution, no local account mutation, credential-change revocation\n";
|