Integrate portal identity and shared navigation in isolated SSO mode

This commit is contained in:
leefer
2026-09-22 10:27:19 +08:00
parent 0e2e5f02de
commit 0883858c0e
9 changed files with 173 additions and 2 deletions
+7
View File
@@ -2,13 +2,20 @@
declare(strict_types=1);
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/portal.php';
if (session_status() === PHP_SESSION_NONE) {
if (portalEnabled()) {
session_name('JINNIU_FUSION_LOAN_SID');
session_set_cookie_params(['httponly'=>true, 'samesite'=>'Lax', 'secure'=>getenv('PORTAL_COOKIE_SECURE')==='1']);
ini_set('session.use_strict_mode', '1');
}
session_start();
}
function currentUser(): ?array
{
portalValidate();
if (empty($_SESSION['uid'])) {
return null;
}
+2 -1
View File
@@ -16,7 +16,8 @@ function db(): PDO
$pdo->exec('PRAGMA foreign_keys = ON');
initSchema($pdo);
seedAdmin($pdo);
if (getenv('PORTAL_SSO_ENABLED') !== '1') seedAdmin($pdo);
$pdo->exec('CREATE TABLE IF NOT EXISTS portal_identity(uid INTEGER PRIMARY KEY REFERENCES users(id), empid TEXT UNIQUE NOT NULL)');
return $pdo;
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
function portalEnabled(): bool { return getenv('PORTAL_SSO_ENABLED') === '1'; }
function portalPublic(): string
{
$url = rtrim((string)getenv('PORTAL_PUBLIC_URL'), '/');
if (!preg_match('#^https?://[a-zA-Z0-9.:-]+$#D', $url)) {
throw new RuntimeException('Portal URL unavailable');
}
return $url;
}
function portalFail(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
header('Referrer-Policy: no-referrer');
echo '<!doctype html><meta charset="utf-8"><title>金牛贷款档案</title><p>' . htmlspecialchars($message) . '</p>';
try { echo '<a href="' . htmlspecialchars(portalPublic(), ENT_QUOTES) . '">返回统一门户</a>'; } catch (Throwable $e) {}
exit;
}
function portalCall(string $operation, array $payload): array
{
$base = rtrim((string)getenv('PORTAL_INTERNAL_URL'), '/');
$secret = (string)getenv('PORTAL_SSO_SECRET');
if (!preg_match('#^https?://[a-zA-Z0-9.:-]+$#D', $base) || strlen($secret) < 32) {
throw new RuntimeException('SSO configuration unavailable');
}
$context = stream_context_create(['http' => [
'method' => 'POST', 'header' => "Content-Type: application/json\r\nAuthorization: Bearer " . $secret,
'content' => json_encode($payload), 'timeout' => 5, 'follow_location' => 0,
'ignore_errors' => true,
]]);
$body = @file_get_contents($base . '/api/sso/apps/loan-archive/' . $operation, false, $context, 0, 32769);
if ($body === false || strlen($body) > 32768 || !isset($http_response_header[0])
|| !preg_match('#^HTTP/\S+ 200\b#', $http_response_header[0])) {
throw new RuntimeException('Portal verification unavailable');
}
$result = json_decode($body, true, 32, JSON_THROW_ON_ERROR);
if (!is_array($result)) throw new RuntimeException('Invalid identity response');
return $result;
}
function portalValidate(): void
{
static $checked = false;
if (!portalEnabled() || $checked) return;
$handle = $_SESSION['portal_handle'] ?? '';
if (!is_string($handle) || !preg_match('/^[a-f0-9]{64}$/D', $handle)) {
portalFail(401, '请从统一门户进入贷款档案系统。');
}
try { $result = portalCall('introspect', ['session_handle' => $handle]); }
catch (Throwable $e) { portalFail(503, '暂时无法验证登录,请稍后重试。'); }
$identity = $result['identity'] ?? [];
if (empty($result['active']) || ($identity['empid'] ?? '') !== ($_SESSION['portal_empid'] ?? '')
|| ($identity['local_uid'] ?? 0) !== ($_SESSION['uid'] ?? 0)) {
$_SESSION = [];
session_regenerate_id(true);
portalFail(401, '登录或授权已失效,请重新进入统一门户。');
}
$checked = true;
}
function portalCallback(): never
{
if (!portalEnabled()) portalFail(404, '统一登录未启用。');
header('Cache-Control: no-store');
header('Referrer-Policy: no-referrer');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') portalFail(405, 'Method not allowed');
if (!isset($_GET['ticket'])) {
session_regenerate_id(true);
$verifier = bin2hex(random_bytes(32));
$_SESSION['portal_verifier'] = $verifier;
$_SESSION['portal_started'] = time();
$_SESSION['portal_theme'] = ($_GET['theme'] ?? '') === 'night' ? 'night' : 'day';
header('Location: ' . portalPublic() . '/api/sso/apps/loan-archive/authorize?challenge=' . hash('sha256', $verifier), true, 303);
exit;
}
$ticket = $_GET['ticket'];
$verifier = $_SESSION['portal_verifier'] ?? '';
if (!is_string($ticket) || !preg_match('/^[a-f0-9]{64}$/D', $ticket)
|| !preg_match('/^[a-f0-9]{64}$/D', $verifier)
|| time() - (int)($_SESSION['portal_started'] ?? 0) > 120) {
portalFail(401, '登录请求已失效,请从门户重新进入。');
}
unset($_SESSION['portal_verifier'], $_SESSION['portal_started']);
try {
$result = portalCall('exchange', ['ticket' => $ticket, 'verifier' => $verifier]);
$who = $result['identity'] ?? [];
$uid = $who['local_uid'] ?? 0;
$empid = $who['empid'] ?? '';
$handle = $result['session_handle'] ?? '';
if (!is_int($uid) || $uid < 1 || !preg_match('/^[a-f0-9-]{36}$/D', $empid)
|| !preg_match('/^[a-f0-9]{64}$/D', $handle)) throw new RuntimeException('Invalid identity');
$db = db();
$stmt = $db->prepare('SELECT id FROM users WHERE id=?');
$stmt->execute([$uid]);
if (!$stmt->fetch()) throw new DomainException('Local account missing');
$stmt = $db->prepare('INSERT OR IGNORE INTO portal_identity(uid, empid) VALUES (?,?)');
$stmt->execute([$uid, $empid]);
$stmt = $db->prepare('SELECT empid FROM portal_identity WHERE uid=?');
$stmt->execute([$uid]);
if ($stmt->fetchColumn() !== $empid) throw new DomainException('Binding mismatch');
$theme = $_SESSION['portal_theme'] ?? 'day';
$_SESSION = ['uid' => $uid, 'portal_empid' => $empid, 'portal_handle' => $handle];
session_regenerate_id(true);
header('Location: /index.php?action=workbench&theme=' . $theme, true, 303);
exit;
} catch (Throwable $e) {
$_SESSION = [];
session_regenerate_id(true);
portalFail(401, '无法进入,请检查门户授权、账号绑定及子系统可用状态。');
}
}
+2 -1
View File
@@ -15,5 +15,6 @@
</DirectoryMatch>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
SetEnvIf Request_URI "^/portal-sso" portal_sso_request
CustomLog ${APACHE_LOG_DIR}/access.log combined env=!portal_sso_request
</VirtualHost>
+11
View File
@@ -0,0 +1,11 @@
# 统一门户接入(2026-09-22)
分支 feature/portal-integration,基于 main 0e2e5f0。融合测试从 `http://192.168.200.15:18091/` 进入,贷款实例 18123;原 8080 实例和数据未修改。
门户模式需 PORTAL_SSO_ENABLED=1、独立 PORTAL_SSO_SECRET、PORTAL_INTERNAL_URL、PORTAL_PUBLIC_URL。固定回调 `/portal-sso` 经 Apache rewrite 进入 public/portal-sso.php。Apache 配置不记录票据回调 URL。
每个本地 users.id 显式绑定门户员工 empid,增量表 portal_identity 防止静默改绑。使用独立 HttpOnly/SameSite Cookie,每个业务请求在线检查门户会话和入口权限;本地 role 决定管理员/经办业务权限。原生登录、退出、个人凭证及用户管理路由在融合模式封闭;固定默认管理员不会自动生成。
部署 overlay 在 OA 仓库 deploy/business-apps.compose.yml。新增实例使用独立 loan-data/data 与 loan-data/uploads,不能指向原测试资料。测试管理员和经办人为合成身份,不迁移旧档案。初始化数据库应由 www-data 执行,或在初始化后明确修正数据目录属主,避免无法写入 empid 绑定。
真实 HTTP 40 项通过:直接访问拦截、两个角色、删除审批权限、退出联动、主要业务页、Excel 导出。浏览器验证隐藏账户菜单、返回门户及夜间主题往返。当前为空库测试,不代表已经完成原档案迁移验收。
+2
View File
@@ -0,0 +1,2 @@
RewriteEngine On
RewriteRule ^portal-sso$ portal-sso.php [L]
+17
View File
@@ -1,3 +1,20 @@
// Accept only the shared day/night preference supplied by the portal.
(function () {
const incoming = new URLSearchParams(location.search).get('theme');
if (incoming === 'day' || incoming === 'night') {
try { localStorage.setItem('jn_theme', incoming); } catch (e) {}
const surface = document.querySelector('[data-variant-root]');
if (surface) surface.setAttribute('data-theme', incoming);
}
document.addEventListener('click', function (event) {
const link = event.target.closest('[data-return-portal]');
if (!link) return;
const url = new URL(link.href);
const surface = document.querySelector('[data-variant-root]');
url.searchParams.set('theme', surface && surface.getAttribute('data-theme') === 'night' ? 'night' : 'day');
link.href = url.toString();
});
})();
/* 金牛贷款档案 · 霜曜融合交互层(服务端渲染版)
行为模型与定版样板一致:属性驱动、事件委托、可打断。 */
"use strict";
+11
View File
@@ -26,6 +26,10 @@ function archiveKindLabel(string $k): string
return $k === ARCHIVE_KIND_EXTERNAL_GUARANTEE ? '对外担保' : '银行贷款';
}
if (portalEnabled() && in_array($action, ['login','logout','profile','users'], true)) {
portalFail(403, '账号和登录请前往统一门户管理。');
}
if ($action === 'logout') {
session_destroy();
header('Location: index.php?action=login');
@@ -134,15 +138,21 @@ function pageHeader(array $me, ?int $taskCount = null): void
<?php if ($isAdmin): ?>
<div class="nav-label">管理</div>
<?= $navItem('approvals', 'index.php?action=delete_approvals', '删除审批', '审') ?>
<?php if (!portalEnabled()): ?>
<?= $navItem('users', 'index.php?action=users', '用户管理', '用') ?>
<?php endif; ?>
<?php endif; ?>
<?php if (!portalEnabled()): ?>
<div class="nav-label">账户</div>
<?= $navItem('profile', 'index.php?action=profile', '个人设置', '我') ?>
<?php endif; ?>
<?php if (portalEnabled()): ?><a class="nav-item" data-return-portal href="<?= e(portalPublic()) ?>">← 返回统一门户</a><?php endif; ?>
<div class="sidebar-account">
<div class="theme-toggle" role="group" aria-label="日间 / 夜间主题切换">
<button type="button" data-theme-btn="day" aria-pressed="true">☀ 日间</button>
<button type="button" data-theme-btn="night" aria-pressed="false">☾ 夜间</button>
</div>
<?php if (!portalEnabled()): ?>
<div class="account-box">
<div class="account-id">
<div class="account-meta">
@@ -154,6 +164,7 @@ function pageHeader(array $me, ?int $taskCount = null): void
</div>
<div class="account-note">金牛集团 · 内部档案系统</div>
</div>
<?php endif; ?>
</div>
</aside>
<div class="content-shell">
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__ . '/../app/auth.php';
portalCallback();