Integrate portal identity and shared navigation in isolated SSO mode
This commit is contained in:
@@ -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
@@ -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
@@ -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, '无法进入,请检查门户授权、账号绑定及子系统可用状态。');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 导出。浏览器验证隐藏账户菜单、返回门户及夜间主题往返。当前为空库测试,不代表已经完成原档案迁移验收。
|
||||
@@ -0,0 +1,2 @@
|
||||
RewriteEngine On
|
||||
RewriteRule ^portal-sso$ portal-sso.php [L]
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/auth.php';
|
||||
portalCallback();
|
||||
Reference in New Issue
Block a user