126 lines
6.8 KiB
PHP
Executable File
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('请求包含不支持的字段'); }
|
|
}
|
|
}
|