mcp-uof 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_uof/__init__.py +4 -0
- mcp_uof/_log.py +15 -0
- mcp_uof/auth/__init__.py +68 -0
- mcp_uof/auth/base.py +176 -0
- mcp_uof/auth/session.py +152 -0
- mcp_uof/auth/token.py +256 -0
- mcp_uof/domains/__init__.py +2 -0
- mcp_uof/domains/dms/__init__.py +2 -0
- mcp_uof/domains/dms/endpoints.py +13 -0
- mcp_uof/domains/eip/__init__.py +2 -0
- mcp_uof/domains/eip/endpoints.py +17 -0
- mcp_uof/domains/system/__init__.py +1 -0
- mcp_uof/domains/system/endpoints.py +8 -0
- mcp_uof/domains/system/tools.py +12 -0
- mcp_uof/domains/uchat/__init__.py +2 -0
- mcp_uof/domains/uchat/endpoints.py +13 -0
- mcp_uof/domains/wkf/__init__.py +1 -0
- mcp_uof/domains/wkf/endpoints.py +9 -0
- mcp_uof/domains/wkf/service.py +807 -0
- mcp_uof/ops/__init__.py +33 -0
- mcp_uof/ops/base.py +84 -0
- mcp_uof/ops/router.py +151 -0
- mcp_uof/ops/soap.py +134 -0
- mcp_uof/ops/web.py +1235 -0
- mcp_uof/ops/web_apply/__init__.py +8 -0
- mcp_uof/ops/web_apply/base.py +35 -0
- mcp_uof/ops/web_apply/helpers.py +80 -0
- mcp_uof/ops/web_apply/purchase_order.py +610 -0
- mcp_uof/ops/web_apply/registry.py +56 -0
- mcp_uof/ops/web_apply/router.py +92 -0
- mcp_uof/server.py +268 -0
- mcp_uof/soap_client.py +220 -0
- mcp_uof/sse_server.py +105 -0
- mcp_uof-0.1.0.dist-info/METADATA +221 -0
- mcp_uof-0.1.0.dist-info/RECORD +38 -0
- mcp_uof-0.1.0.dist-info/WHEEL +4 -0
- mcp_uof-0.1.0.dist-info/entry_points.txt +3 -0
- mcp_uof-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp_uof/__init__.py
ADDED
mcp_uof/_log.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""共用診斷輸出:一律走 stderr。
|
|
2
|
+
|
|
3
|
+
stdio MCP 下 stdout 專供 JSON-RPC,任何混入 stdout 的文字都會破壞協定。診斷/log 訊息一律經
|
|
4
|
+
本模組輸出到 stderr。這條規則只在這裡有單一實作——各模組 `from .._log import eprint`,不要再各自
|
|
5
|
+
複製一份(複製就會在某處漏改而污染 stdout)。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def eprint(*args, **kwargs) -> None:
|
|
13
|
+
"""print 到 stderr(呼叫端可覆寫 file)。"""
|
|
14
|
+
kwargs.setdefault("file", sys.stderr)
|
|
15
|
+
print(*args, **kwargs)
|
mcp_uof/auth/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UOF MCP Server — Authentication package.
|
|
3
|
+
|
|
4
|
+
Two providers coexist behind a common ABC:
|
|
5
|
+
|
|
6
|
+
- TokenAuthProvider — RSA-encrypted account/password → SOAP GetToken → bearer token
|
|
7
|
+
(works against UOF deployments with PublicAPI module installed)
|
|
8
|
+
- SessionAuthProvider — Login.aspx form post → ASPXFORMSAUTH cookie + storage state
|
|
9
|
+
(works against UOF deployments without PublicAPI; web-only)
|
|
10
|
+
|
|
11
|
+
There is no user-selectable mode: which mechanism (SOAP/token vs web/session) a tool uses is
|
|
12
|
+
a per-tool decision baked into ops.router. Both providers coexist and are built lazily from the
|
|
13
|
+
same identity (one process = one UOF_ACCOUNT). See docs/architecture.md.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .base import (
|
|
17
|
+
AuthMode,
|
|
18
|
+
AuthProvider,
|
|
19
|
+
get_provider,
|
|
20
|
+
get_session_provider,
|
|
21
|
+
get_token_provider,
|
|
22
|
+
require_auth,
|
|
23
|
+
)
|
|
24
|
+
from .token import TokenAuthProvider
|
|
25
|
+
|
|
26
|
+
# Kept for backwards compatibility with existing imports that did `from .auth import get_token`.
|
|
27
|
+
# Returns the SOAP identity's bearer token; tools/mechanisms should prefer their own provider.
|
|
28
|
+
def get_token() -> str:
|
|
29
|
+
return get_token_provider().fetch_token()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rsa_encrypt(public_key_base64: str, plaintext: str) -> str:
|
|
33
|
+
"""Compatibility shim — RSA-encrypt with the token provider's helper.
|
|
34
|
+
|
|
35
|
+
Only meaningful in token mode (session mode never RSA-encrypts). Kept so existing
|
|
36
|
+
callers/tests that did `from mcp_uof.auth import rsa_encrypt` keep working after the
|
|
37
|
+
auth package split.
|
|
38
|
+
"""
|
|
39
|
+
from .token import _rsa_encrypt
|
|
40
|
+
return _rsa_encrypt(public_key_base64, plaintext)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_credentials():
|
|
44
|
+
"""Compatibility shim used by domains/system/tools.py."""
|
|
45
|
+
provider = get_provider()
|
|
46
|
+
if not hasattr(provider, "read_credentials"):
|
|
47
|
+
return None
|
|
48
|
+
return provider.read_credentials()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def credentials_file() -> str:
|
|
52
|
+
"""Compatibility shim — returns the active provider's persistence file."""
|
|
53
|
+
return get_provider().credentials_file()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"AuthMode",
|
|
58
|
+
"AuthProvider",
|
|
59
|
+
"TokenAuthProvider",
|
|
60
|
+
"credentials_file",
|
|
61
|
+
"get_provider",
|
|
62
|
+
"get_session_provider",
|
|
63
|
+
"get_token",
|
|
64
|
+
"get_token_provider",
|
|
65
|
+
"read_credentials",
|
|
66
|
+
"require_auth",
|
|
67
|
+
"rsa_encrypt",
|
|
68
|
+
]
|
mcp_uof/auth/base.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auth provider base + the two per-mechanism providers.
|
|
3
|
+
|
|
4
|
+
`AuthProvider` defines the minimal surface every concrete provider must satisfy. There are
|
|
5
|
+
two: `get_token_provider()` (SOAP/PublicAPI — RSA + GetToken) and `get_session_provider()`
|
|
6
|
+
(web — Login.aspx cookie). Each is cached per process so it can hold long-lived state
|
|
7
|
+
(token cache, browser session). `require_auth` validates whichever the tool's bound
|
|
8
|
+
mechanism needs; `get_provider()` remains as a token-provider alias for compat shims.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from functools import wraps
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthMode(str, Enum):
|
|
20
|
+
"""認證機制描述符:token = RSA + GetToken(SOAP 機制用);session = Login.aspx cookie(web 機制用)。
|
|
21
|
+
|
|
22
|
+
這只是各 UOF 機制標記自己用哪種認證,不是使用者可選的模式。
|
|
23
|
+
"""
|
|
24
|
+
TOKEN = "token"
|
|
25
|
+
SESSION = "session"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthProvider(ABC):
|
|
29
|
+
"""Abstract auth provider; both token + session implementations live behind this."""
|
|
30
|
+
|
|
31
|
+
mode: AuthMode # set by subclass
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def ensure_valid(self) -> None:
|
|
35
|
+
"""Refresh credentials if needed. Raises RuntimeError on hard auth failure."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def status_report(self) -> str:
|
|
39
|
+
"""Human-readable check_auth output. Triggers a refresh if needed."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def credentials_file(self) -> str:
|
|
43
|
+
"""Path to the on-disk credential/cookie cache for this identity."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def clear(self, all_identities: bool = False) -> None:
|
|
47
|
+
"""Wipe cached credentials (memory + disk)."""
|
|
48
|
+
|
|
49
|
+
def required_env_help(self) -> str:
|
|
50
|
+
"""Bullet list of env vars this provider requires — shown when auth fails."""
|
|
51
|
+
return (
|
|
52
|
+
"- `UOF_BASE_URL`\n"
|
|
53
|
+
"- `UOF_ACCOUNT`\n"
|
|
54
|
+
"- `UOF_PASSWORD`"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def auth_failure_message(detail: str = "") -> str:
|
|
59
|
+
"""
|
|
60
|
+
登入/憑證失敗時給介接 AI 的固定訊息。
|
|
61
|
+
|
|
62
|
+
目的:帳號密碼失效或登入有誤時,要讓介接的 AI 對使用者清楚、固定地說明這是設定層級
|
|
63
|
+
的登入問題,而不是讓 AI 自行猜測或沿用對話記憶中的帳號資訊臆測。
|
|
64
|
+
"""
|
|
65
|
+
account = os.getenv("UOF_ACCOUNT", "(未設定)")
|
|
66
|
+
base_url = os.getenv("UOF_BASE_URL", "(未設定)")
|
|
67
|
+
detail_line = f"原因:{detail}\n" if detail else ""
|
|
68
|
+
return (
|
|
69
|
+
f"🔒 UOF 登入失敗:無法以帳號「{account}」取得有效憑證。\n"
|
|
70
|
+
f"{detail_line}"
|
|
71
|
+
f"連線目標:{base_url}\n\n"
|
|
72
|
+
"這是設定層級的問題,需要由設定這個 MCP 的人處理,無法在對話中自行解決。常見原因:\n"
|
|
73
|
+
"- UOF_ACCOUNT 或 UOF_PASSWORD 不正確,或密碼已在 UOF 變更\n"
|
|
74
|
+
"- 該帳號在 UOF 被停用,或未設定部門/職級\n"
|
|
75
|
+
"- 此 UOF 站台未提供 PublicAPI,SOAP 認證無法運作\n\n"
|
|
76
|
+
"請直接、明確告訴使用者:「UOF 登入失敗,請檢查 MCP 設定中的帳號、密碼與連線設定是否正確」。\n"
|
|
77
|
+
"不要猜測其他原因、不要沿用先前對話記得的帳號或密碼、也不要假設已經解決。"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_token_provider: Optional[AuthProvider] = None
|
|
82
|
+
_session_provider: Optional[AuthProvider] = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_token_provider() -> AuthProvider:
|
|
86
|
+
"""SOAP 機制用的認證(RSA 帳密 → GetToken token)。惰性建立、單例。"""
|
|
87
|
+
global _token_provider
|
|
88
|
+
if _token_provider is None:
|
|
89
|
+
from .token import TokenAuthProvider
|
|
90
|
+
_token_provider = TokenAuthProvider()
|
|
91
|
+
return _token_provider
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_session_provider() -> AuthProvider:
|
|
95
|
+
"""web 機制用的認證(Login.aspx 登入 → cookie / storage state)。惰性建立、單例。"""
|
|
96
|
+
global _session_provider
|
|
97
|
+
if _session_provider is None:
|
|
98
|
+
from .session import SessionAuthProvider
|
|
99
|
+
_session_provider = SessionAuthProvider()
|
|
100
|
+
return _session_provider
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_provider() -> AuthProvider:
|
|
104
|
+
"""相容入口 = SOAP 身份(token)的認證 provider。
|
|
105
|
+
|
|
106
|
+
現在機制是每工具綁定的(見 ops.router):SOAP 工具用 token、web 工具用 session,各自取得
|
|
107
|
+
所需 provider。此函式保留為 token provider 的別名,給仍以單一身份語意呼叫的相容 shim
|
|
108
|
+
(get_token / credentials_file / domains.system)。"""
|
|
109
|
+
return get_token_provider()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def reset_provider_for_tests() -> None:
|
|
113
|
+
"""Test hook — drop cached providers so the next getter rebuilds them."""
|
|
114
|
+
global _token_provider, _session_provider
|
|
115
|
+
_token_provider = None
|
|
116
|
+
_session_provider = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _provider_for(mechanism: str) -> AuthProvider:
|
|
120
|
+
"""機制 → 它需要的認證 provider。soap→token、web→session。"""
|
|
121
|
+
if mechanism == "web":
|
|
122
|
+
return get_session_provider()
|
|
123
|
+
return get_token_provider() # "soap" 與未知值都用 token(與舊行為一致)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _mechanisms_for_call(op: str, args: tuple, kwargs: dict) -> list:
|
|
127
|
+
"""依本次呼叫參數決定認證機制。
|
|
128
|
+
|
|
129
|
+
`BINDING` 是工具層預設;起單相關工具還有表單層分派:registry 命中的表單會走 web_apply,
|
|
130
|
+
因此入口認證也要驗 session,不能先被 SOAP token 擋下。
|
|
131
|
+
"""
|
|
132
|
+
from ..ops.router import mechanisms_for
|
|
133
|
+
|
|
134
|
+
key = None
|
|
135
|
+
if op in ("get_form_structure_by_id",):
|
|
136
|
+
key = kwargs.get("form_id") if "form_id" in kwargs else (args[0] if args else None)
|
|
137
|
+
elif op in ("get_form_structure", "preview_workflow", "apply_form"):
|
|
138
|
+
key = kwargs.get("form_version_id") if "form_version_id" in kwargs else (args[0] if args else None)
|
|
139
|
+
|
|
140
|
+
if key:
|
|
141
|
+
from ..ops import web_apply
|
|
142
|
+
if web_apply.resolve(str(key)):
|
|
143
|
+
return ["web"]
|
|
144
|
+
if op in ("get_form_structure", "preview_workflow", "apply_form"):
|
|
145
|
+
return ["web"]
|
|
146
|
+
return mechanisms_for(op)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def require_auth(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
150
|
+
"""工具入口的認證閘——依「該工具設計上綁定的機制」驗證對應認證,而非一律驗 SOAP token。
|
|
151
|
+
|
|
152
|
+
一個工具可走多條機制時(fallback,SOAP 優先)採 **OR**:任一機制的認證通過就放行;
|
|
153
|
+
只有當它所有可走機制的認證**都**不過,才回固定的失敗訊息(對使用者只有「通過與否」)。
|
|
154
|
+
驗證集中在這一道入口閘,工具內部不再重複驗證;機制本身的失效重試(token 自動刷新、
|
|
155
|
+
web session 重登)仍各自在 backend 處理。失敗回字串而非 raise,避免 MCP client 收到 isError。
|
|
156
|
+
"""
|
|
157
|
+
op = func.__name__.removeprefix("uof_custom_")
|
|
158
|
+
# fail-loud(裝飾期):op 必須在 BINDING 有登錄機制綁定。漏綁、工具改名、或裝飾順序錯誤
|
|
159
|
+
# (@require_auth 被放到 @mcp.tool 外層導致 __name__ 變 wrapper)都會讓 op 解析不到綁定,
|
|
160
|
+
# 在 import server 時就立刻爆,而非讓 web 工具在執行期靜默回歸成被 SOAP token 擋。
|
|
161
|
+
from ..ops.router import mechanisms_for as _validate_op
|
|
162
|
+
_validate_op(op)
|
|
163
|
+
|
|
164
|
+
@wraps(func)
|
|
165
|
+
def wrapper(*args, **kwargs):
|
|
166
|
+
errors = []
|
|
167
|
+
for mech in _mechanisms_for_call(op, args, kwargs):
|
|
168
|
+
try:
|
|
169
|
+
_provider_for(mech).ensure_valid()
|
|
170
|
+
except Exception as e:
|
|
171
|
+
errors.append(f"{mech}: {' '.join(str(e).split())[:120]}")
|
|
172
|
+
continue
|
|
173
|
+
return func(*args, **kwargs) # 任一機制認證通過即放行;工具本體例外不包成登入失敗
|
|
174
|
+
# 所有可走機制都認證不過 → 才是真正不可用
|
|
175
|
+
return auth_failure_message(";".join(errors))
|
|
176
|
+
return wrapper
|
mcp_uof/auth/session.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SessionAuthProvider — UOF Login.aspx form post + cookie jar.
|
|
3
|
+
|
|
4
|
+
For UOF deployments without PublicAPI module (the SOAP/ASMX backend is absent), the only
|
|
5
|
+
way to drive operations is to log in as a real user would and reuse the session. This
|
|
6
|
+
provider:
|
|
7
|
+
|
|
8
|
+
1. Performs the ASP.NET WebForms login (handles __VIEWSTATE round-trip)
|
|
9
|
+
2. Stores the authenticated `.ASPXFORMSAUTH_UOF` cookie via Playwright's storage_state
|
|
10
|
+
so the cookie jar can be reused by the Web ops backend (which drives Telerik UI)
|
|
11
|
+
and survives process restarts.
|
|
12
|
+
3. Re-logs in transparently when the session expires (typical ASP.NET idle timeout ~20m).
|
|
13
|
+
|
|
14
|
+
NOTE: The actual browser/Playwright is owned by `ops.web.WebBackend` — this provider only
|
|
15
|
+
*requests* a session refresh and reads identity from the resulting page. We keep the
|
|
16
|
+
Playwright lifecycle in one place to avoid two parallel browsers fighting for the same
|
|
17
|
+
storage_state file.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import stat
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from .base import AuthMode, AuthProvider
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
from .._log import eprint as _eprint # 診斷一律走 stderr(共用,勿在各檔複製)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CREDENTIALS_DIR = Path(os.path.expanduser("~")) / ".uof"
|
|
37
|
+
DEFAULT_SESSION_TTL = 20 * 60 # ASP.NET 預設 idle timeout = 20 min
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ensure_dir() -> None:
|
|
41
|
+
if not CREDENTIALS_DIR.exists():
|
|
42
|
+
CREDENTIALS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SessionAuthProvider(AuthProvider):
|
|
46
|
+
mode = AuthMode.SESSION
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._last_validated: float = 0.0
|
|
50
|
+
self._identity_cached: Optional[str] = None
|
|
51
|
+
# Logged-in user's display info, populated after successful login.
|
|
52
|
+
self.logged_in_display_name: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
# ── Identity helpers ────────────────────────────────────────────
|
|
55
|
+
def _identity_key(self) -> str:
|
|
56
|
+
return "|".join(
|
|
57
|
+
[
|
|
58
|
+
os.getenv("UOF_BASE_URL", ""),
|
|
59
|
+
os.getenv("UOF_ACCOUNT", ""),
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def credentials_file(self) -> str:
|
|
64
|
+
"""Path to the Playwright storage_state JSON for this identity."""
|
|
65
|
+
account = os.getenv("UOF_ACCOUNT", "anonymous")
|
|
66
|
+
safe_account = re.sub(r"[^A-Za-z0-9_.-]", "_", account) or "anonymous"
|
|
67
|
+
digest = hashlib.sha256(self._identity_key().encode("utf-8")).hexdigest()[:8]
|
|
68
|
+
return str(CREDENTIALS_DIR / f"storage_state-{safe_account}-{digest}.json")
|
|
69
|
+
|
|
70
|
+
def required_env_help(self) -> str:
|
|
71
|
+
return (
|
|
72
|
+
"- `UOF_BASE_URL`\n"
|
|
73
|
+
"- `UOF_ACCOUNT`\n"
|
|
74
|
+
"- `UOF_PASSWORD`"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# ── AuthProvider surface ────────────────────────────────────────
|
|
78
|
+
def ensure_valid(self) -> None:
|
|
79
|
+
"""Trigger the web backend to verify or re-establish the browser session."""
|
|
80
|
+
# Avoid hammering the server: re-validate at most once per 30s.
|
|
81
|
+
if time.time() - self._last_validated < 30 and self._identity_cached == self._identity_key():
|
|
82
|
+
return
|
|
83
|
+
self._validate_env()
|
|
84
|
+
# Delegate to ops.web.WebBackend, which owns the Playwright runtime.
|
|
85
|
+
from ..ops.web import get_web_runtime
|
|
86
|
+
runtime = get_web_runtime()
|
|
87
|
+
display_name = runtime.ensure_logged_in()
|
|
88
|
+
self.logged_in_display_name = display_name
|
|
89
|
+
self._last_validated = time.time()
|
|
90
|
+
self._identity_cached = self._identity_key()
|
|
91
|
+
|
|
92
|
+
def status_report(self) -> str:
|
|
93
|
+
account = os.getenv("UOF_ACCOUNT", "(未設定)")
|
|
94
|
+
base_url = os.getenv("UOF_BASE_URL", "(未設定)")
|
|
95
|
+
try:
|
|
96
|
+
self.ensure_valid()
|
|
97
|
+
except RuntimeError as e:
|
|
98
|
+
from .base import auth_failure_message
|
|
99
|
+
return auth_failure_message(str(e))
|
|
100
|
+
display = self.logged_in_display_name or account
|
|
101
|
+
return (
|
|
102
|
+
f"✅ Session 有效,目前以 **{account}**({display})的身份操作"
|
|
103
|
+
f"(認證:網頁 session)。\n"
|
|
104
|
+
f"🔗 Base URL: {base_url}\n"
|
|
105
|
+
f"📂 Storage state: `{self.credentials_file()}`\n"
|
|
106
|
+
f"⚠️ 網頁機制:操作走 Playwright 驅動 UOF 頁面,並非所有工具皆已實作。"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def clear(self, all_identities: bool = False) -> None:
|
|
110
|
+
_ensure_dir()
|
|
111
|
+
if all_identities:
|
|
112
|
+
for path in CREDENTIALS_DIR.glob("storage_state-*.json"):
|
|
113
|
+
path.unlink(missing_ok=True)
|
|
114
|
+
_eprint(f"[auth.session] 🗑️ 已清除 storage state: {path}")
|
|
115
|
+
else:
|
|
116
|
+
p = Path(self.credentials_file())
|
|
117
|
+
if p.exists():
|
|
118
|
+
p.unlink()
|
|
119
|
+
_eprint(f"[auth.session] 🗑️ 已清除 storage state: {p}")
|
|
120
|
+
# Force web runtime to reset on next call
|
|
121
|
+
from ..ops.web import reset_web_runtime
|
|
122
|
+
reset_web_runtime()
|
|
123
|
+
self._last_validated = 0.0
|
|
124
|
+
self._identity_cached = None
|
|
125
|
+
|
|
126
|
+
# ── Internal ────────────────────────────────────────────────────
|
|
127
|
+
def _validate_env(self) -> None:
|
|
128
|
+
missing = [
|
|
129
|
+
k for k in ("UOF_BASE_URL", "UOF_ACCOUNT", "UOF_PASSWORD")
|
|
130
|
+
if not os.getenv(k)
|
|
131
|
+
]
|
|
132
|
+
if missing:
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
"UOF_BASE_URL、UOF_ACCOUNT、UOF_PASSWORD 必須全部設定。"
|
|
135
|
+
f"目前缺少: {', '.join(missing)}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# ── Storage-state metadata sidecar (account, timestamps) ────────
|
|
139
|
+
def write_metadata(self) -> None:
|
|
140
|
+
"""Write a small JSON next to storage_state recording who logged in + when."""
|
|
141
|
+
_ensure_dir()
|
|
142
|
+
meta_path = self.credentials_file() + ".meta"
|
|
143
|
+
data = {
|
|
144
|
+
"account": os.getenv("UOF_ACCOUNT", ""),
|
|
145
|
+
"base_url": os.getenv("UOF_BASE_URL", ""),
|
|
146
|
+
"identity": self._identity_key(),
|
|
147
|
+
"logged_in_at": time.time(),
|
|
148
|
+
"display_name": self.logged_in_display_name or "",
|
|
149
|
+
}
|
|
150
|
+
with open(meta_path, "w", encoding="utf-8") as f:
|
|
151
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
152
|
+
os.chmod(meta_path, stat.S_IRUSR | stat.S_IWUSR)
|
mcp_uof/auth/token.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TokenAuthProvider — RSA + SOAP GetToken.
|
|
3
|
+
|
|
4
|
+
This is the original mcp-uof authentication path, preserved verbatim and adapted to the
|
|
5
|
+
AuthProvider interface. Token is cached in memory and persisted to
|
|
6
|
+
`~/.uof/credentials-<account>-<hash>.json` keyed by identity so multiple deployments don't
|
|
7
|
+
poison each other's cache.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import stat
|
|
17
|
+
import time
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from Crypto.Cipher import PKCS1_v1_5
|
|
21
|
+
from Crypto.PublicKey import RSA
|
|
22
|
+
|
|
23
|
+
from .base import AuthMode, AuthProvider
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
from .._log import eprint as _eprint # 診斷一律走 stderr(共用,勿在各檔複製)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
CREDENTIALS_DIR = os.path.join(os.path.expanduser("~"), ".uof")
|
|
30
|
+
DEFAULT_TOKEN_TTL = 14 * 24 * 60 * 60 # 14 days
|
|
31
|
+
|
|
32
|
+
# Pre-package single-file credential layout (kept for cleanup compatibility).
|
|
33
|
+
_LEGACY_CREDENTIALS_FILE = os.path.join(CREDENTIALS_DIR, "credentials.json")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ensure_credentials_dir() -> None:
|
|
37
|
+
if not os.path.exists(CREDENTIALS_DIR):
|
|
38
|
+
os.makedirs(CREDENTIALS_DIR, mode=0o700, exist_ok=True)
|
|
39
|
+
_eprint(f"[auth.token] 已建立憑證目錄: {CREDENTIALS_DIR}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _rsa_encrypt(public_key_base64: str, plaintext: str) -> str:
|
|
43
|
+
public_key_xml = base64.b64decode(public_key_base64).decode("utf-8")
|
|
44
|
+
key = _import_rsa_xml_key(public_key_xml)
|
|
45
|
+
cipher = PKCS1_v1_5.new(key)
|
|
46
|
+
encrypted = cipher.encrypt(plaintext.encode("utf-8"))
|
|
47
|
+
return base64.b64encode(encrypted).decode("ascii")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _import_rsa_xml_key(xml_string: str) -> RSA.RsaKey:
|
|
51
|
+
from lxml import etree
|
|
52
|
+
|
|
53
|
+
root = etree.fromstring(xml_string.encode("utf-8"))
|
|
54
|
+
modulus_elem = root.find("Modulus")
|
|
55
|
+
exponent_elem = root.find("Exponent")
|
|
56
|
+
if modulus_elem is None or exponent_elem is None:
|
|
57
|
+
raise ValueError("RSA XML 格式錯誤:缺少 Modulus 或 Exponent 元素")
|
|
58
|
+
n = int.from_bytes(base64.b64decode(modulus_elem.text.strip()), byteorder="big")
|
|
59
|
+
e = int.from_bytes(base64.b64decode(exponent_elem.text.strip()), byteorder="big")
|
|
60
|
+
return RSA.construct((n, e))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TokenAuthProvider(AuthProvider):
|
|
64
|
+
mode = AuthMode.TOKEN
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._token: Optional[str] = None
|
|
68
|
+
self._expires_at: float = 0.0
|
|
69
|
+
self._identity: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
# ── Identity helpers ────────────────────────────────────────────
|
|
72
|
+
def _identity_key(self) -> str:
|
|
73
|
+
return "|".join(
|
|
74
|
+
[
|
|
75
|
+
os.getenv("UOF_BASE_URL", ""),
|
|
76
|
+
os.getenv("UOF_APP_NAME", ""),
|
|
77
|
+
os.getenv("UOF_ACCOUNT", ""),
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def credentials_file(self) -> str:
|
|
82
|
+
account = os.getenv("UOF_ACCOUNT", "anonymous")
|
|
83
|
+
safe_account = re.sub(r"[^A-Za-z0-9_.-]", "_", account) or "anonymous"
|
|
84
|
+
digest = hashlib.sha256(self._identity_key().encode("utf-8")).hexdigest()[:8]
|
|
85
|
+
return os.path.join(CREDENTIALS_DIR, f"credentials-{safe_account}-{digest}.json")
|
|
86
|
+
|
|
87
|
+
def required_env_help(self) -> str:
|
|
88
|
+
return (
|
|
89
|
+
"- `UOF_BASE_URL`\n"
|
|
90
|
+
"- `UOF_APP_NAME`\n"
|
|
91
|
+
"- `UOF_RSA_PUBLIC_KEY`\n"
|
|
92
|
+
"- `UOF_ACCOUNT`\n"
|
|
93
|
+
"- `UOF_PASSWORD`"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# ── AuthProvider surface ────────────────────────────────────────
|
|
97
|
+
def ensure_valid(self) -> None:
|
|
98
|
+
self.fetch_token()
|
|
99
|
+
|
|
100
|
+
def status_report(self) -> str:
|
|
101
|
+
account = os.getenv("UOF_ACCOUNT", "(未設定)")
|
|
102
|
+
base_url = os.getenv("UOF_BASE_URL", "(未設定)")
|
|
103
|
+
# 實際向 UOF 重新取得一次 Token 來「真正驗證」登入,而非只看本地快取 TTL——
|
|
104
|
+
# UOF Token 的伺服器端有效期可能短於本地 TTL,只看快取會誤報為有效。
|
|
105
|
+
try:
|
|
106
|
+
self.fetch_token(force_refresh=True)
|
|
107
|
+
return (
|
|
108
|
+
f"✅ 已成功登入 UOF,目前以「{account}」的身份操作。\n"
|
|
109
|
+
f"🔗 連線目標:{base_url}\n"
|
|
110
|
+
f"Token 由系統自動管理,過期時會在工具呼叫時自動更新,使用者不需手動處理。\n"
|
|
111
|
+
f"💡 要改以其他人的身份操作,請切換 MCP 設定(一份設定 = 一個身份)。"
|
|
112
|
+
)
|
|
113
|
+
except RuntimeError as e:
|
|
114
|
+
from .base import auth_failure_message
|
|
115
|
+
return auth_failure_message(str(e))
|
|
116
|
+
|
|
117
|
+
def clear(self, all_identities: bool = False) -> None:
|
|
118
|
+
targets = []
|
|
119
|
+
if all_identities:
|
|
120
|
+
if os.path.isdir(CREDENTIALS_DIR):
|
|
121
|
+
for name in os.listdir(CREDENTIALS_DIR):
|
|
122
|
+
if name.startswith("credentials") and name.endswith(".json"):
|
|
123
|
+
targets.append(os.path.join(CREDENTIALS_DIR, name))
|
|
124
|
+
else:
|
|
125
|
+
targets.append(self.credentials_file())
|
|
126
|
+
if os.path.exists(_LEGACY_CREDENTIALS_FILE):
|
|
127
|
+
targets.append(_LEGACY_CREDENTIALS_FILE)
|
|
128
|
+
for path in targets:
|
|
129
|
+
if os.path.exists(path):
|
|
130
|
+
os.remove(path)
|
|
131
|
+
_eprint(f"[auth.token] 🗑️ 已清除憑證: {path}")
|
|
132
|
+
self._token = None
|
|
133
|
+
self._expires_at = 0.0
|
|
134
|
+
self._identity = None
|
|
135
|
+
|
|
136
|
+
# ── Token-specific API ──────────────────────────────────────────
|
|
137
|
+
def fetch_token(self, force_refresh: bool = False) -> str:
|
|
138
|
+
"""取得有效 Token。
|
|
139
|
+
|
|
140
|
+
force_refresh=True 時略過記憶體與磁碟快取,強制重新向 UOF 取得——用於
|
|
141
|
+
「快取 Token 已被伺服器端判定失效」時的刷新重試(UOF Token 伺服器端有效期
|
|
142
|
+
可能短於本地 TTL,失效時 Wkf.asmx 回 HTTP 500 而無明確過期訊息)。
|
|
143
|
+
"""
|
|
144
|
+
identity = self._identity_key()
|
|
145
|
+
if not force_refresh:
|
|
146
|
+
# 1. memory cache
|
|
147
|
+
if (
|
|
148
|
+
self._token
|
|
149
|
+
and self._identity == identity
|
|
150
|
+
and time.time() < (self._expires_at - 60)
|
|
151
|
+
):
|
|
152
|
+
return self._token
|
|
153
|
+
# 2. disk cache
|
|
154
|
+
creds = self.read_credentials()
|
|
155
|
+
if creds:
|
|
156
|
+
self._token = creds["token"]
|
|
157
|
+
self._expires_at = creds["expires_at"]
|
|
158
|
+
self._identity = identity
|
|
159
|
+
return self._token
|
|
160
|
+
# 3. SOAP GetToken
|
|
161
|
+
_eprint(
|
|
162
|
+
f"[auth.token] 🔄 正在以 {os.getenv('UOF_ACCOUNT', '?')} 身份呼叫 GetToken..."
|
|
163
|
+
)
|
|
164
|
+
token = self._call_get_token()
|
|
165
|
+
ttl_env = os.getenv("UOF_TOKEN_TTL")
|
|
166
|
+
ttl = int(ttl_env) if ttl_env else DEFAULT_TOKEN_TTL
|
|
167
|
+
self._write_credentials(token, ttl)
|
|
168
|
+
self._token = token
|
|
169
|
+
self._expires_at = time.time() + ttl
|
|
170
|
+
self._identity = identity
|
|
171
|
+
return token
|
|
172
|
+
|
|
173
|
+
def _call_get_token(self) -> str:
|
|
174
|
+
from ..soap_client import uof_client
|
|
175
|
+
from ..domains.system.endpoints import AUTH_ENDPOINT
|
|
176
|
+
|
|
177
|
+
app_name = os.getenv("UOF_APP_NAME", "")
|
|
178
|
+
public_key = os.getenv("UOF_RSA_PUBLIC_KEY", "")
|
|
179
|
+
account = os.getenv("UOF_ACCOUNT", "")
|
|
180
|
+
password = os.getenv("UOF_PASSWORD", "")
|
|
181
|
+
if not all([app_name, public_key, account, password]):
|
|
182
|
+
raise RuntimeError(
|
|
183
|
+
"UOF_APP_NAME、UOF_RSA_PUBLIC_KEY、UOF_ACCOUNT、UOF_PASSWORD "
|
|
184
|
+
"必須全部設定。請檢查 .env 檔案。"
|
|
185
|
+
)
|
|
186
|
+
encrypted_account = _rsa_encrypt(public_key, account)
|
|
187
|
+
encrypted_password = _rsa_encrypt(public_key, password)
|
|
188
|
+
# 登入失敗的回應型態不一致:有時回空 GetTokenResponse(200),有時直接 HTTP 500
|
|
189
|
+
# (如密碼錯誤導致伺服器端 RSADecrypt 失敗)。兩者都收斂為 RuntimeError,
|
|
190
|
+
# 讓上層以固定訊息回報,不把原始 500 堆疊丟給介接 AI。
|
|
191
|
+
try:
|
|
192
|
+
result = uof_client.call(
|
|
193
|
+
endpoint_path=AUTH_ENDPOINT,
|
|
194
|
+
method_name="GetToken",
|
|
195
|
+
params={
|
|
196
|
+
"appName": app_name,
|
|
197
|
+
"account": encrypted_account,
|
|
198
|
+
"password": encrypted_password,
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
brief = " ".join(str(e).split()).split("For more information")[0].strip()[:160]
|
|
203
|
+
raise RuntimeError(
|
|
204
|
+
f"呼叫 GetToken 失敗(帳號「{account}」):{brief or e.__class__.__name__}。"
|
|
205
|
+
"可能是帳號或密碼錯誤、RSA 公私鑰不相符,或 UOF 服務暫時無法連線。"
|
|
206
|
+
)
|
|
207
|
+
if not result:
|
|
208
|
+
raise RuntimeError(
|
|
209
|
+
f"GetToken 未回傳憑證(帳號「{account}」):可能是帳號或密碼錯誤、密碼已變更、"
|
|
210
|
+
"帳號被停用,或 RSA 公私鑰不相符。"
|
|
211
|
+
)
|
|
212
|
+
return result.strip()
|
|
213
|
+
|
|
214
|
+
# ── Disk persistence ─────────────────────────────────────────────
|
|
215
|
+
def read_credentials(self) -> Optional[dict]:
|
|
216
|
+
path = self.credentials_file()
|
|
217
|
+
if not os.path.exists(path):
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
221
|
+
data = json.load(f)
|
|
222
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
223
|
+
_eprint(f"[auth.token] ❌ 讀取憑證檔失敗: {e}")
|
|
224
|
+
return None
|
|
225
|
+
token = data.get("token")
|
|
226
|
+
expires_at = data.get("expires_at", 0)
|
|
227
|
+
if not token:
|
|
228
|
+
return None
|
|
229
|
+
if data.get("identity") and data.get("identity") != self._identity_key():
|
|
230
|
+
return None
|
|
231
|
+
if time.time() >= (expires_at - 60):
|
|
232
|
+
_eprint(
|
|
233
|
+
f"[auth.token] ⏰ Token 已過期 "
|
|
234
|
+
f"(expires_at={expires_at}, now={time.time():.0f})"
|
|
235
|
+
)
|
|
236
|
+
return None
|
|
237
|
+
return data
|
|
238
|
+
|
|
239
|
+
def _write_credentials(self, token: str, ttl: int) -> None:
|
|
240
|
+
_ensure_credentials_dir()
|
|
241
|
+
path = self.credentials_file()
|
|
242
|
+
now = time.time()
|
|
243
|
+
data = {
|
|
244
|
+
"token": token,
|
|
245
|
+
"expires_at": now + ttl,
|
|
246
|
+
"ttl": ttl,
|
|
247
|
+
"issued_at": now,
|
|
248
|
+
"account": os.getenv("UOF_ACCOUNT", ""),
|
|
249
|
+
"app_name": os.getenv("UOF_APP_NAME", ""),
|
|
250
|
+
"base_url": os.getenv("UOF_BASE_URL", ""),
|
|
251
|
+
"identity": self._identity_key(),
|
|
252
|
+
}
|
|
253
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
254
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
255
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
256
|
+
_eprint(f"[auth.token] ✅ 憑證已寫入 {path} (ttl={ttl}s)")
|