214 lines
14 KiB
PHP
Executable File
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]);
|
|
}
|
|
}
|