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]); } }