Add portal SSO while retaining native financial role and company scope
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Optional overlay for a separately approved, fresh finance integration instance.
|
||||
# Never point FINANCE_DATA at the standalone server's real database.
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
FINANCE_SSO_ENABLED: "true"
|
||||
FINANCE_SSO_SECRET: ${FINANCE_SSO_SECRET}
|
||||
FINANCE_SSO_CALLBACK_URL: ${FINANCE_PUBLIC_URL}/portal-sso
|
||||
finance:
|
||||
image: ${FINANCE_IMAGE}
|
||||
container_name: jinniu-finance-portal-integration
|
||||
ports: ["${TEST_HOST}:18124:4173"]
|
||||
environment:
|
||||
PORTAL_SSO_ENABLED: "1"
|
||||
PORTAL_SSO_SECRET: ${FINANCE_SSO_SECRET}
|
||||
PORTAL_INTERNAL_URL: http://backend:8000
|
||||
PORTAL_PUBLIC_URL: http://${TEST_HOST}:18091
|
||||
APP_DB_PATH: /app/data/app.db
|
||||
APP_STORAGE_DIR: /app/data/files
|
||||
volumes:
|
||||
- ${FINANCE_SOURCE}/server.py:/app/server.py:ro
|
||||
- ${FINANCE_SOURCE}/src:/app/src:ro
|
||||
- ${FINANCE_SOURCE}/web:/app/web:ro
|
||||
- ${FINANCE_DATA}:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4173/healthz',timeout=3)"]
|
||||
depends_on: [backend]
|
||||
@@ -0,0 +1,13 @@
|
||||
# 统一门户适配(2026-09-22)
|
||||
|
||||
分支 feature/portal-integration,基于 main 6eccbe9。代码完成但尚未替换外网测试实例;部署方向等待用户决定。
|
||||
|
||||
`PORTAL_SSO_ENABLED=1` 开启门户模式;必须配置独立的 PORTAL_SSO_SECRET、PORTAL_INTERNAL_URL、PORTAL_PUBLIC_URL。若使用 HTTPS,部署时还需相应安全 Cookie 配置。门户配置 FINANCE_SSO_ENABLED、FINANCE_SSO_SECRET、FINANCE_SSO_CALLBACK_URL,回调固定为 `/portal-sso`。
|
||||
|
||||
员工在门户中绑定已有财务用户的数字 ID;本地角色及 company_id 继续决定业务权限,不读取门户 admin 作为业务角色。每名员工应有独立本地账号,同一公司可有多名员工;不将多人映射到共享公司登录身份。账户停用及财务本地停用均拒绝访问。门户模式不允许原生登录、登出、改密、创建凭证或重置密码;管理员/出纳页面仍分开。
|
||||
|
||||
初始化会增量创建 portal_identity、portal_sessions 两表,不改银行流水、业务计算或原会话表。已有银行数据迁移不包含在本次工作中。全新实例须先配置测试公司和测试业务身份,再绑定门户员工;不会默认生成管理员密码。
|
||||
|
||||
候选内网部署 overlay 为 deploy/portal-integration.compose.yml,应叠加现有门户/OA融合 Compose,使用独立 FINANCE_DATA 目录并赋予容器用户 10001 写权限。健康检查使用 `/healthz`,原主页在门户模式跳回门户。FINANCE_IMAGE 应为已准备好依赖的 Python 3.12 财务镜像,代码目录通过 FINANCE_SOURCE 只读挂载。不得把候选测试实例数据路径指向现有正式或独立测试库。
|
||||
|
||||
验证命令:`PYTHONPATH=src python -m unittest discover -s tests`。本轮 395 项:391 通过、4 跳过,其中新增 6 项 SSO 回调/权限/隔离测试。外网到内网门户目前不可达,实际财务跨系统端到端部署验收待完成。
|
||||
@@ -19,6 +19,7 @@ from bank_importer import (
|
||||
positions, reminders, settings, subjects,
|
||||
)
|
||||
from bank_importer.db import connect, migrate, transaction, utc_now
|
||||
from bank_importer import portal_sso
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
@@ -26,13 +27,13 @@ WEB_ROOT = ROOT / "web"
|
||||
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
DB_PATH = Path(os.environ.get("APP_DB_PATH", ROOT / "data" / "app.db"))
|
||||
STORAGE_DIR = Path(os.environ.get("APP_STORAGE_DIR", ROOT / "data" / "files"))
|
||||
SESSION_COOKIE = "cw_session"
|
||||
SESSION_COOKIE = "cw_fusion_session" if portal_sso.enabled() else "cw_session"
|
||||
# Local plain-HTTP deployment: the cookie intentionally carries no Secure
|
||||
# flag (see docs/decisions/003-auth.md).
|
||||
COOKIE_FLAGS = "HttpOnly; SameSite=Lax; Path=/"
|
||||
|
||||
|
||||
class AppHandler(SimpleHTTPRequestHandler):
|
||||
class AppHandler(portal_sso.PortalMixin, SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(WEB_ROOT), **kwargs)
|
||||
|
||||
@@ -44,6 +45,15 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
query = parse_qs(parsed.query)
|
||||
if path == "/healthz":
|
||||
self._send_json(200, {"status": "ok"})
|
||||
return
|
||||
if path == "/portal-sso":
|
||||
self._portal_callback(query, DB_PATH)
|
||||
return
|
||||
if portal_sso.enabled() and path in {"/", "/index.html", "/login-admin.html", "/login-company.html"}:
|
||||
self._portal_redirect(portal_sso.base_url("PORTAL_PUBLIC_URL"))
|
||||
return
|
||||
|
||||
if path == "/api/me":
|
||||
self._handle_me()
|
||||
@@ -274,6 +284,10 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if portal_sso.enabled() and (path in {"/api/login", "/api/logout", "/api/password/change", "/api/admin/users"}
|
||||
or re.fullmatch(r"/api/admin/users/\d+/reset-password", path)):
|
||||
self._send_json(403, {"message": "账号和登录请前往统一门户管理。"})
|
||||
return
|
||||
|
||||
if path == "/api/login":
|
||||
self._handle_login()
|
||||
@@ -463,6 +477,11 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
token = self._session_token()
|
||||
if not token:
|
||||
return None
|
||||
if portal_sso.enabled():
|
||||
try:
|
||||
return portal_sso.resolve(connection, token)
|
||||
except Exception:
|
||||
return None # Fail closed; no local-password fallback.
|
||||
return auth.resolve_session(connection, token)
|
||||
|
||||
def _require_user(self, connection, *, pending_password_ok: bool = False):
|
||||
@@ -470,7 +489,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if user is None:
|
||||
self._send_json(401, {"status": "error", "message": "请先登录。"})
|
||||
return None
|
||||
if user["must_change_password"] and not pending_password_ok:
|
||||
if user["must_change_password"] and not pending_password_ok and not portal_sso.enabled():
|
||||
self._send_json(
|
||||
403,
|
||||
{"status": "error", "message": "首次登录须修改密码后才能继续操作。"},
|
||||
@@ -619,7 +638,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
"status": "ok",
|
||||
"username": user["username"],
|
||||
"role": user["role"],
|
||||
"must_change_password": bool(user["must_change_password"]),
|
||||
"must_change_password": bool(user["must_change_password"]) and not portal_sso.enabled(),
|
||||
"company_id": user["company_id"],
|
||||
"company_name": company_name,
|
||||
}
|
||||
@@ -1142,6 +1161,9 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
return
|
||||
name = str(data.get("name") or "").strip()
|
||||
username = str(data.get("username") or "").strip()
|
||||
if portal_sso.enabled() and username:
|
||||
self._send_json(403, {"message": "员工账号请在统一门户建立,再配置财务权限绑定。"})
|
||||
return
|
||||
try:
|
||||
company_id = master_data.create_company(
|
||||
connection,
|
||||
@@ -4416,7 +4438,8 @@ def main() -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
migrate(connection)
|
||||
initial_password = ensure_bootstrap_admin(connection)
|
||||
portal_sso.migrate(connection)
|
||||
initial_password = None if portal_sso.enabled() else ensure_bootstrap_admin(connection)
|
||||
finally:
|
||||
connection.close()
|
||||
if initial_password is not None:
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Optional portal identity bridge. Financial role and company scope stay local."""
|
||||
import hashlib
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from http.cookies import SimpleCookie
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
from urllib.request import Request, build_opener, HTTPRedirectHandler
|
||||
|
||||
from . import auth
|
||||
from .db import connect
|
||||
|
||||
|
||||
def enabled():
|
||||
return os.environ.get("PORTAL_SSO_ENABLED") == "1"
|
||||
|
||||
|
||||
def base_url(name):
|
||||
value = os.environ.get(name, "").rstrip("/")
|
||||
url = urlsplit(value)
|
||||
if (url.scheme not in {"http", "https"} or not url.hostname or url.username
|
||||
or url.password or url.path or url.query or url.fragment):
|
||||
raise ValueError("Portal URL unavailable")
|
||||
return value
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def call(operation, payload):
|
||||
secret = os.environ.get("PORTAL_SSO_SECRET", "")
|
||||
if len(secret) < 32:
|
||||
raise ValueError("Portal secret unavailable")
|
||||
req = Request(base_url("PORTAL_INTERNAL_URL") + "/api/sso/apps/finance/" + operation,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer " + secret})
|
||||
with build_opener(NoRedirect).open(req, timeout=5) as response:
|
||||
body = response.read(32769)
|
||||
if len(body) > 32768:
|
||||
raise ValueError("Invalid portal response")
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def migrate(connection):
|
||||
connection.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS portal_identity (
|
||||
uid INTEGER PRIMARY KEY REFERENCES users(id), empid TEXT UNIQUE NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS portal_sessions (
|
||||
token_hash TEXT PRIMARY KEY, handle TEXT NOT NULL,
|
||||
empid TEXT NOT NULL, uid INTEGER NOT NULL REFERENCES users(id)
|
||||
);
|
||||
""")
|
||||
|
||||
|
||||
def resolve(connection, token):
|
||||
user = auth.resolve_session(connection, token)
|
||||
if user is None:
|
||||
return None
|
||||
row = connection.execute("SELECT * FROM portal_sessions WHERE token_hash=?",
|
||||
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
result = call("introspect", {"session_handle": row["handle"]})
|
||||
who = result.get("identity") or {}
|
||||
if (not result.get("active") or who.get("local_uid") != user["id"]
|
||||
or who.get("empid") != row["empid"] or row["uid"] != user["id"]):
|
||||
auth.revoke_session(connection, token)
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
class PortalMixin:
|
||||
def _portal_error(self, status, message):
|
||||
home = html.escape(base_url("PORTAL_PUBLIC_URL"), quote=True)
|
||||
body = ('<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
|
||||
'<title>金牛财务往来</title><p>' + html.escape(message)
|
||||
+ '</p><a href="' + home + '">返回统一门户</a></html>').encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _portal_redirect(self, target, cookie=None):
|
||||
self.send_response(303)
|
||||
self.send_header("Location", target)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
if cookie:
|
||||
self.send_header("Set-Cookie", cookie)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def _portal_callback(self, query, db_path):
|
||||
if not enabled():
|
||||
self.send_error(404)
|
||||
return
|
||||
secure = "; Secure" if os.environ.get("PORTAL_COOKIE_SECURE") == "1" else ""
|
||||
flags = "; HttpOnly; SameSite=Lax; Path=/portal-sso" + secure
|
||||
if "ticket" not in query:
|
||||
verifier = secrets.token_hex(32)
|
||||
theme = "night" if query.get("theme") == ["night"] else "day"
|
||||
self._portal_redirect(base_url("PORTAL_PUBLIC_URL") + "/api/sso/apps/finance/authorize?"
|
||||
+ urlencode({"challenge": hashlib.sha256(verifier.encode()).hexdigest()}),
|
||||
"cw_portal_proof=" + verifier + "." + theme + flags + "; Max-Age=120")
|
||||
return
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(self.headers.get("Cookie", ""))
|
||||
proof = cookie.get("cw_portal_proof")
|
||||
parts = proof.value.split(".") if proof else []
|
||||
ticket = query["ticket"][0]
|
||||
if len(parts) != 2 or not re.fullmatch("[a-f0-9]{64}", parts[0]) or not re.fullmatch("[a-f0-9]{64}", ticket):
|
||||
self._portal_error(401, "登录请求已失效,请从统一门户重新进入。")
|
||||
return
|
||||
connection = connect(db_path)
|
||||
try:
|
||||
result = call("exchange", {"ticket": ticket, "verifier": parts[0]})
|
||||
who = result["identity"]
|
||||
handle = result["session_handle"]
|
||||
if not re.fullmatch("[a-f0-9]{64}", handle) or not re.fullmatch("[a-f0-9-]{36}", who["empid"]):
|
||||
raise ValueError("Invalid identity")
|
||||
user = connection.execute("SELECT * FROM users WHERE id=? AND status='active'", (who["local_uid"],)).fetchone()
|
||||
if user is None:
|
||||
raise ValueError("Local account unavailable")
|
||||
with connection:
|
||||
connection.execute("INSERT OR IGNORE INTO portal_identity(uid,empid) VALUES (?,?)", (user["id"], who["empid"]))
|
||||
binding = connection.execute("SELECT empid FROM portal_identity WHERE uid=?", (user["id"],)).fetchone()
|
||||
if not binding or binding["empid"] != who["empid"]:
|
||||
raise ValueError("Binding mismatch")
|
||||
token = auth.create_session(connection, user["id"])
|
||||
with connection:
|
||||
connection.execute("INSERT INTO portal_sessions(token_hash,handle,empid,uid) VALUES (?,?,?,?)",
|
||||
(hashlib.sha256(token.encode()).hexdigest(), handle, who["empid"], user["id"]))
|
||||
connection.execute("DELETE FROM portal_sessions WHERE token_hash NOT IN (SELECT token_hash FROM sessions)")
|
||||
auth.audit(connection, "portal_login", actor=user, detail=who["empid"])
|
||||
target = "/admin.html" if user["role"] == "admin" else "/company.html"
|
||||
theme = "night" if parts[1] == "night" else "day"
|
||||
self.send_response(303)
|
||||
self._set_session_cookie(token)
|
||||
self.send_header("Set-Cookie", "cw_portal_proof=" + flags + "; Max-Age=0")
|
||||
self.send_header("Location", target + "?theme=" + theme)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
except Exception:
|
||||
self._portal_error(401, "无法进入,请检查门户授权、账号绑定及子系统可用状态。")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def send_head(self):
|
||||
# Serve the existing two business surfaces, with account controls hidden
|
||||
# before first paint. The API gate separately blocks native credentials.
|
||||
path = urlsplit(self.path).path
|
||||
if enabled() and path in {"/admin.html", "/company.html"}:
|
||||
from pathlib import Path
|
||||
body = Path(self.directory, path[1:]).read_text(encoding="utf-8")
|
||||
home = base_url("PORTAL_PUBLIC_URL")
|
||||
script = '<script>window.PORTAL_HOME=' + json.dumps(home) + ';</script>'
|
||||
style = '<style>.account-box,.portal-native-account{display:none!important}</style>'
|
||||
body = body.replace('</head>', script + style + '</head>')
|
||||
body = body.replace('<nav class="side-nav">', '<nav class="side-nav"><a data-return-portal href="'
|
||||
+ html.escape(home, quote=True) + '">← 返回统一门户</a>')
|
||||
data = body.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
return io.BytesIO(data)
|
||||
return super().send_head()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
# Callback URLs contain short-lived credentials. Never write them to logs.
|
||||
if urlsplit(self.path).path == "/portal-sso":
|
||||
return
|
||||
super().log_message(fmt, *args)
|
||||
@@ -0,0 +1,135 @@
|
||||
import hashlib
|
||||
from http.client import HTTPConnection
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bank_importer import auth, portal_sso
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
import server
|
||||
from test_server_auth import Client
|
||||
|
||||
|
||||
class PortalSsoTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.db_path = Path(self.temp.name) / 'app.db'
|
||||
self.db = connect(self.db_path)
|
||||
self.addCleanup(self.db.close)
|
||||
migrate(self.db)
|
||||
portal_sso.migrate(self.db)
|
||||
with self.db:
|
||||
self.db.execute('INSERT INTO companies(name,created_at,updated_at) VALUES (?,?,?)', ('测试公司甲', utc_now(), utc_now()))
|
||||
self.db.execute('INSERT INTO companies(name,created_at,updated_at) VALUES (?,?,?)', ('测试公司乙', utc_now(), utc_now()))
|
||||
self.uid = auth.create_user(self.db, 'staff-a', 'UnusedPass123', 'company', company_id=1)
|
||||
self.token = auth.create_session(self.db, self.uid)
|
||||
self.empid = '11111111-2222-3333-4444-555555555555'
|
||||
self.claims = {'active': True, 'identity': {'empid': self.empid, 'local_uid': self.uid}}
|
||||
self.env = patch.dict(os.environ, {'PORTAL_SSO_ENABLED': '1', 'PORTAL_PUBLIC_URL': 'http://portal.test'})
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
|
||||
def bind_session(self):
|
||||
with self.db:
|
||||
self.db.execute('INSERT INTO portal_sessions VALUES (?,?,?,?)',
|
||||
(hashlib.sha256(self.token.encode()).hexdigest(), 'a' * 64, self.empid, self.uid))
|
||||
|
||||
def test_native_session_does_not_bypass_portal(self):
|
||||
with patch.object(portal_sso, 'call') as call:
|
||||
self.assertIsNone(portal_sso.resolve(self.db, self.token))
|
||||
call.assert_not_called()
|
||||
|
||||
def test_native_role_and_company_are_retained(self):
|
||||
self.bind_session()
|
||||
with patch.object(portal_sso, 'call', return_value=self.claims):
|
||||
user = portal_sso.resolve(self.db, self.token)
|
||||
self.assertEqual('company', user['role'])
|
||||
self.assertEqual(1, user['company_id'])
|
||||
|
||||
def test_revocation_mismatch_and_local_disable_reject(self):
|
||||
self.bind_session()
|
||||
with patch.object(portal_sso, 'call', return_value={'active': False}):
|
||||
self.assertIsNone(portal_sso.resolve(self.db, self.token))
|
||||
self.assertIsNone(auth.resolve_session(self.db, self.token))
|
||||
|
||||
def test_portal_outage_does_not_fallback(self):
|
||||
self.bind_session()
|
||||
with patch.object(portal_sso, 'call', side_effect=TimeoutError):
|
||||
with self.assertRaises(TimeoutError):
|
||||
portal_sso.resolve(self.db, self.token)
|
||||
|
||||
def test_http_guards_company_scope_and_hidden_credentials(self):
|
||||
self.bind_session()
|
||||
class Quiet(server.AppHandler):
|
||||
def log_message(self, *args): pass
|
||||
with patch.object(server, 'DB_PATH', self.db_path), patch.object(portal_sso, 'call', return_value=self.claims):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), Quiet)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = Client('127.0.0.1', httpd.server_port)
|
||||
self.assertEqual(401, client.get('/api/company/accounts')[0])
|
||||
client.cookies[server.SESSION_COOKIE] = self.token
|
||||
self.assertEqual(200, client.get('/api/company/accounts?company_id=2')[0])
|
||||
self.assertEqual(403, client.get('/api/admin/companies')[0])
|
||||
status, _, data = client.get('/api/me')
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(1, json.loads(data)['company_id'])
|
||||
for path in ['/api/login', '/api/logout', '/api/password/change', '/api/admin/users', '/api/admin/users/1/reset-password']:
|
||||
self.assertEqual(403, client.post_json(path, {})[0])
|
||||
status, _, data = client.get('/company.html')
|
||||
self.assertEqual(200, status)
|
||||
self.assertIn(b'data-return-portal', data)
|
||||
self.assertIn(b'display:none!important', data)
|
||||
self.assertEqual('http://portal.test', client.get('/login-company.html')[1]['location'])
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_callback_checks_browser_proof_and_pins_local_identity(self):
|
||||
class Quiet(server.AppHandler):
|
||||
def log_message(self, *args): pass
|
||||
def response(operation, payload):
|
||||
if operation == 'exchange':
|
||||
self.assertEqual('a' * 64, payload['verifier'])
|
||||
return {'session_handle': 'b' * 64, 'identity': self.claims['identity']}
|
||||
return self.claims
|
||||
with patch.object(server, 'DB_PATH', self.db_path), patch.object(portal_sso, 'call', side_effect=response):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), Quiet)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = Client('127.0.0.1', httpd.server_port)
|
||||
status, headers, _ = client.get('/portal-sso?theme=night')
|
||||
self.assertEqual(303, status)
|
||||
self.assertIn('/api/sso/apps/finance/authorize?challenge=', headers['location'])
|
||||
client.cookies.clear()
|
||||
status, _, body = client.get('/portal-sso?ticket=' + 'c' * 64)
|
||||
self.assertEqual(401, status)
|
||||
self.assertIn(b'http://portal.test', body)
|
||||
conn = HTTPConnection('127.0.0.1', httpd.server_port)
|
||||
conn.request('GET', '/portal-sso?ticket=' + 'c' * 64,
|
||||
headers={'Cookie': 'cw_portal_proof=' + 'a' * 64 + '.night'})
|
||||
res = conn.getresponse()
|
||||
self.assertEqual(303, res.status)
|
||||
self.assertEqual('/company.html?theme=night', res.getheader('Location'))
|
||||
for key, value in res.getheaders():
|
||||
if key.lower() == 'set-cookie':
|
||||
cookies = SimpleCookie(value)
|
||||
if server.SESSION_COOKIE in cookies:
|
||||
client.cookies[server.SESSION_COOKIE] = cookies[server.SESSION_COOKIE].value
|
||||
res.read()
|
||||
conn.close()
|
||||
self.assertEqual(1, json.loads(client.get('/api/me')[2])['company_id'])
|
||||
self.assertEqual(self.empid, self.db.execute('SELECT empid FROM portal_identity WHERE uid=?', (self.uid,)).fetchone()['empid'])
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
thread.join()
|
||||
+36
-36
@@ -433,12 +433,12 @@ async function initAuthGuard() {
|
||||
try {
|
||||
const response = await fetch("/api/me");
|
||||
if (response.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return false;
|
||||
}
|
||||
const me = await response.json();
|
||||
if (!response.ok || me.role !== portal) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return false;
|
||||
}
|
||||
state.me = me;
|
||||
@@ -512,7 +512,7 @@ function initShell() {
|
||||
try {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
} catch { /* 网络异常时仍然回到登录页 */ }
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
}));
|
||||
$$("[data-metric-link]").forEach((card) => {
|
||||
const activate = () => showView(card.dataset.metricLink);
|
||||
@@ -810,7 +810,7 @@ async function loadCompanyAccounts() {
|
||||
showTableLoading(tbody, 6);
|
||||
const response = await fetch("/api/company/accounts").catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
if (!response?.ok) {
|
||||
@@ -984,7 +984,7 @@ async function loadAdminAuditQueue() {
|
||||
]);
|
||||
|
||||
if ([accountsRes, manualsRes, exceptionsRes].some((res) => res?.status === 401)) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
if (![accountsRes, manualsRes, exceptionsRes].every((res) => res?.ok)) {
|
||||
@@ -1900,7 +1900,7 @@ async function loadAdminRemindersPending() {
|
||||
const sendAll = $("#pending-send-all");
|
||||
if (!list) return;
|
||||
const response = await fetch("/api/admin/reminders/pending").catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) return;
|
||||
const findings = result.findings || [];
|
||||
@@ -1957,7 +1957,7 @@ async function loadAdminRemindersHistory(sourceFilter) {
|
||||
if (!tbody) return;
|
||||
const query = sourceFilter && sourceFilter !== "all" ? `?source=${sourceFilter === "system" ? "auto" : "manual"}` : "";
|
||||
const response = await fetch(`/api/admin/reminders${query}`).catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) return;
|
||||
const items = result.reminders || [];
|
||||
@@ -2002,7 +2002,7 @@ async function sendPendingReminders(keys) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dedupe_keys: keys }),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return null; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return null; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) {
|
||||
showToast("发送失败", result?.message || "请稍后重试", "danger");
|
||||
@@ -2054,7 +2054,7 @@ function initAdminReminders() {
|
||||
|
||||
$("#reminder-scan-btn")?.addEventListener("click", async () => {
|
||||
const response = await fetch("/api/admin/reminders/scan", { method: "POST" }).catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) {
|
||||
showToast("扫描失败", result?.message || "请稍后重试", "danger");
|
||||
@@ -2138,7 +2138,7 @@ function initAdminReminders() {
|
||||
deadline: deadline || null,
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) {
|
||||
showToast("发送失败", result?.message || "请稍后重试", "danger");
|
||||
@@ -2179,7 +2179,7 @@ function initAdminReminders() {
|
||||
if (!remindBtn) return;
|
||||
const tr = remindBtn.closest("tr");
|
||||
const response = await fetch(`/api/admin/reminders/${tr.dataset.reminderId}/resend`, { method: "POST" }).catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) {
|
||||
showToast("再提醒失败", result?.message || "请稍后重试", "danger");
|
||||
@@ -2385,7 +2385,7 @@ function applyClosePanel(overview) {
|
||||
async function loadPeriodClose() {
|
||||
if (!$("#closingPanel")) return;
|
||||
const response = await fetch("/api/admin/period-closes").catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => null);
|
||||
if (!response?.ok || result?.status !== "ok") {
|
||||
showToast("结账状态加载失败", result?.message || "请稍后重试", "danger");
|
||||
@@ -2755,7 +2755,7 @@ function initAdmin() {
|
||||
body: JSON.stringify({ decision: apiDecision, reason }),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
@@ -2779,7 +2779,7 @@ function initAdmin() {
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
@@ -2801,7 +2801,7 @@ function initAdmin() {
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
@@ -2921,7 +2921,7 @@ function initAdmin() {
|
||||
const data = new FormData(form);
|
||||
const companyName = String(data.get("companyName") || "").trim();
|
||||
const loginName = String(data.get("loginName") || "").trim();
|
||||
const createUser = data.get("createUser") !== null;
|
||||
const createUser = !window.PORTAL_HOME && data.get("createUser") !== null;
|
||||
|
||||
const payload = {
|
||||
name: companyName,
|
||||
@@ -2936,7 +2936,7 @@ function initAdmin() {
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
@@ -2993,7 +2993,7 @@ function initAdmin() {
|
||||
const user = (usersResult?.users || []).find((u) => String(u.company_id) === String(companyId) && u.role === "company");
|
||||
if (!user) { showToast("重置失败", "该公司尚无公司登录账号", "danger"); return; }
|
||||
const response = await fetch(`/api/admin/users/${user.id}/reset-password`, { method: "POST" }).catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) { showToast("重置失败", result?.message || "请稍后重试", "danger"); return; }
|
||||
$("#btn-reset-pwd").disabled = true;
|
||||
@@ -3049,7 +3049,7 @@ function initAdmin() {
|
||||
async function loadSystemSettings() {
|
||||
const response = await fetch("/api/admin/settings").catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
@@ -3084,7 +3084,7 @@ function initAdmin() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ calculation_start_date: nextStart, reason }),
|
||||
}).catch(() => null);
|
||||
if (calcResp?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (calcResp?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const calcResult = await calcResp?.json().catch(() => ({}));
|
||||
if (!calcResp?.ok) {
|
||||
showToast("起算日保存失败", calcResult?.message || "请稍后重试", "danger");
|
||||
@@ -3106,7 +3106,7 @@ function initAdmin() {
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
@@ -3125,7 +3125,7 @@ function initAdmin() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings: { monthly_start_day: String(day || 5), gap_days: String(gapDays) } }),
|
||||
}).catch(() => null);
|
||||
if (reminderResp?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (reminderResp?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
if (!reminderResp?.ok) {
|
||||
showToast("提醒扫描参数保存失败", "", "danger");
|
||||
return;
|
||||
@@ -3234,7 +3234,7 @@ async function loadFlows() {
|
||||
if ($("#flowKeyword")?.value.trim()) params.set("keyword", $("#flowKeyword").value.trim());
|
||||
params.set("limit", "200");
|
||||
const response = await fetch(`/api/flows?${params.toString()}`).catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => null);
|
||||
if (!response?.ok || result?.status !== "ok") {
|
||||
showTableError(tbody, portal === "admin" ? 9 : 8);
|
||||
@@ -3415,7 +3415,7 @@ function renderCompanyManualRecords(records) {
|
||||
async function loadCompanyManualRecords() {
|
||||
if (!$("#manualRecordRows")) return;
|
||||
const response = await fetch("/api/company/manual-records").catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401 || response?.status === 403) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) {
|
||||
showToast("手工记录加载失败", result?.message || "请稍后重试", "danger");
|
||||
@@ -3493,7 +3493,7 @@ async function parseFile() {
|
||||
try {
|
||||
const response = await fetch("/api/parse", { method: "POST", body: formData });
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
result = await response.json();
|
||||
@@ -3634,7 +3634,7 @@ async function sheetReviewAction(batchId, sheetName, decision) {
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
@@ -3682,7 +3682,7 @@ async function confirmImport() {
|
||||
body: JSON.stringify({ sheets: pending }),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const outcome = await response?.json().catch(() => ({}));
|
||||
@@ -3764,7 +3764,7 @@ async function loadImportBatches() {
|
||||
showTableLoading(tbody, 8);
|
||||
const response = await fetch("/api/batches").catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
if (!response?.ok) {
|
||||
@@ -3894,7 +3894,7 @@ function applyCompanyWorkspace(payload) {
|
||||
async function loadCompanyWorkspace() {
|
||||
const response = await fetch("/api/company/workspace").catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return null;
|
||||
}
|
||||
const result = await response?.json().catch(() => null);
|
||||
@@ -3970,7 +3970,7 @@ function renderUnilateralMatchCard(event, peers) {
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
@@ -4131,7 +4131,7 @@ function initNotifications() {
|
||||
|
||||
async function loadReminders() {
|
||||
const response = await fetch("/api/company/reminders").catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response?.ok) return;
|
||||
const items = result.reminders || [];
|
||||
@@ -4184,7 +4184,7 @@ function initNotifications() {
|
||||
if (!id) return;
|
||||
const action = readBtn ? "acknowledge" : "resolve";
|
||||
const response = await fetch(`/api/company/reminders/${id}/${action}`, { method: "POST" }).catch(() => null);
|
||||
if (response?.status === 401) { window.location.href = "index.html"; return; }
|
||||
if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
|
||||
if (!response?.ok) return;
|
||||
await loadReminders();
|
||||
});
|
||||
@@ -4340,7 +4340,7 @@ async function loadTransfersSummary({ asOf } = {}) {
|
||||
const qs = params.toString();
|
||||
const response = await fetch(`/api/company/intercompany/summary${qs ? `?${qs}` : ""}`).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return null;
|
||||
}
|
||||
const result = await response?.json().catch(() => null);
|
||||
@@ -4570,7 +4570,7 @@ async function loadTransfersEvents({ reset = false } = {}) {
|
||||
// Always need a distinguishing filter so HEL-176 path is used (counterparty_id is enough)
|
||||
const response = await fetch(`/api/company/intercompany/events?${params}`).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => null);
|
||||
@@ -4641,7 +4641,7 @@ async function openTransferEvidence(eventId) {
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
const response = await fetch(`/api/company/transfer-events/${eventId}`).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => null);
|
||||
@@ -4974,7 +4974,7 @@ function initCompany() {
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
window.location.href = window.PORTAL_HOME || "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
|
||||
+24
-1
@@ -43,7 +43,30 @@
|
||||
apply(next, true);
|
||||
}
|
||||
|
||||
apply(read(), false);
|
||||
var incoming = new URLSearchParams(location.search).get("theme");
|
||||
if (incoming === "day" || incoming === "night") {
|
||||
try { localStorage.setItem(KEY, incoming); } catch (e) {}
|
||||
}
|
||||
apply(incoming === "day" || incoming === "night" ? incoming : read(), false);
|
||||
document.addEventListener("click", function(event) {
|
||||
var link = event.target.closest("[data-return-portal]");
|
||||
if (link) { var url = new URL(link.href); url.searchParams.set("theme", read()); link.href = url.toString(); }
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
if (!global.PORTAL_HOME) return;
|
||||
var checkbox = document.querySelector('[name="createUser"]');
|
||||
if (checkbox) { checkbox.checked = false; checkbox.disabled = true; checkbox.parentElement.classList.add("portal-native-account"); }
|
||||
var login = document.querySelector('[name="loginName"]');
|
||||
if (login) { login.required = false; login.closest(".field").classList.add("portal-native-account"); }
|
||||
var reset = document.querySelector('#btn-reset-pwd');
|
||||
if (reset) reset.classList.add("portal-native-account");
|
||||
var sub = document.querySelector('#companyDialog .modal-sub');
|
||||
if (sub) sub.textContent = "登记成员公司。员工账号由统一门户管理,财务角色与公司范围按员工单独绑定。";
|
||||
var hint = document.querySelector('#companyForm .hint');
|
||||
if (hint) hint.textContent = "完成公司登记后,为指定员工配置财务权限和公司范围。";
|
||||
var submit = document.querySelector('#companyForm button[type="submit"]');
|
||||
if (submit) submit.textContent = "创建公司";
|
||||
});
|
||||
document.addEventListener("click", function (event) {
|
||||
var btn = event.target.closest("[data-theme-set]");
|
||||
if (!btn) return;
|
||||
|
||||
Reference in New Issue
Block a user