Files
2026-11-06 13:37:12 +08:00

214 lines
14 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace app\adminapi\service\iam\v2;
use RuntimeException;
use think\facade\Db;
use think\facade\Config;
use app\adminapi\logic\auth\AuthLogic;
use app\common\cache\AdminAccountSafeCache;
/** Browser source sessions are separate from legacy account/terminal shared business tokens. */
final class IamV2Source
{
public const COOKIE = 'ZYT_IAM_V2_SESSION';
public static function requirePurpose(string $purpose): void
{
$feature = in_array($purpose, ['management', 'self_link', 'password_setup'], true) ? 'management' : ($purpose === 'launch' ? 'launch' : null);
if ($feature !== null && !IamV2Client::feature($feature)) { throw new RuntimeException('FEATURE_DISABLED'); }
}
public static function enroll(string $token, string $method, bool $cookie = true, ?int $authTime = null): array
{
if (!in_array($method, ['password', 'wecom', 'oidc', 'iam_launch'], true)) { throw new RuntimeException('REAUTH_REQUIRED'); }
if (in_array($method, ['oidc', 'iam_launch'], true) && $authTime !== null && ($authTime < 0 || $authTime > time() + 60)) { throw new RuntimeException('INVALID_AUTHENTICATION'); }
$legacy = Db::name('admin_session')->where('token', $token)->where('expire_time', '>', time())->find();
if (!$legacy) { throw new RuntimeException('REAUTH_REQUIRED'); }
$admin = self::admin((int) $legacy['admin_id']);
$old = (string) ($_COOKIE[self::COOKIE] ?? '');
if ($old !== '') { self::revokeSession($old); }
$binding = Db::name('iam_v2_binding')->where('admin_id', $legacy['admin_id'])->where('state', 'verified')->find();
$sid = bin2hex(random_bytes(32));
$row = ['session_id' => $sid, 'admin_id' => (int) $legacy['admin_id'], 'business_session_id' => $legacy['id'],
'token_hash' => hash('sha256', $token), 'account_stamp' => self::accountStamp($admin), 'auth_method' => $method, 'auth_time' => in_array($method, ['oidc', 'iam_launch'], true) ? max(0, $authTime ?? 0) : time(),
'version' => 1, 'revoked' => 0, 'binding_version' => (int) ($binding['version'] ?? 0), 'employee_id' => $binding['employee_id'] ?? '', 'expires_at' => min((int) $legacy['expire_time'], time() + 43200)];
Db::name('iam_v2_source_session')->insert($row);
if ($cookie) {
setcookie(self::COOKIE, $sid, ['expires' => $row['expires_at'], 'path' => '/adminapi', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);
$_COOKIE[self::COOKIE] = $sid;
}
return $row;
}
private static function accountStamp(array $admin): string
{
return hash('sha256', json_encode([$admin['account'], $admin['password'], $admin['work_wechat_userid'] ?? '', (int) $admin['root']], JSON_THROW_ON_ERROR));
}
public static function admin(int $id): array
{
$a = Db::name('admin')->where('id', $id)->find();
if (!$a || (int) $a['disable'] !== 0 || !empty($a['delete_time'])) { throw new RuntimeException('ACCOUNT_DISABLED'); }
return $a;
}
public static function current(string $token, bool $recent = false): array
{
$s = self::check((string) ($_COOKIE[self::COOKIE] ?? ''));
if (!hash_equals($s['token_hash'], hash('sha256', $token))) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
if ($recent && $s['auth_time'] < time() - 300) { throw new RuntimeException('REAUTH_REQUIRED'); }
return $s;
}
public static function check(string $id, ?int $version = null): array
{
$s = Db::name('iam_v2_source_session')->where('session_id', $id)->find();
if (!$s || (int) $s['revoked'] !== 0 || $s['expires_at'] <= time() || ($version !== null && (int) $s['version'] !== $version)) { throw new RuntimeException('REAUTH_REQUIRED'); }
$admin = self::admin((int) $s['admin_id']);
if (!hash_equals($s['account_stamp'], self::accountStamp($admin))) { self::revokeSession($id); throw new RuntimeException('REAUTH_REQUIRED'); }
$legacy = Db::name('admin_session')->where('id', $s['business_session_id'])->find();
if (!$legacy || $legacy['expire_time'] <= time() || (int) $legacy['admin_id'] !== (int) $s['admin_id'] || !hash_equals($s['token_hash'], hash('sha256', $legacy['token']))) { throw new RuntimeException('REAUTH_REQUIRED'); }
if ((int) ($s['binding_version'] ?? 0) > 0) {
$binding = Db::name('iam_v2_binding')->where('admin_id', $s['admin_id'])->find();
if ($binding && ($binding['state'] === 'revoked' || (int) $binding['version'] > (int) $s['binding_version'] || $binding['employee_id'] !== $s['employee_id'])) {
self::revokeSession($id); throw new RuntimeException('REAUTH_REQUIRED');
}
}
return $s;
}
/** Service callback: local-only live authorization, safe while central holds its state lock. */
public static function liveStatus(string $id, int $version, string $purpose = '', array $targets = []): array
{
self::requirePurpose($purpose);
$s = self::check($id, $version);
$out = ['active' => true, 'actorLocalId' => (string) $s['admin_id'], 'sessionId' => $s['session_id'], 'sessionVersion' => (int) $s['version'],
'authMethod' => $s['auth_method'], 'authTime' => (int) $s['auth_time'], 'expiresAt' => (int) $s['expires_at']];
if ($purpose !== 'management') { return $out + ['protectedAccount' => self::protectedAccount((int) $s['admin_id'])]; }
if ($s['auth_time'] < time() - 300) { throw new RuntimeException('REAUTH_REQUIRED'); }
if (!$targets || count($targets) > 100) { throw new RuntimeException('INVALID_INPUT'); }
foreach ($targets as $target) {
if (!is_string($target) || !preg_match('/^[1-9][0-9]{0,9}$/D', $target)) { throw new RuntimeException('INVALID_INPUT'); }
}
$caps = self::capabilities((int) $s['admin_id']);
if (!$caps) { throw new RuntimeException('FORBIDDEN'); }
self::scope((int) $s['admin_id'], $targets);
$protected = false;
foreach ($targets as $target) { self::admin((int) $target); $protected = self::protectedAccount((int) $target) || $protected; }
$eligible = count($targets) === 1 && self::reviewOperator((int) $s['admin_id']);
if ($protected && !$eligible) { throw new RuntimeException('PROTECTED_ACCOUNT_REVIEW_REQUIRED'); }
return $out + ['purpose' => 'management', 'capabilities' => array_map(fn($c) => 'iam:' . $c, $caps),
'targetLocalIds' => $targets, 'protectedAccount' => $protected, 'protectedReviewEligible' => $eligible];
}
public static function reauth(string $token, string $password): array
{
$legacy = Db::name('admin_session')->where('token', $token)->where('expire_time', '>', time())->find();
if (!$legacy || $password === '' || strlen($password) > 256) { throw new RuntimeException('REAUTH_REQUIRED'); }
$a = self::admin((int) $legacy['admin_id']);
$limiter = new AdminAccountSafeCache();
if (!$limiter->isSafe()) { throw new RuntimeException('RATE_LIMITED'); }
// Deliberately does NOT invoke LoginValidate's local-debug password bypass.
if (!hash_equals((string) $a['password'], create_password($password, Config::get('project.unique_identification')))) {
$limiter->record(); throw new RuntimeException('REAUTH_REQUIRED');
}
$limiter->relieve(); return self::enroll($token, 'password');
}
public static function capabilities(int $id): array
{
$admin = self::admin($id);
$all = ['read', 'bind', 'create', 'unbind', 'rebind', 'batch'];
if ((int) $admin['root'] === 1) { return $all; }
$perms = AuthLogic::getAuthByAdminId($id);
return array_values(array_filter($all, fn($p) => in_array('iamV2/' . $p, $perms, true)));
}
public static function reviewOperator(int $id): bool
{
$a = self::admin($id);
if ((int) $a['root'] === 1) { return true; }
$roles = Db::name('admin_role')->where('admin_id', $id)->column('role_id');
return $roles && Db::name('system_role')->whereIn('id', $roles)->whereIn('name', ['管理员', '系统管理员', '超级管理员'])->whereNull('delete_time')->count() > 0;
}
public static function protectedAccount(int $id): bool
{
return self::reviewOperator($id) || count(array_intersect(self::capabilities($id), ['bind','create','unbind','rebind','batch'])) > 0;
}
public static function visibleIds(int $id): ?array
{
$a = self::admin($id);
$a['role_id'] = Db::name('admin_role')->where('admin_id', $id)->column('role_id');
return \app\common\service\DataScope\DataScopeService::getVisibleAdminIds($id, $a);
}
public static function scope(int $actor, array $targets): void
{
$visible = self::visibleIds($actor);
if ($visible !== null && array_diff(array_map('intval', $targets), $visible)) { throw new RuntimeException('FORBIDDEN'); }
}
public static function authorize(array $session, string $capability, array $targets = [], bool $recent = true, bool $protectedReview = false): void
{
if (!in_array($capability, self::capabilities((int) $session['admin_id']), true)) { throw new RuntimeException('FORBIDDEN'); }
if ($recent && $session['auth_time'] < time() - 300) { throw new RuntimeException('REAUTH_REQUIRED'); }
self::scope((int) $session['admin_id'], $targets);
foreach ($targets as $id) {
$target = self::admin((int) $id);
// Single-target high-privilege requests may reach central review, never bypass it.
if (self::protectedAccount((int) $id) && !($protectedReview && count($targets) === 1 && self::reviewOperator((int) $session['admin_id']))) {
throw new RuntimeException('PROTECTED_ACCOUNT_REVIEW_REQUIRED');
}
}
}
public static function proof(array $session, string $purpose, array $targets = [], array $capabilities = [], ?string $transaction = null, array $extra = []): string
{
self::requirePurpose($purpose);
self::check($session['session_id'], (int) $session['version']);
$secret = bin2hex(random_bytes(32));
$payload = ['applicationId' => (string) Config::get('iam.application_id', 'zyt'), 'actorLocalId' => (string) $session['admin_id'],
'sessionId' => $session['session_id'], 'sessionVersion' => (int) $session['version'], 'authMethod' => $session['auth_method'],
'authTime' => (int) $session['auth_time'], 'expiresAt' => time() + 60, 'capabilities' => $capabilities,
'targetLocalIds' => array_map('strval', $targets), 'protectedAccount' => count(array_filter($targets, fn($id) => self::protectedAccount((int) $id))) > 0, 'transactionId' => $transaction];
$payload += array_intersect_key($extra, array_flip(['oidcNonce', 'state', 'protectedReviewEligible']));
Db::name('iam_v2_proof')->insert(['proof_hash' => hash('sha256', $secret), 'purpose' => $purpose, 'session_id' => $session['session_id'],
'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'expires_at' => time() + 60, 'consumed' => 0]);
return $secret;
}
public static function introspect(string $proof, string $purpose, ?string $transaction = null): array
{
self::requirePurpose($purpose);
// The central Go attestor serializes an absent transaction as an empty string.
$transaction = $transaction === '' ? null : $transaction;
return Db::transaction(function () use ($proof, $purpose, $transaction) {
$hash = hash('sha256', $proof);
$r = Db::name('iam_v2_proof')->where('proof_hash', $hash)->lock(true)->find();
if (!$r || $r['purpose'] !== $purpose || $r['expires_at'] <= time() || (int) $r['consumed'] !== 0) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
$p = json_decode($r['payload'], true, 32, JSON_THROW_ON_ERROR);
if (($p['transactionId'] ?? null) !== $transaction) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$session = self::check($p['sessionId'], (int) $p['sessionVersion']);
$p['protectedReviewEligible'] = ($p['protectedReviewEligible'] ?? false) === true
&& count($p['targetLocalIds']) === 1 && self::reviewOperator((int) $session['admin_id']);
foreach ($p['capabilities'] as $cap) {
if (!str_starts_with($cap, 'iam:')) { throw new RuntimeException('FORBIDDEN'); }
self::authorize($session, substr($cap, 4), $p['targetLocalIds'], true, ($p['protectedReviewEligible'] ?? false) === true && count($p['targetLocalIds']) === 1);
}
// Target protection can change during the proof's short lifetime. Recompute it.
$p['protectedAccount'] = count(array_filter($p['targetLocalIds'], fn($id) => self::protectedAccount((int) $id))) > 0;
if (Db::name('iam_v2_proof')->where('proof_hash', $hash)->where('consumed', 0)->update(['consumed' => 1]) !== 1) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
return ['active' => true] + $p;
});
}
public static function revokeSession(string $id): void
{
Db::name('iam_v2_source_session')->where('session_id', $id)->inc('version')->update(['revoked' => 1]);
}
public static function revokeGeneration(int $id, int $upper, ?string $employeeId = null): void
{
$query = Db::name('iam_v2_source_session')->where('admin_id', $id)->where('binding_version', '<=', $upper);
if ($employeeId !== null) { $query->where(function ($q) use ($employeeId) { $q->where('employee_id', $employeeId)->whereOr('binding_version', 0); }); }
$sessions = $query->select()->toArray();
foreach ($sessions as $s) { self::revokeSession($s['session_id']); }
foreach ($sessions as $s) {
$newer = Db::name('iam_v2_source_session')->where('token_hash', $s['token_hash'])->where('revoked', 0)->where('expires_at', '>', time())->find();
if ($newer) { continue; }
$legacy = Db::name('admin_session')->where('id', $s['business_session_id'])->find();
if ($legacy && hash_equals($s['token_hash'], hash('sha256', $legacy['token']))) { \app\adminapi\logic\auth\AdminLogic::expireToken($legacy['token']); }
}
}
public static function revokeAdmin(int $id): void
{
Db::name('iam_v2_source_session')->where('admin_id', $id)->where('revoked', 0)->inc('version')->update(['revoked' => 1]);
}
}