Files
zyt/server/app/adminapi/service/IdentityHospitalSsoService.php
T
2026-11-06 13:37:12 +08:00

126 lines
6.8 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace app\adminapi\service;
use app\adminapi\logic\LoginLogic;
use app\adminapi\http\middleware\AuthMiddleware;
use app\common\cache\AdminTokenCache;
use app\common\enum\AdminTerminalEnum;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminSession;
use app\common\service\JsonService;
use think\facade\Config;
use think\facade\Db;
/** The hospital trusts its own live source session, never a caller-provided identity. */
final class IdentityHospitalSsoService
{
private IdentityHospitalSsoTicketStore $tickets;
public function __construct(?IdentityHospitalSsoTicketStore $tickets = null)
{
$this->tickets = $tickets ?? new IdentityHospitalSsoTicketStore(app()->getRuntimePath() . 'identity-hospital-sso');
}
public function issue(string $sourceToken, array $data): array
{
self::only($data, ['code_challenge', 'code_challenge_method']);
if (($data['code_challenge_method'] ?? null) !== 'S256'
|| !is_string($data['code_challenge'] ?? null)
|| !preg_match('/^[A-Za-z0-9_-]{43}$/D', $data['code_challenge'])) {
JsonService::throw('登录验证参数不合法');
}
if ($sourceToken === '' || strlen($sourceToken) > 512 || preg_match('/[\x00-\x20\x7f]/', $sourceToken)) {
JsonService::throw('请先登录集团工作台', [], -1);
}
return Db::transaction(function () use ($sourceToken, $data): array {
$candidate = AdminSession::where('token', $sourceToken)->findOrEmpty();
[$admin, $session] = self::lockSource($candidate, hash('sha256', $sourceToken));
self::ready($admin);
// 交接源仍受原 IP 绑定保护;目标 exchange 才允许来自医院浏览器的不同 IP。
$info = (new AdminTokenCache())->getAdminInfo($sourceToken);
$ip = request()->ip();
if (!$info || (int) ($info['admin_id'] ?? 0) !== (int) $admin['id']
|| (($info['login_ip'] ?? '') !== $ip && !AuthMiddleware::isTrustedRpaServerRequest(
(array) $info, $ip, env('wecom_rpa.server_ips', '')))) {
JsonService::throw('ip地址发生变化,请重新登录', [], -1);
}
return ['ticket' => $this->tickets->issue((int) $session['id'], hash('sha256', $sourceToken), $data['code_challenge'])];
});
}
public function exchange(array $data): array
{
self::only($data, ['ticket', 'code_verifier']);
if (!is_string($data['ticket'] ?? null) || !is_string($data['code_verifier'] ?? null)) {
JsonService::throw('登录验证参数不合法');
}
try { $record = $this->tickets->consume($data['ticket'], $data['code_verifier']); }
catch (\RuntimeException $error) { JsonService::throw('登录票据已失效,请重新从集团工作台进入'); }
$result = Db::transaction(static function () use ($record): array {
$candidate = AdminSession::where('id', $record['session_id'])->findOrEmpty();
[$admin] = self::lockSource($candidate, $record['token_hash']);
self::ready($admin);
$now = time();
$target = AdminSession::where('admin_id', (int) $admin['id'])
->where('terminal', AdminTerminalEnum::GROUP_HOSPITAL)->lock(true)->findOrEmpty();
$oldToken = $target->isEmpty() ? '' : (string) $target['token'];
$token = $oldToken;
if ($target->isEmpty() || (int) $target['expire_time'] <= $now || (int) $admin['multipoint_login'] === 0) {
// 原 admin_session.token 为 varchar(32),保持128位随机性且不修改业务表。
$token = bin2hex(random_bytes(16));
}
$values = ['token' => $token, 'expire_time' => $now + (int) Config::get('project.admin_token.expire_duration'),
'update_time' => $now];
if ($target->isEmpty()) {
AdminSession::create($values + ['admin_id' => (int) $admin['id'], 'terminal' => AdminTerminalEnum::GROUP_HOSPITAL]);
} else { $target->save($values); }
// Persist the independent session under the same account/source locks. Publish its cache only after commit.
return ['token' => $token, 'old_token' => $oldToken, 'name' => (string) $admin['name'],
'avatar' => (string) $admin['avatar']];
});
$cache = new AdminTokenCache();
if ($result['old_token'] !== '' && $result['old_token'] !== $result['token']) {
$cache->deleteAdminInfo($result['old_token']);
}
// This executes in the real hospital browser request, retaining the original per-token IP protection.
$info = $cache->setAdminInfo($result['token']);
if (!$info) { JsonService::throw('登录已失效,请重新进入', [], -1); }
return ['name' => $result['name'], 'avatar' => $result['avatar'], 'role_name' => $info['role_name'],
'token' => $result['token'], 'is_paw' => 1, 'need_bind_work_wechat' => false];
}
private static function lockSource(AdminSession $candidate, string $tokenHash): array
{
if ($candidate->isEmpty()) { JsonService::throw('集团登录已失效,请重新登录', [], -1); }
// Same order as IdentitySessionService and account changes: account row, then the exact session row.
$admin = Admin::where('id', (int) $candidate['admin_id'])->lock(true)->findOrEmpty();
if ($admin->isEmpty() || (int) $admin['disable'] !== 0 || !empty($admin->getData('delete_time'))) {
JsonService::throw('集团登录已失效,请重新登录', [], -1);
}
$session = AdminSession::where('id', (int) $candidate['id'])->where('admin_id', (int) $admin['id'])
->where('expire_time', '>', time())->lock(true)->findOrEmpty();
if ($session->isEmpty() || !hash_equals($tokenHash, hash('sha256', (string) $session['token']))
|| (int) $session['terminal'] === AdminTerminalEnum::GROUP_HOSPITAL) {
JsonService::throw('集团登录已失效,请重新登录', [], -1);
}
return [$admin, $session];
}
private static function ready(Admin $admin): void
{
if ((int) $admin['is_paw'] !== 1) { JsonService::throw('请先在集团工作台完成首次密码修改'); }
if (LoginLogic::adminMustBindWorkWechat(['root' => (int) $admin['root'],
'work_wechat_userid' => (string) ($admin['work_wechat_userid'] ?? '')])) {
JsonService::throw('请先在原后台完成企业微信绑定', [], LoginLogic::CODE_NEED_BIND_WORK_WECHAT);
}
}
private static function only(array $data, array $allowed): void
{
if (array_diff(array_keys($data), $allowed) !== []) { JsonService::throw('请求包含不支持的字段'); }
}
}