tqx-data 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.
- tqx_data/__init__.py +63 -0
- tqx_data/auth_manager.py +563 -0
- tqx_data/client/__init__.py +11 -0
- tqx_data/client/live_market_client.py +638 -0
- tqx_data/client/service.py +412 -0
- tqx_data/client/ws_auth.py +106 -0
- tqx_data/config/__init__.py +98 -0
- tqx_data/core/__init__.py +2 -0
- tqx_data/core/service.py +49 -0
- tqx_data/exceptions.py +275 -0
- tqx_data/readers/__init__.py +0 -0
- tqx_data/readers/hk_reader.py +137 -0
- tqx_data/readers/init_token.py +157 -0
- tqx_data/readers/oversea_reader.py +1338 -0
- tqx_data/readers/trading_tools_reader.py +253 -0
- tqx_data/readers/us_reader.py +127 -0
- tqx_data/tqx_data.md +3130 -0
- tqx_data/tqx_data_bk.md +3595 -0
- tqx_data/transport/__init__.py +6 -0
- tqx_data/transport/http.py +1287 -0
- tqx_data/utils/common_utils.py +52 -0
- tqx_data/utils/param_check_utils.py +2550 -0
- tqx_data-0.1.0.dist-info/METADATA +20 -0
- tqx_data-0.1.0.dist-info/RECORD +25 -0
- tqx_data-0.1.0.dist-info/WHEEL +4 -0
tqx_data/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""tqx_data SDK public interface."""
|
|
2
|
+
import warnings
|
|
3
|
+
|
|
4
|
+
warnings.filterwarnings("ignore", message=".*Unable to find acceptable character detection dependency.*")
|
|
5
|
+
|
|
6
|
+
from .client import get_client, get_factory, init, TickStreamClient
|
|
7
|
+
from .auth_manager import (
|
|
8
|
+
is_authenticated,
|
|
9
|
+
auth_remaining_seconds,
|
|
10
|
+
auth_expires_at,
|
|
11
|
+
get_token_ttl,
|
|
12
|
+
auth_info,
|
|
13
|
+
clear_auth,
|
|
14
|
+
)
|
|
15
|
+
from .readers import (
|
|
16
|
+
hk_reader,
|
|
17
|
+
trading_tools_reader,
|
|
18
|
+
us_reader,
|
|
19
|
+
oversea_reader,
|
|
20
|
+
init_token,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# 交易工具(交易日历等,exchange 支持 HK/US)
|
|
24
|
+
get_trade_cal = trading_tools_reader.get_trade_cal
|
|
25
|
+
get_prev_trade_date = trading_tools_reader.get_prev_trade_date
|
|
26
|
+
get_last_trade_date = trading_tools_reader.get_last_trade_date
|
|
27
|
+
get_trade_list = trading_tools_reader.get_trade_list
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# 港美股(用 market 参数区分 hk/us;日线/分钟线接口保持分开,见下方港股/美股)
|
|
32
|
+
get_stock_detail = oversea_reader.get_stock_detail
|
|
33
|
+
get_stock_detail_all = oversea_reader.get_stock_detail_all
|
|
34
|
+
get_stock_dividend_event = oversea_reader.get_stock_dividend_event
|
|
35
|
+
get_stock_market_event = oversea_reader.get_stock_market_event
|
|
36
|
+
get_stock_meeting_event = oversea_reader.get_stock_meeting_event
|
|
37
|
+
get_stock_financial_event = oversea_reader.get_stock_financial_event
|
|
38
|
+
get_stock_ir_event = oversea_reader.get_stock_ir_event
|
|
39
|
+
get_stock_investor_concentration = oversea_reader.get_stock_investor_concentration
|
|
40
|
+
get_stock_top20_concentration = oversea_reader.get_stock_top20_concentration
|
|
41
|
+
get_stock_investor_ranking = oversea_reader.get_stock_investor_ranking
|
|
42
|
+
get_stock_insider_trade = oversea_reader.get_stock_insider_trade
|
|
43
|
+
get_stock_shareholder_holding = oversea_reader.get_stock_shareholder_holding
|
|
44
|
+
get_stock_industry_median = oversea_reader.get_stock_industry_median
|
|
45
|
+
get_stock_pv_indicator = oversea_reader.get_stock_pv_indicator
|
|
46
|
+
get_stock_ncycl_consensus = oversea_reader.get_stock_ncycl_consensus
|
|
47
|
+
get_stock_operating_indicator = oversea_reader.get_stock_operating_indicator
|
|
48
|
+
get_stock_mktfin_indicator = oversea_reader.get_stock_mktfin_indicator
|
|
49
|
+
get_financial_statement = oversea_reader.get_financial_statement
|
|
50
|
+
|
|
51
|
+
# 24 合并
|
|
52
|
+
|
|
53
|
+
# 港股(日线/分钟线,不与美股合并)
|
|
54
|
+
get_hk_daily = hk_reader.get_hk_daily
|
|
55
|
+
|
|
56
|
+
# 美股(日线/分钟线,不与港股合并)
|
|
57
|
+
get_us_daily = us_reader.get_us_daily
|
|
58
|
+
init_token = init_token.init_token
|
|
59
|
+
__all__ = [name for name in globals().keys() if name.startswith('get_')] + [
|
|
60
|
+
'init', 'init_token', 'get_client', 'get_factory', 'TickStreamClient',
|
|
61
|
+
'is_authenticated', 'auth_remaining_seconds', 'auth_expires_at',
|
|
62
|
+
'auth_info', 'clear_auth',
|
|
63
|
+
]
|
tqx_data/auth_manager.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auth_manager.py — Centralized authentication state management.
|
|
3
|
+
|
|
4
|
+
Security design:
|
|
5
|
+
- Token lives ONLY in memory (never written to disk).
|
|
6
|
+
- Credentials are encrypted at rest in ``user.json`` using a key derived from
|
|
7
|
+
the machine fingerprint (hostname + MAC + fixed salt).
|
|
8
|
+
- The public API exposes only auth-status / remaining-duration information;
|
|
9
|
+
it never leaks the raw token string or internal user-id.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
# Login
|
|
14
|
+
tqx_data.init_token(username, password, base_url)
|
|
15
|
+
|
|
16
|
+
# Check auth status
|
|
17
|
+
tqx_data.is_authenticated() # → True / False
|
|
18
|
+
tqx_data.auth_remaining_seconds() # → 86340 (seconds until expiry)
|
|
19
|
+
|
|
20
|
+
# On subsequent sessions, auto-login happens transparently
|
|
21
|
+
df = tqx_data.get_stock_daily(...) # re-logs in if user.json exists
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import hashlib
|
|
28
|
+
import hmac as hmac_mod
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import platform
|
|
32
|
+
import secrets
|
|
33
|
+
import socket
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Any, Dict, Optional, Tuple
|
|
39
|
+
from urllib.parse import urljoin
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Machine-fingerprint key derivation (standard library only — zero deps)
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
_SALT = b"tqx_data_auth_v2" # v2: HMAC-protected encryption
|
|
46
|
+
_KEY_ITERATIONS = 200_000
|
|
47
|
+
_HMAC_SEPARATOR = b"::hmac::"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_pepper() -> bytes:
|
|
51
|
+
"""Return the pepper from env var ``TQX_DATA_AUTH_PEPPER``.
|
|
52
|
+
|
|
53
|
+
If not set, encryption still works with machine-fingerprint-derived key.
|
|
54
|
+
Production deployments that need defense-in-depth should set this,
|
|
55
|
+
but the primary security boundary is server-side (Gateway JWT + Redis).
|
|
56
|
+
"""
|
|
57
|
+
pepper = os.environ.get("TQX_DATA_AUTH_PEPPER", "").strip()
|
|
58
|
+
return pepper.encode("utf-8") if pepper else b""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_machine_fingerprint() -> str:
|
|
62
|
+
"""Return a host-specific string used to encrypt on-disk credentials."""
|
|
63
|
+
try:
|
|
64
|
+
parts = [
|
|
65
|
+
platform.node() or "unknown-host",
|
|
66
|
+
str(uuid.getnode()),
|
|
67
|
+
socket.gethostname() or "unknown-name",
|
|
68
|
+
platform.machine() or "unknown-arch",
|
|
69
|
+
]
|
|
70
|
+
except Exception:
|
|
71
|
+
parts = ["fallback", str(uuid.getnode())]
|
|
72
|
+
return "|".join(parts)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _derive_key(fingerprint: str) -> Tuple[bytes, bytes]:
|
|
76
|
+
"""Derive encryption + signing keys (each 32 bytes) from fingerprint + pepper.
|
|
77
|
+
|
|
78
|
+
Returns ``(encryption_key, signing_key)``.
|
|
79
|
+
"""
|
|
80
|
+
pepper = _get_pepper()
|
|
81
|
+
# HKDF-style: derive 64 bytes, split into enc-key (first 32) and hmac-key (last 32)
|
|
82
|
+
material = fingerprint.encode() + _HMAC_SEPARATOR + pepper
|
|
83
|
+
raw = hashlib.pbkdf2_hmac(
|
|
84
|
+
"sha256", material, _SALT, _KEY_ITERATIONS, dklen=64
|
|
85
|
+
)
|
|
86
|
+
return raw[:32], raw[32:]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _generate_key_stream(key: bytes, iv: bytes, length: int) -> bytes:
|
|
90
|
+
"""Expand *key* + *iv* into a deterministic stream of *length* bytes."""
|
|
91
|
+
result = b""
|
|
92
|
+
counter = 0
|
|
93
|
+
while len(result) < length:
|
|
94
|
+
h = hashlib.sha256(key + iv + counter.to_bytes(4, "big")).digest()
|
|
95
|
+
result += h
|
|
96
|
+
counter += 1
|
|
97
|
+
return result[:length]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _encrypt(plaintext: str, enc_key: bytes, sign_key: bytes) -> str:
|
|
101
|
+
"""Encrypt + HMAC-sign *plaintext*.
|
|
102
|
+
|
|
103
|
+
Output format: ``base64(iv || ciphertext || hmac_sha256)``
|
|
104
|
+
where *iv* is 16 random bytes and *hmac* is 32 bytes.
|
|
105
|
+
Any tampering with the ciphertext will cause decryption to fail.
|
|
106
|
+
"""
|
|
107
|
+
iv = secrets.token_bytes(16)
|
|
108
|
+
plain_bytes = plaintext.encode("utf-8")
|
|
109
|
+
key_stream = _generate_key_stream(enc_key, iv, len(plain_bytes))
|
|
110
|
+
ciphertext = bytes(a ^ b for a, b in zip(plain_bytes, key_stream))
|
|
111
|
+
|
|
112
|
+
# HMAC over iv + ciphertext (authenticated encryption)
|
|
113
|
+
payload = iv + ciphertext
|
|
114
|
+
mac = hmac_mod.digest(sign_key, payload, "sha256")
|
|
115
|
+
|
|
116
|
+
return base64.b64encode(payload + mac).decode("ascii")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _decrypt(encrypted: str, enc_key: bytes, sign_key: bytes) -> str:
|
|
120
|
+
"""Verify HMAC then decrypt. Raises ``ValueError`` on integrity failure."""
|
|
121
|
+
data = base64.b64decode(encrypted.encode("ascii"))
|
|
122
|
+
|
|
123
|
+
if len(data) < 16 + 32: # iv + hmac minimum
|
|
124
|
+
raise ValueError("Encrypted data too short — may be corrupted")
|
|
125
|
+
|
|
126
|
+
payload = data[:-32] # iv + ciphertext
|
|
127
|
+
mac = data[-32:] # HMAC tag
|
|
128
|
+
|
|
129
|
+
# Verify integrity before decrypting
|
|
130
|
+
expected_mac = hmac_mod.digest(sign_key, payload, "sha256")
|
|
131
|
+
if not secrets.compare_digest(mac, expected_mac):
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"Credential integrity check failed — the user.json file may have "
|
|
134
|
+
"been tampered with. Delete it and re-login."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
iv = payload[:16]
|
|
138
|
+
ciphertext = payload[16:]
|
|
139
|
+
key_stream = _generate_key_stream(enc_key, iv, len(ciphertext))
|
|
140
|
+
plain_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
|
|
141
|
+
return plain_bytes.decode("utf-8")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Auth state (module-private globals)
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class _AuthState:
|
|
151
|
+
token: Optional[str] = None
|
|
152
|
+
username: Optional[str] = None
|
|
153
|
+
base_url: Optional[str] = None
|
|
154
|
+
login_timestamp: float = 0.0
|
|
155
|
+
token_expires_in_seconds: int = 14400 # default 4 h
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def is_valid(self) -> bool:
|
|
159
|
+
return self.token is not None
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def expires_at(self) -> float:
|
|
163
|
+
return self.login_timestamp + self.token_expires_in_seconds
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def remaining_seconds(self) -> float:
|
|
167
|
+
return max(0.0, self.expires_at - time.time())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_auth_state = _AuthState()
|
|
171
|
+
_auth_lock = threading.Lock()
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# user.json helpers
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
# Keep a module-level cache so we don't walk the filesystem on every call.
|
|
178
|
+
_user_json_dir: Optional[str] = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _get_user_json_path() -> str:
|
|
182
|
+
global _user_json_dir
|
|
183
|
+
if _user_json_dir is None:
|
|
184
|
+
from tqx_data.utils.common_utils import find_project_root
|
|
185
|
+
|
|
186
|
+
_user_json_dir = find_project_root(os.path.dirname(__file__))
|
|
187
|
+
return os.path.join(_user_json_dir, "user.json")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _read_persisted_credentials() -> Optional[Dict[str, Any]]:
|
|
191
|
+
"""Decrypt and return the credential blob from *user.json*, or *None*."""
|
|
192
|
+
filepath = _get_user_json_path()
|
|
193
|
+
if not os.path.exists(filepath):
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
with open(filepath, "r", encoding="utf-8") as fh:
|
|
197
|
+
data = json.load(fh)
|
|
198
|
+
except (json.JSONDecodeError, OSError):
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
enc_creds = data.get("encrypted_credentials")
|
|
202
|
+
if not enc_creds:
|
|
203
|
+
# Legacy / empty file — treat as absent.
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
enc_key, sign_key = _derive_key(_get_machine_fingerprint())
|
|
208
|
+
creds_json = _decrypt(enc_creds, enc_key, sign_key)
|
|
209
|
+
creds = json.loads(creds_json)
|
|
210
|
+
except Exception:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"username": creds.get("username", ""),
|
|
215
|
+
"password": creds.get("password", ""),
|
|
216
|
+
"base_url": data.get("base_url", ""),
|
|
217
|
+
"login_timestamp": data.get("login_timestamp", 0.0),
|
|
218
|
+
"token_expires_in_seconds": data.get("token_expires_in_seconds", 14400),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _persist_credentials(username: str, password: str, base_url: str, expires_in: int) -> None:
|
|
223
|
+
"""Write encrypted credentials + metadata to *user.json* (NO token)."""
|
|
224
|
+
enc_key, sign_key = _derive_key(_get_machine_fingerprint())
|
|
225
|
+
creds_json = json.dumps({"username": username, "password": password})
|
|
226
|
+
encrypted = _encrypt(creds_json, enc_key, sign_key)
|
|
227
|
+
|
|
228
|
+
file_data: Dict[str, Any] = {
|
|
229
|
+
"encrypted_credentials": encrypted,
|
|
230
|
+
"base_url": base_url,
|
|
231
|
+
"login_timestamp": time.time(),
|
|
232
|
+
"token_expires_in_seconds": expires_in,
|
|
233
|
+
"last_verified_at": time.time(),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
filepath = _get_user_json_path()
|
|
237
|
+
with open(filepath, "w", encoding="utf-8") as fh:
|
|
238
|
+
json.dump(file_data, fh, ensure_ascii=False, indent=2)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Internal HTTP login helper (avoids circular imports from init_token)
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _http_login(
|
|
247
|
+
username: str, password: str, base_url: str
|
|
248
|
+
) -> Tuple[str, int]:
|
|
249
|
+
"""POST to the login endpoint and return ``(token, expires_in_seconds)``.
|
|
250
|
+
|
|
251
|
+
This is a self-contained helper that does **not** depend on init_token or
|
|
252
|
+
the global client factory so that auth_manager stays importable even before
|
|
253
|
+
the rest of the SDK is wired.
|
|
254
|
+
"""
|
|
255
|
+
from tqx_data.exceptions import ServiceError
|
|
256
|
+
from tqx_data.transport.http import HTTPClient, HTTPClientConfig
|
|
257
|
+
|
|
258
|
+
endpoint = "/dataUser/login"
|
|
259
|
+
|
|
260
|
+
def _service_http_base(url: str) -> str:
|
|
261
|
+
u = url.rstrip("/")
|
|
262
|
+
for cp in ("/tqxData/oversea", "/tqxDataTick", "/tqxData"):
|
|
263
|
+
if u.endswith(cp):
|
|
264
|
+
return u[: -len(cp)]
|
|
265
|
+
return u
|
|
266
|
+
|
|
267
|
+
login_base = _service_http_base(base_url) + "/tqxData"
|
|
268
|
+
full_login_url = urljoin(login_base.rstrip("/") + "/", endpoint.lstrip("/"))
|
|
269
|
+
|
|
270
|
+
http_config = HTTPClientConfig(
|
|
271
|
+
base_url=login_base, username=username, password=password
|
|
272
|
+
)
|
|
273
|
+
http_client = HTTPClient(http_config)
|
|
274
|
+
|
|
275
|
+
payload = {
|
|
276
|
+
"username": username,
|
|
277
|
+
"password": password,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
raw_response = http_client.request(
|
|
282
|
+
method="POST", endpoint=endpoint, payload=payload
|
|
283
|
+
)
|
|
284
|
+
except ServiceError as exc:
|
|
285
|
+
msg = str(exc)
|
|
286
|
+
if msg.startswith("HTTP 404"):
|
|
287
|
+
msg = (
|
|
288
|
+
f"{msg}\n"
|
|
289
|
+
f"Login URL: {full_login_url}\n"
|
|
290
|
+
"If this port does not serve the HTTP login endpoint, "
|
|
291
|
+
"specify the correct gateway address."
|
|
292
|
+
)
|
|
293
|
+
raise ServiceError(msg) from exc
|
|
294
|
+
except Exception as exc:
|
|
295
|
+
raise ServiceError(f"Login failed: {exc} (URL: {full_login_url})") from exc
|
|
296
|
+
finally:
|
|
297
|
+
http_client.close()
|
|
298
|
+
|
|
299
|
+
# Parse response: may be {code, data: "<token>"} (legacy format) or
|
|
300
|
+
# {code, data: {token, expires_in}} (extended format).
|
|
301
|
+
expires_in = int(os.environ.get("tqx_AUTH_TOKEN_TTL", "14400")) # fallback 默认
|
|
302
|
+
if isinstance(raw_response, str):
|
|
303
|
+
result = raw_response
|
|
304
|
+
elif isinstance(raw_response, dict):
|
|
305
|
+
code = raw_response.get("code")
|
|
306
|
+
if code is not None and code not in (200, "200"):
|
|
307
|
+
raise ServiceError(
|
|
308
|
+
f"[Error code {code}: {raw_response.get('message', 'unknown')}]"
|
|
309
|
+
)
|
|
310
|
+
data = raw_response.get("data")
|
|
311
|
+
if isinstance(data, dict):
|
|
312
|
+
result = data.get("token", "")
|
|
313
|
+
expires_in = int(data.get("expires_in", 14400))
|
|
314
|
+
elif isinstance(data, str):
|
|
315
|
+
result = data
|
|
316
|
+
elif data is None and "token" in raw_response:
|
|
317
|
+
result = raw_response["token"]
|
|
318
|
+
else:
|
|
319
|
+
result = str(data) if data else ""
|
|
320
|
+
else:
|
|
321
|
+
result = str(raw_response)
|
|
322
|
+
|
|
323
|
+
if not result or not isinstance(result, str):
|
|
324
|
+
raise ServiceError("Login failed: unable to obtain a valid token")
|
|
325
|
+
|
|
326
|
+
# 从 JWT 的 exp 字段提取实际 TTL(覆盖默认值)
|
|
327
|
+
try:
|
|
328
|
+
parts = result.split(".")
|
|
329
|
+
if len(parts) == 3:
|
|
330
|
+
payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
|
331
|
+
jwt_payload = json.loads(base64.b64decode(payload_b64))
|
|
332
|
+
exp = jwt_payload.get("exp", 0)
|
|
333
|
+
if exp > 0:
|
|
334
|
+
expires_in = max(0, int(exp - time.time()))
|
|
335
|
+
except Exception:
|
|
336
|
+
pass # 解析失败就用默认值
|
|
337
|
+
|
|
338
|
+
return result, expires_in
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# Public API — used by the rest of the SDK
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def save_auth_state(
|
|
347
|
+
username: str,
|
|
348
|
+
password: str,
|
|
349
|
+
base_url: str,
|
|
350
|
+
token: str,
|
|
351
|
+
expires_in: int = 14400,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Persist encrypted credentials to disk and cache *token* in memory."""
|
|
354
|
+
global _auth_state
|
|
355
|
+
with _auth_lock:
|
|
356
|
+
_auth_state.token = token
|
|
357
|
+
_auth_state.username = username
|
|
358
|
+
_auth_state.base_url = base_url
|
|
359
|
+
_auth_state.login_timestamp = time.time()
|
|
360
|
+
_auth_state.token_expires_in_seconds = expires_in
|
|
361
|
+
|
|
362
|
+
_persist_credentials(username, password, base_url, expires_in)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def get_token() -> Optional[str]:
|
|
366
|
+
"""Return the currently cached token (in-memory), or *None*."""
|
|
367
|
+
with _auth_lock:
|
|
368
|
+
return _auth_state.token
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def get_base_url() -> Optional[str]:
|
|
372
|
+
with _auth_lock:
|
|
373
|
+
return _auth_state.base_url
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def get_username() -> Optional[str]:
|
|
377
|
+
with _auth_lock:
|
|
378
|
+
return _auth_state.username
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def get_password() -> Optional[str]:
|
|
382
|
+
"""Return the decrypted password from disk (used for re-login)."""
|
|
383
|
+
creds = _read_persisted_credentials()
|
|
384
|
+
return creds.get("password") if creds else None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def get_decrypted_credentials() -> Optional[Dict[str, Any]]:
|
|
388
|
+
"""Return the full decrypted credential dict or *None*."""
|
|
389
|
+
return _read_persisted_credentials()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# -- Public status queries (safe to expose to end users) --
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def is_authenticated() -> bool:
|
|
396
|
+
"""Return *True* if a valid token is cached in memory."""
|
|
397
|
+
with _auth_lock:
|
|
398
|
+
return _auth_state.is_valid
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def auth_remaining_seconds() -> float:
|
|
402
|
+
"""Approximate seconds until the current session expires.
|
|
403
|
+
|
|
404
|
+
Returns 0.0 when not authenticated or when the expiry has already passed.
|
|
405
|
+
"""
|
|
406
|
+
with _auth_lock:
|
|
407
|
+
return _auth_state.remaining_seconds
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def auth_expires_at() -> Optional[float]:
|
|
411
|
+
"""Return the POSIX timestamp at which the current session expires, or *None*."""
|
|
412
|
+
with _auth_lock:
|
|
413
|
+
if _auth_state.is_valid:
|
|
414
|
+
return _auth_state.expires_at
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def get_token_ttl() -> int:
|
|
419
|
+
"""Return the token's configured TTL in seconds (the full duration, not remaining)."""
|
|
420
|
+
with _auth_lock:
|
|
421
|
+
return _auth_state.token_expires_in_seconds
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def auth_info() -> Dict[str, Any]:
|
|
425
|
+
"""Return a user-safe dict with auth status and duration (no token / uid)."""
|
|
426
|
+
with _auth_lock:
|
|
427
|
+
return {
|
|
428
|
+
"authenticated": _auth_state.is_valid,
|
|
429
|
+
"remaining_seconds": _auth_state.remaining_seconds,
|
|
430
|
+
"expires_at": _auth_state.expires_at if _auth_state.is_valid else None,
|
|
431
|
+
"username": _auth_state.username,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# -- Auto-login & re-login --
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def auto_login_from_disk() -> bool:
|
|
439
|
+
"""Attempt to log in using persisted credentials.
|
|
440
|
+
|
|
441
|
+
Returns *True* when login succeeds and the in-memory token is refreshed.
|
|
442
|
+
"""
|
|
443
|
+
creds = _read_persisted_credentials()
|
|
444
|
+
if not creds:
|
|
445
|
+
return False
|
|
446
|
+
|
|
447
|
+
username = creds["username"]
|
|
448
|
+
password = creds["password"]
|
|
449
|
+
base_url = creds["base_url"]
|
|
450
|
+
|
|
451
|
+
if not all([username, password, base_url]):
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
token, expires_in = _http_login(username, password, base_url)
|
|
456
|
+
save_auth_state(username, password, base_url, token, expires_in)
|
|
457
|
+
return True
|
|
458
|
+
except Exception:
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# 防止并发 re-login 的互斥锁
|
|
463
|
+
_relogin_lock = threading.Lock()
|
|
464
|
+
# 记录最后一次 re-login 尝试的时间戳,避免短时间内重复尝试
|
|
465
|
+
_last_relogin_attempt: float = 0.0
|
|
466
|
+
_RELOGIN_COOLDOWN_SECONDS = 5.0 # 两次 re-login 之间的最小间隔
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def re_login(force: bool = False) -> bool:
|
|
470
|
+
"""Re-login using persisted credentials (called on 401 or proactive refresh).
|
|
471
|
+
|
|
472
|
+
线程安全:同一时刻只允许一个线程执行 HTTP 登录;
|
|
473
|
+
其余线程短暂等待后复用结果,避免惊群效应。
|
|
474
|
+
|
|
475
|
+
``force=True`` 用于 WebSocket 握手被拒绝后强制重新登录,即使内存中
|
|
476
|
+
仍缓存着尚未过期的旧 token。
|
|
477
|
+
|
|
478
|
+
Returns *True* on success. On failure the in-memory token is cleared.
|
|
479
|
+
"""
|
|
480
|
+
global _last_relogin_attempt
|
|
481
|
+
|
|
482
|
+
# 快速路径:如果 token 仍然有效且不接近过期,直接返回成功
|
|
483
|
+
if not force:
|
|
484
|
+
with _auth_lock:
|
|
485
|
+
if _auth_state.is_valid and _auth_state.remaining_seconds > 60:
|
|
486
|
+
return True
|
|
487
|
+
|
|
488
|
+
# 冷却期检查:避免短时间内重复尝试(比如网关暂时不可达)
|
|
489
|
+
now = time.time()
|
|
490
|
+
if not force and now - _last_relogin_attempt < _RELOGIN_COOLDOWN_SECONDS:
|
|
491
|
+
with _auth_lock:
|
|
492
|
+
return _auth_state.is_valid
|
|
493
|
+
|
|
494
|
+
# 尝试获取 re-login 锁(非阻塞)
|
|
495
|
+
if not _relogin_lock.acquire(blocking=False):
|
|
496
|
+
# 另一个线程正在执行 re-login,等待其完成(最多 5 秒)
|
|
497
|
+
for _ in range(50):
|
|
498
|
+
time.sleep(0.1)
|
|
499
|
+
with _auth_lock:
|
|
500
|
+
if _auth_state.is_valid:
|
|
501
|
+
return True
|
|
502
|
+
# 等待超时 → 返回当前状态
|
|
503
|
+
with _auth_lock:
|
|
504
|
+
return _auth_state.is_valid
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
# 双重检查:获取锁后再次确认是否需要 re-login
|
|
508
|
+
if not force:
|
|
509
|
+
with _auth_lock:
|
|
510
|
+
if _auth_state.is_valid and _auth_state.remaining_seconds > 60:
|
|
511
|
+
return True
|
|
512
|
+
|
|
513
|
+
_last_relogin_attempt = time.time()
|
|
514
|
+
|
|
515
|
+
creds = _read_persisted_credentials()
|
|
516
|
+
if not creds:
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
username = creds["username"]
|
|
520
|
+
password = creds["password"]
|
|
521
|
+
base_url = creds["base_url"]
|
|
522
|
+
|
|
523
|
+
if not all([username, password, base_url]):
|
|
524
|
+
return False
|
|
525
|
+
|
|
526
|
+
token, expires_in = _http_login(username, password, base_url)
|
|
527
|
+
save_auth_state(username, password, base_url, token, expires_in)
|
|
528
|
+
return True
|
|
529
|
+
except Exception:
|
|
530
|
+
with _auth_lock:
|
|
531
|
+
_auth_state.token = None
|
|
532
|
+
return False
|
|
533
|
+
finally:
|
|
534
|
+
_relogin_lock.release()
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def is_token_near_expiry(threshold_seconds: float = 1800.0) -> bool:
|
|
538
|
+
"""Return *True* when the token expires within *threshold_seconds* (default 30 min)."""
|
|
539
|
+
with _auth_lock:
|
|
540
|
+
if not _auth_state.is_valid:
|
|
541
|
+
return False
|
|
542
|
+
return _auth_state.remaining_seconds <= threshold_seconds
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def proactive_refresh_if_needed(threshold_seconds: float = 1800.0) -> bool:
|
|
546
|
+
"""If the token is near expiry, attempt a proactive re-login.
|
|
547
|
+
|
|
548
|
+
Returns *True* if a refresh was attempted and succeeded.
|
|
549
|
+
"""
|
|
550
|
+
if is_token_near_expiry(threshold_seconds):
|
|
551
|
+
return re_login()
|
|
552
|
+
return False # no refresh needed
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def clear_auth() -> None:
|
|
556
|
+
"""Clear in-memory token and remove *user.json*."""
|
|
557
|
+
global _auth_state
|
|
558
|
+
with _auth_lock:
|
|
559
|
+
_auth_state = _AuthState()
|
|
560
|
+
try:
|
|
561
|
+
os.remove(_get_user_json_path())
|
|
562
|
+
except OSError:
|
|
563
|
+
pass
|