xtb-api-python 0.5.2__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.
- xtb_api/__init__.py +70 -0
- xtb_api/__main__.py +154 -0
- xtb_api/auth/__init__.py +5 -0
- xtb_api/auth/auth_manager.py +321 -0
- xtb_api/auth/browser_auth.py +316 -0
- xtb_api/auth/cas_client.py +543 -0
- xtb_api/client.py +444 -0
- xtb_api/exceptions.py +56 -0
- xtb_api/grpc/__init__.py +25 -0
- xtb_api/grpc/client.py +329 -0
- xtb_api/grpc/proto.py +239 -0
- xtb_api/grpc/types.py +14 -0
- xtb_api/instruments.py +132 -0
- xtb_api/py.typed +0 -0
- xtb_api/types/__init__.py +6 -0
- xtb_api/types/enums.py +92 -0
- xtb_api/types/instrument.py +45 -0
- xtb_api/types/trading.py +139 -0
- xtb_api/types/websocket.py +164 -0
- xtb_api/utils.py +62 -0
- xtb_api/ws/__init__.py +3 -0
- xtb_api/ws/parsers.py +161 -0
- xtb_api/ws/ws_client.py +905 -0
- xtb_api_python-0.5.2.dist-info/METADATA +257 -0
- xtb_api_python-0.5.2.dist-info/RECORD +28 -0
- xtb_api_python-0.5.2.dist-info/WHEEL +4 -0
- xtb_api_python-0.5.2.dist-info/entry_points.txt +2 -0
- xtb_api_python-0.5.2.dist-info/licenses/LICENSE +21 -0
xtb_api/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Unofficial Python client for XTB xStation5 trading platform."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _pkg_version
|
|
5
|
+
|
|
6
|
+
from xtb_api.auth.auth_manager import AuthManager as XTBAuth
|
|
7
|
+
from xtb_api.client import XTBClient
|
|
8
|
+
from xtb_api.exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
CASError,
|
|
11
|
+
InstrumentNotFoundError,
|
|
12
|
+
ProtocolError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ReconnectionError,
|
|
15
|
+
TradeError,
|
|
16
|
+
XTBConnectionError,
|
|
17
|
+
XTBError,
|
|
18
|
+
XTBTimeoutError,
|
|
19
|
+
)
|
|
20
|
+
from xtb_api.instruments import InstrumentRegistry
|
|
21
|
+
from xtb_api.types.enums import (
|
|
22
|
+
SocketStatus,
|
|
23
|
+
SubscriptionEid,
|
|
24
|
+
Xs6Side,
|
|
25
|
+
XTBEnvironment,
|
|
26
|
+
)
|
|
27
|
+
from xtb_api.types.instrument import InstrumentSearchResult, Quote
|
|
28
|
+
from xtb_api.types.trading import (
|
|
29
|
+
AccountBalance,
|
|
30
|
+
PendingOrder,
|
|
31
|
+
Position,
|
|
32
|
+
TradeOptions,
|
|
33
|
+
TradeResult,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
__version__ = _pkg_version("xtb-api-python")
|
|
38
|
+
except PackageNotFoundError: # pragma: no cover
|
|
39
|
+
__version__ = "0.0.0+unknown"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
# Client
|
|
43
|
+
"XTBClient",
|
|
44
|
+
"XTBAuth",
|
|
45
|
+
"InstrumentRegistry",
|
|
46
|
+
# Exceptions
|
|
47
|
+
"XTBError",
|
|
48
|
+
"XTBConnectionError",
|
|
49
|
+
"AuthenticationError",
|
|
50
|
+
"CASError",
|
|
51
|
+
"ReconnectionError",
|
|
52
|
+
"TradeError",
|
|
53
|
+
"InstrumentNotFoundError",
|
|
54
|
+
"RateLimitError",
|
|
55
|
+
"XTBTimeoutError",
|
|
56
|
+
"ProtocolError",
|
|
57
|
+
# Data models
|
|
58
|
+
"Position",
|
|
59
|
+
"PendingOrder",
|
|
60
|
+
"AccountBalance",
|
|
61
|
+
"TradeResult",
|
|
62
|
+
"TradeOptions",
|
|
63
|
+
"Quote",
|
|
64
|
+
"InstrumentSearchResult",
|
|
65
|
+
# Enums
|
|
66
|
+
"Xs6Side",
|
|
67
|
+
"SocketStatus",
|
|
68
|
+
"XTBEnvironment",
|
|
69
|
+
"SubscriptionEid",
|
|
70
|
+
]
|
xtb_api/__main__.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""CLI entry point for xtb-api-python.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m xtb_api doctor # Verify installation state
|
|
5
|
+
xtb-api doctor # Same, if the entry point script is on PATH
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import importlib.util
|
|
12
|
+
import platform
|
|
13
|
+
import sys
|
|
14
|
+
from importlib.metadata import PackageNotFoundError
|
|
15
|
+
from importlib.metadata import version as _pkg_version
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ok(label: str, detail: str = "") -> str:
|
|
19
|
+
return f" [OK] {label}" + (f" — {detail}" if detail else "")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fail(label: str, detail: str = "") -> str:
|
|
23
|
+
return f" [FAIL] {label}" + (f" — {detail}" if detail else "")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _info(label: str, detail: str = "") -> str:
|
|
27
|
+
return f" [--] {label}" + (f" — {detail}" if detail else "")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _check_python_version() -> tuple[bool, str]:
|
|
31
|
+
major, minor = sys.version_info[:2]
|
|
32
|
+
version_str = f"{major}.{minor}.{sys.version_info[2]}"
|
|
33
|
+
ok = (major, minor) >= (3, 12)
|
|
34
|
+
return ok, version_str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_package_version() -> tuple[bool, str]:
|
|
38
|
+
try:
|
|
39
|
+
return True, _pkg_version("xtb-api-python")
|
|
40
|
+
except PackageNotFoundError:
|
|
41
|
+
return False, "not installed (are you running from a non-installed checkout?)"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_playwright_package() -> tuple[bool, str]:
|
|
45
|
+
spec = importlib.util.find_spec("playwright")
|
|
46
|
+
if spec is None:
|
|
47
|
+
return False, "playwright Python package not installed"
|
|
48
|
+
try:
|
|
49
|
+
ver = _pkg_version("playwright")
|
|
50
|
+
except PackageNotFoundError:
|
|
51
|
+
ver = "unknown"
|
|
52
|
+
return True, f"playwright {ver}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _check_chromium_binary() -> tuple[bool, str]:
|
|
56
|
+
"""Attempt to locate the Chromium executable without launching it."""
|
|
57
|
+
try:
|
|
58
|
+
from playwright.sync_api import sync_playwright
|
|
59
|
+
except ImportError:
|
|
60
|
+
return False, "playwright not importable"
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with sync_playwright() as p:
|
|
64
|
+
exe = p.chromium.executable_path
|
|
65
|
+
# executable_path is a property that returns a string path; does not
|
|
66
|
+
# guarantee the file exists. Probe it.
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
if exe and Path(exe).exists():
|
|
70
|
+
return True, exe
|
|
71
|
+
return False, f"expected binary at {exe!r} not found"
|
|
72
|
+
except Exception as e: # noqa: BLE001
|
|
73
|
+
return False, f"playwright check failed: {e}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _check_pyotp_optional() -> tuple[bool, str]:
|
|
77
|
+
spec = importlib.util.find_spec("pyotp")
|
|
78
|
+
if spec is None:
|
|
79
|
+
return False, "pyotp not installed (install 'xtb-api-python[totp]' for auto-2FA)"
|
|
80
|
+
try:
|
|
81
|
+
ver = _pkg_version("pyotp")
|
|
82
|
+
except PackageNotFoundError:
|
|
83
|
+
ver = "unknown"
|
|
84
|
+
return True, f"pyotp {ver}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_doctor() -> int:
|
|
88
|
+
"""Run environment checks and print a status report. Returns 0 on success."""
|
|
89
|
+
print(f"xtb-api-python doctor — {platform.platform()}")
|
|
90
|
+
print()
|
|
91
|
+
|
|
92
|
+
all_ok = True
|
|
93
|
+
|
|
94
|
+
# Required checks
|
|
95
|
+
ok, detail = _check_python_version()
|
|
96
|
+
print(_ok("Python >= 3.12", detail) if ok else _fail("Python >= 3.12", detail))
|
|
97
|
+
all_ok &= ok
|
|
98
|
+
|
|
99
|
+
ok, detail = _check_package_version()
|
|
100
|
+
print(_ok("xtb-api-python", detail) if ok else _fail("xtb-api-python", detail))
|
|
101
|
+
all_ok &= ok
|
|
102
|
+
|
|
103
|
+
ok, detail = _check_playwright_package()
|
|
104
|
+
print(_ok("playwright package", detail) if ok else _fail("playwright package", detail))
|
|
105
|
+
all_ok &= ok
|
|
106
|
+
|
|
107
|
+
ok, detail = _check_chromium_binary()
|
|
108
|
+
if ok:
|
|
109
|
+
print(_ok("Chromium binary", detail))
|
|
110
|
+
else:
|
|
111
|
+
print(_fail("Chromium binary", detail))
|
|
112
|
+
print()
|
|
113
|
+
print(" To install Chromium, run:")
|
|
114
|
+
print(" playwright install chromium")
|
|
115
|
+
print()
|
|
116
|
+
all_ok = False
|
|
117
|
+
|
|
118
|
+
# Optional checks
|
|
119
|
+
ok, detail = _check_pyotp_optional()
|
|
120
|
+
print(_ok("pyotp (optional 2FA)", detail) if ok else _info("pyotp (optional 2FA)", detail))
|
|
121
|
+
|
|
122
|
+
print()
|
|
123
|
+
if all_ok:
|
|
124
|
+
print("All required checks passed.")
|
|
125
|
+
return 0
|
|
126
|
+
print("Some required checks failed. See above for fix instructions.")
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main() -> int:
|
|
131
|
+
parser = argparse.ArgumentParser(
|
|
132
|
+
prog="xtb-api",
|
|
133
|
+
description="xtb-api-python CLI",
|
|
134
|
+
)
|
|
135
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
136
|
+
subparsers.add_parser("doctor", help="Verify the library's installation state")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
args = parser.parse_args()
|
|
140
|
+
except SystemExit as e:
|
|
141
|
+
# argparse raises SystemExit on --help or parse errors. Convert to
|
|
142
|
+
# a return value so main() stays testable and honors its -> int contract.
|
|
143
|
+
code = e.code
|
|
144
|
+
if isinstance(code, int):
|
|
145
|
+
return code
|
|
146
|
+
return 2
|
|
147
|
+
|
|
148
|
+
if args.command == "doctor":
|
|
149
|
+
return run_doctor()
|
|
150
|
+
return 2
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
sys.exit(main())
|
xtb_api/auth/__init__.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""High-level authentication manager that encapsulates the full TGT auth flow.
|
|
2
|
+
|
|
3
|
+
Handles cached sessions, REST CAS login, browser fallback, and automatic TOTP —
|
|
4
|
+
so consumers don't need to implement the auth chain themselves.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import time
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from xtb_api.auth.cas_client import CASClient, CASClientConfig
|
|
20
|
+
from xtb_api.types.websocket import (
|
|
21
|
+
CASError,
|
|
22
|
+
CASLoginSuccess,
|
|
23
|
+
CASLoginTwoFactorRequired,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# TGT validity: 8 hours, but refresh 5 minutes early to avoid races
|
|
29
|
+
TGT_LIFETIME_SECONDS = 8 * 3600
|
|
30
|
+
TGT_REFRESH_MARGIN_SECONDS = 5 * 60
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthManager:
|
|
34
|
+
"""Manages the full XTB CAS authentication chain.
|
|
35
|
+
|
|
36
|
+
Auth chain (tried in order):
|
|
37
|
+
1. Cached TGT from session file (if configured and still valid)
|
|
38
|
+
2. REST CAS login (fast, no browser)
|
|
39
|
+
3. Playwright browser fallback (if WAF blocks REST)
|
|
40
|
+
4. Automatic TOTP if 2FA required and totp_secret provided
|
|
41
|
+
|
|
42
|
+
Example::
|
|
43
|
+
|
|
44
|
+
auth = AuthManager(
|
|
45
|
+
email="user@example.com",
|
|
46
|
+
password="secret",
|
|
47
|
+
totp_secret="BASE32SECRET",
|
|
48
|
+
session_file="~/.xtb_session.json",
|
|
49
|
+
)
|
|
50
|
+
tgt = await auth.get_tgt()
|
|
51
|
+
st = await auth.get_service_ticket()
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
email: str,
|
|
57
|
+
password: str,
|
|
58
|
+
totp_secret: str = "",
|
|
59
|
+
session_file: Path | str | None = None,
|
|
60
|
+
cas_config: CASClientConfig | None = None,
|
|
61
|
+
cookies_file: Path | str | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Args:
|
|
65
|
+
email: XTB account email.
|
|
66
|
+
password: XTB account password.
|
|
67
|
+
totp_secret: Base32 TOTP secret for auto-2FA (optional).
|
|
68
|
+
If omitted and 2FA is required, raises CASError.
|
|
69
|
+
session_file: Path to cache TGT as JSON (optional).
|
|
70
|
+
If omitted, TGT is only cached in memory.
|
|
71
|
+
cas_config: Custom CAS client configuration (optional).
|
|
72
|
+
cookies_file: Path to persist HTTP cookies as JSON (optional).
|
|
73
|
+
If omitted but ``session_file`` is set, defaults to
|
|
74
|
+
``<session_file_stem>_cookies.json`` alongside it.
|
|
75
|
+
"""
|
|
76
|
+
self._email = email
|
|
77
|
+
self._password = password
|
|
78
|
+
self._totp_secret = totp_secret
|
|
79
|
+
self._session_file = Path(session_file).expanduser() if session_file else None
|
|
80
|
+
|
|
81
|
+
# Derive cookies_file from session_file when not explicitly provided
|
|
82
|
+
if cookies_file is None and self._session_file is not None:
|
|
83
|
+
cookies_file = self._session_file.parent / f"{self._session_file.stem}_cookies.json"
|
|
84
|
+
|
|
85
|
+
# Inject cookies_file into CAS config
|
|
86
|
+
if cookies_file is not None:
|
|
87
|
+
cas_config = (cas_config or CASClientConfig()).model_copy(update={"cookies_file": cookies_file})
|
|
88
|
+
|
|
89
|
+
self._cas = CASClient(cas_config)
|
|
90
|
+
self._cached_tgt: str | None = None
|
|
91
|
+
self._cached_expires_at: float = 0.0
|
|
92
|
+
|
|
93
|
+
async def get_tgt(self) -> str:
|
|
94
|
+
"""Get a valid TGT, using the full auth chain as needed.
|
|
95
|
+
|
|
96
|
+
Chain: cached (memory/file) -> REST CAS -> browser fallback -> TOTP.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Valid TGT string.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
CASError: If authentication fails at all stages.
|
|
103
|
+
"""
|
|
104
|
+
# 1. Check in-memory cache
|
|
105
|
+
if self._cached_tgt and self._is_tgt_fresh(self._cached_expires_at):
|
|
106
|
+
return self._cached_tgt
|
|
107
|
+
|
|
108
|
+
# 2. Check session file
|
|
109
|
+
if self._session_file:
|
|
110
|
+
cached = self._load_session_file()
|
|
111
|
+
if cached:
|
|
112
|
+
tgt = str(cached["tgt"])
|
|
113
|
+
self._cached_tgt = tgt
|
|
114
|
+
self._cached_expires_at = float(cached["expires_at"])
|
|
115
|
+
return tgt
|
|
116
|
+
|
|
117
|
+
# 3. REST CAS login
|
|
118
|
+
result = await self._login_with_fallback()
|
|
119
|
+
|
|
120
|
+
# 4. Handle 2FA if needed
|
|
121
|
+
if isinstance(result, CASLoginTwoFactorRequired):
|
|
122
|
+
result = await self._handle_two_factor(result)
|
|
123
|
+
|
|
124
|
+
# 5. Cache and return
|
|
125
|
+
self._cache_tgt(result.tgt, result.expires_at)
|
|
126
|
+
return result.tgt
|
|
127
|
+
|
|
128
|
+
def get_tgt_sync(self) -> str:
|
|
129
|
+
"""Synchronous wrapper for :meth:`get_tgt`.
|
|
130
|
+
|
|
131
|
+
Uses a dedicated thread with its own event loop to avoid conflicts
|
|
132
|
+
with any running loop in the caller's thread.
|
|
133
|
+
"""
|
|
134
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
135
|
+
return pool.submit(lambda: asyncio.run(self.get_tgt())).result()
|
|
136
|
+
|
|
137
|
+
async def get_service_ticket(self, service: str = "xapi5") -> str:
|
|
138
|
+
"""Get a service ticket, obtaining a TGT first if needed.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
service: CAS service name. Use ``'xapi5'`` for WebSocket,
|
|
142
|
+
``'abigail'`` for REST API.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Service ticket string (``ST-...``).
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
CASError: If TGT acquisition or service ticket request fails.
|
|
149
|
+
"""
|
|
150
|
+
tgt = await self.get_tgt()
|
|
151
|
+
try:
|
|
152
|
+
st_result = await self._cas.get_service_ticket(tgt, service)
|
|
153
|
+
return st_result.service_ticket
|
|
154
|
+
except CASError as e:
|
|
155
|
+
if e.code == "CAS_TGT_EXPIRED":
|
|
156
|
+
# TGT was cached but expired server-side — clear and retry
|
|
157
|
+
self._invalidate_cache()
|
|
158
|
+
tgt = await self.get_tgt()
|
|
159
|
+
st_result = await self._cas.get_service_ticket(tgt, service)
|
|
160
|
+
return st_result.service_ticket
|
|
161
|
+
raise
|
|
162
|
+
|
|
163
|
+
def invalidate(self) -> None:
|
|
164
|
+
"""Clear cached TGT from memory and session file."""
|
|
165
|
+
self._invalidate_cache()
|
|
166
|
+
|
|
167
|
+
async def aclose(self) -> None:
|
|
168
|
+
"""Close underlying HTTP clients. Call on shutdown."""
|
|
169
|
+
await self._cas.aclose()
|
|
170
|
+
|
|
171
|
+
# -- Internal helpers --
|
|
172
|
+
|
|
173
|
+
async def _login_with_fallback(self) -> CASLoginSuccess | CASLoginTwoFactorRequired:
|
|
174
|
+
"""Try REST CAS login, fall back to browser if WAF blocks."""
|
|
175
|
+
try:
|
|
176
|
+
return await self._cas.login(self._email, self._password)
|
|
177
|
+
except CASError as e:
|
|
178
|
+
# Invalid credentials — don't retry with browser
|
|
179
|
+
if "UNAUTHORIZED" in e.code:
|
|
180
|
+
raise
|
|
181
|
+
logger.info("REST CAS login failed (%s), trying browser fallback", e.code)
|
|
182
|
+
return await self._cas.login_with_browser(self._email, self._password)
|
|
183
|
+
except Exception as e:
|
|
184
|
+
# WAF often returns HTML instead of JSON → httpx decode error
|
|
185
|
+
logger.info("REST CAS login failed (%s), trying browser fallback", e)
|
|
186
|
+
return await self._cas.login_with_browser(self._email, self._password)
|
|
187
|
+
|
|
188
|
+
async def _handle_two_factor(self, challenge: CASLoginTwoFactorRequired) -> CASLoginSuccess:
|
|
189
|
+
"""Handle 2FA challenge using TOTP auto-generation or browser OTP."""
|
|
190
|
+
code = await self._generate_totp()
|
|
191
|
+
|
|
192
|
+
# If browser session is active, prefer browser OTP (WAF blocks REST 2FA too)
|
|
193
|
+
if hasattr(self._cas, "_browser_auth") and self._cas._browser_auth:
|
|
194
|
+
logger.info("Submitting TOTP via browser OTP...")
|
|
195
|
+
result = await self._cas.submit_browser_otp(code)
|
|
196
|
+
else:
|
|
197
|
+
# Try REST 2FA submission
|
|
198
|
+
two_factor_type = "TOTP" if "TOTP" in challenge.methods else challenge.two_factor_auth_type
|
|
199
|
+
result = await self._cas.login_with_two_factor(challenge.login_ticket, code, two_factor_type)
|
|
200
|
+
|
|
201
|
+
if isinstance(result, CASLoginTwoFactorRequired):
|
|
202
|
+
raise CASError(
|
|
203
|
+
"AUTH_MANAGER_2FA_LOOP",
|
|
204
|
+
"Server requested 2FA again after submitting code",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
async def _generate_totp(self) -> str:
|
|
210
|
+
"""Generate a TOTP code from the stored secret.
|
|
211
|
+
|
|
212
|
+
If fewer than 2 seconds remain in the current 30-second TOTP window,
|
|
213
|
+
waits for the next window before generating the code. Without this,
|
|
214
|
+
roughly 6.6% of logins fail because the code expires in transit before
|
|
215
|
+
the server validates it. Waiting (rather than returning the next
|
|
216
|
+
window's code) avoids relying on server-side window-drift tolerance.
|
|
217
|
+
"""
|
|
218
|
+
if not self._totp_secret:
|
|
219
|
+
raise CASError(
|
|
220
|
+
"AUTH_MANAGER_2FA_NO_SECRET",
|
|
221
|
+
"2FA is required but no totp_secret was provided. "
|
|
222
|
+
"Pass totp_secret to AuthManager or disable 2FA on your account.",
|
|
223
|
+
)
|
|
224
|
+
try:
|
|
225
|
+
import pyotp
|
|
226
|
+
except ImportError as e:
|
|
227
|
+
raise CASError(
|
|
228
|
+
"AUTH_MANAGER_PYOTP_MISSING",
|
|
229
|
+
"2FA requires the pyotp package. Install with: pip install 'pyotp>=2.9.0'",
|
|
230
|
+
) from e
|
|
231
|
+
totp = pyotp.TOTP(self._totp_secret)
|
|
232
|
+
remaining = totp.interval - (time.time() % totp.interval)
|
|
233
|
+
if remaining < 2:
|
|
234
|
+
# Within 2s of window boundary — wait for the next window so the
|
|
235
|
+
# generated code is guaranteed valid for its full lifetime.
|
|
236
|
+
await asyncio.sleep(remaining + 0.1)
|
|
237
|
+
return str(totp.now())
|
|
238
|
+
|
|
239
|
+
def _cache_tgt(self, tgt: str, expires_at: float) -> None:
|
|
240
|
+
"""Cache TGT in memory and optionally to session file."""
|
|
241
|
+
self._cached_tgt = tgt
|
|
242
|
+
self._cached_expires_at = expires_at
|
|
243
|
+
|
|
244
|
+
if self._session_file:
|
|
245
|
+
self._save_session_file(tgt, expires_at)
|
|
246
|
+
|
|
247
|
+
def _invalidate_cache(self) -> None:
|
|
248
|
+
"""Clear TGT from memory and session file."""
|
|
249
|
+
self._cached_tgt = None
|
|
250
|
+
self._cached_expires_at = 0.0
|
|
251
|
+
|
|
252
|
+
if self._session_file and self._session_file.exists():
|
|
253
|
+
self._session_file.unlink(missing_ok=True)
|
|
254
|
+
|
|
255
|
+
def _load_session_file(self) -> dict | None:
|
|
256
|
+
"""Load and validate cached TGT from session file.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Dict with 'tgt' and 'expires_at' if valid, None otherwise.
|
|
260
|
+
"""
|
|
261
|
+
if not self._session_file or not self._session_file.exists():
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
# Fix permissions if file is readable by group/others (TGT is sensitive)
|
|
266
|
+
file_mode = self._session_file.stat().st_mode & 0o777
|
|
267
|
+
if file_mode & 0o077:
|
|
268
|
+
logger.warning(
|
|
269
|
+
"Session file %s has permissive permissions (%o). Fixing to 0600.",
|
|
270
|
+
self._session_file,
|
|
271
|
+
file_mode,
|
|
272
|
+
)
|
|
273
|
+
self._session_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
274
|
+
|
|
275
|
+
data = json.loads(self._session_file.read_text())
|
|
276
|
+
tgt = data.get("tgt", "")
|
|
277
|
+
expires_at_str = data.get("expires_at", "")
|
|
278
|
+
|
|
279
|
+
if not tgt or not expires_at_str:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
expires_at = datetime.fromisoformat(expires_at_str).timestamp()
|
|
283
|
+
|
|
284
|
+
if not self._is_tgt_fresh(expires_at):
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
return {"tgt": tgt, "expires_at": expires_at}
|
|
288
|
+
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
def _save_session_file(self, tgt: str, expires_at: float) -> None:
|
|
292
|
+
"""Save TGT to session file as JSON with restricted permissions (0600)."""
|
|
293
|
+
if not self._session_file:
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
extracted_at = datetime.now(UTC)
|
|
297
|
+
expires_at_dt = datetime.fromtimestamp(expires_at, tz=UTC)
|
|
298
|
+
|
|
299
|
+
data = {
|
|
300
|
+
"tgt": tgt,
|
|
301
|
+
"extracted_at": extracted_at.isoformat(),
|
|
302
|
+
"expires_at": expires_at_dt.isoformat(),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
self._session_file.parent.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
content = json.dumps(data, indent=2)
|
|
307
|
+
# Write with owner-only permissions to protect the TGT
|
|
308
|
+
fd = os.open(
|
|
309
|
+
str(self._session_file),
|
|
310
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
311
|
+
stat.S_IRUSR | stat.S_IWUSR, # 0600
|
|
312
|
+
)
|
|
313
|
+
try:
|
|
314
|
+
os.write(fd, content.encode())
|
|
315
|
+
finally:
|
|
316
|
+
os.close(fd)
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _is_tgt_fresh(expires_at: float) -> bool:
|
|
320
|
+
"""Check if TGT is still valid with a safety margin."""
|
|
321
|
+
return time.time() < (expires_at - TGT_REFRESH_MARGIN_SECONDS)
|