touchque-authenticator 4.2.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.
- touchque/__init__.py +148 -0
- touchque/agent.py +314 -0
- touchque/agent_card.py +190 -0
- touchque/agent_frameworks.py +215 -0
- touchque/approval_proof.py +104 -0
- touchque/approval_token.py +134 -0
- touchque/client.py +105 -0
- touchque/code_reply.py +75 -0
- touchque/config.py +56 -0
- touchque/contrib/__init__.py +4 -0
- touchque/contrib/django.py +175 -0
- touchque/contrib/fastapi.py +208 -0
- touchque/contrib/flask.py +154 -0
- touchque/cross_app_access.py +222 -0
- touchque/exceptions.py +170 -0
- touchque/guard.py +290 -0
- touchque/hosted_login.py +474 -0
- touchque/http_client.py +156 -0
- touchque/mcp_auth.py +256 -0
- touchque/my_account.py +173 -0
- touchque/org_agent.py +262 -0
- touchque/rate_limiter.py +56 -0
- touchque/relay.py +643 -0
- touchque/resources/__init__.py +1 -0
- touchque/resources/action_types.py +52 -0
- touchque/resources/actions.py +234 -0
- touchque/resources/agents.py +236 -0
- touchque/resources/assist.py +115 -0
- touchque/resources/auth.py +65 -0
- touchque/resources/device_risk.py +174 -0
- touchque/resources/identity.py +268 -0
- touchque/resources/login.py +296 -0
- touchque/resources/mobile.py +130 -0
- touchque/resources/offline.py +138 -0
- touchque/resources/qr_pairings.py +93 -0
- touchque/resources/recovery.py +118 -0
- touchque/resources/risk.py +141 -0
- touchque/resources/safe_guard.py +99 -0
- touchque/resources/webauthn.py +115 -0
- touchque/resources/webhook.py +258 -0
- touchque/safe_guard.py +308 -0
- touchque/security_events.py +205 -0
- touchque/steps.py +239 -0
- touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
- touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
- touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
- touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
- touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
touchque/relay.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Framework-agnostic relay: the routes every partner backend needs for classic
|
|
3
|
+
push 2FA, offline codes and passkeys, so you don't hand-write them. The Flask,
|
|
4
|
+
FastAPI and Django adapters in ``touchque.contrib`` are thin wrappers over
|
|
5
|
+
``RelayHandler.handle()``. Same routes and behaviour as the Node SDK's
|
|
6
|
+
``touchqueRouter``.
|
|
7
|
+
|
|
8
|
+
Routes (relative to wherever you mount the relay):
|
|
9
|
+
|
|
10
|
+
POST /enroll/start (auth required) -> generate_secret response
|
|
11
|
+
GET /enroll/status (auth required) -> {linked, used, deviceId}
|
|
12
|
+
POST /login (pre-auth) -> 202 {touchque: step, token} until approved
|
|
13
|
+
POST /offline/challenge (pre-auth, body.externalUsername) -> QR for a phone with no internet
|
|
14
|
+
POST /offline/verify (pre-auth, body.challengeId/code)
|
|
15
|
+
POST /offline/totp (pre-auth, body.externalUsername/code) -> time-based code fallback
|
|
16
|
+
POST /passkey/authenticate/options (pre-auth, body.email; none = any passkey, for autofill)
|
|
17
|
+
POST /passkey/authenticate/verify (pre-auth, body.attemptId/response)
|
|
18
|
+
POST /passkey/login/options (pre-auth, body.requestId - approve a pending 2FA request)
|
|
19
|
+
POST /passkey/login/verify (pre-auth, body.requestId/response)
|
|
20
|
+
POST /passkey/register/options (auth required)
|
|
21
|
+
POST /passkey/register/verify (auth required)
|
|
22
|
+
GET /passkey/credentials (auth required)
|
|
23
|
+
DELETE /passkey/credentials/<id> (auth required)
|
|
24
|
+
|
|
25
|
+
"Auth required" routes answer 401 ``UNAUTHORIZED_FOR_2FA`` when
|
|
26
|
+
``get_user_id(request)`` returns nothing.
|
|
27
|
+
|
|
28
|
+
Pre-auth routes are rate limited per IP and per username by default
|
|
29
|
+
(``rate_limit``), and TouchQue itself caps approval pushes per user. Set
|
|
30
|
+
``get_login_user`` so POST /login and the offline routes only work for a user
|
|
31
|
+
who already passed YOUR first factor (password step): then nobody can make
|
|
32
|
+
TouchQue push to, or lock out, an arbitrary user through your server. The
|
|
33
|
+
offline routes are OFF until you do (or explicitly opt out with
|
|
34
|
+
``allow_offline_without_first_factor=True``): with the username taken from
|
|
35
|
+
the body, anyone could burn a user's offline attempts and lock them out.
|
|
36
|
+
"""
|
|
37
|
+
import inspect
|
|
38
|
+
import logging
|
|
39
|
+
import re
|
|
40
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union
|
|
41
|
+
|
|
42
|
+
from .exceptions import (
|
|
43
|
+
TouchQueAPIException, TouchQuePasskeyRequiredError, TouchQueRejectedException, TouchQueTimeoutException,
|
|
44
|
+
)
|
|
45
|
+
from .guard import SilentSignIn, guard_input_from_headers, is_status_poll, run_guard, silent_input_from_headers
|
|
46
|
+
from .rate_limiter import FixedWindowLimiter
|
|
47
|
+
|
|
48
|
+
# A 1.5 s poll for a 2-minute push is 80 requests: one attempt's worth of polls.
|
|
49
|
+
POLLS_PER_ATTEMPT = 10
|
|
50
|
+
#: Reserved action: the approval on the phone that unlocks adding a passkey.
|
|
51
|
+
PASSKEY_ENROLL_ACTION = 'PASSKEY_ENROLL'
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from .client import TouchQue
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger('touchque')
|
|
57
|
+
|
|
58
|
+
#: Every (method, sub-path) the relay answers. ``<id>`` is one path segment.
|
|
59
|
+
RELAY_ROUTES = (
|
|
60
|
+
('POST', '/enroll/start'),
|
|
61
|
+
('GET', '/enroll/status'),
|
|
62
|
+
('POST', '/login'),
|
|
63
|
+
('POST', '/offline/challenge'),
|
|
64
|
+
('POST', '/offline/verify'),
|
|
65
|
+
('POST', '/offline/totp'),
|
|
66
|
+
('POST', '/passkey/authenticate/options'),
|
|
67
|
+
('POST', '/passkey/authenticate/verify'),
|
|
68
|
+
('POST', '/passkey/login/options'),
|
|
69
|
+
('POST', '/passkey/login/verify'),
|
|
70
|
+
('POST', '/passkey/register/options'),
|
|
71
|
+
('POST', '/passkey/register/verify'),
|
|
72
|
+
('GET', '/passkey/credentials'),
|
|
73
|
+
('DELETE', '/passkey/credentials/<id>'),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_CREDENTIAL_PATH = re.compile(r'^/passkey/credentials/([^/]+)$')
|
|
77
|
+
|
|
78
|
+
_warned_pre_auth = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RelayResponse:
|
|
82
|
+
"""What the adapter should send: ``status``, a JSON-serialisable ``body``
|
|
83
|
+
and extra ``headers`` (``Retry-After``, ``Cache-Control``)."""
|
|
84
|
+
|
|
85
|
+
__slots__ = ('status', 'body', 'headers')
|
|
86
|
+
|
|
87
|
+
def __init__(self, status: int, body: Any, headers: Optional[Dict[str, str]] = None):
|
|
88
|
+
self.status = status
|
|
89
|
+
self.body = body
|
|
90
|
+
self.headers = headers or {}
|
|
91
|
+
|
|
92
|
+
def __repr__(self) -> str:
|
|
93
|
+
return 'RelayResponse(status=%r, body=%r, headers=%r)' % (self.status, self.body, self.headers)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _Done(BaseException):
|
|
97
|
+
"""Internal: carries an early response out of a helper. A BaseException so the
|
|
98
|
+
routes' ``except Exception`` error mapping never swallows it."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, response: RelayResponse):
|
|
101
|
+
super().__init__()
|
|
102
|
+
self.response = response
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _json(body: Any, status: int = 200, headers: Optional[Dict[str, str]] = None) -> RelayResponse:
|
|
106
|
+
return RelayResponse(status, body, headers)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _field(body: Any, name: str) -> Any:
|
|
110
|
+
return body.get(name) if isinstance(body, dict) else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _non_empty_str(value: Any) -> Optional[str]:
|
|
114
|
+
return value if isinstance(value, str) and value else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _compact(d: Dict[str, Any]) -> Dict[str, Any]:
|
|
118
|
+
"""Drops ``None`` values (JSON.stringify drops ``undefined`` the same way)."""
|
|
119
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _auth_result_error(error: BaseException) -> RelayResponse:
|
|
123
|
+
"""Same status-code mapping as the step-up guard adapters - kept in sync."""
|
|
124
|
+
if isinstance(error, TouchQueRejectedException):
|
|
125
|
+
return _json({'error': '2FA_REJECTED', 'message': 'User rejected the 2FA request on their device.'}, 403)
|
|
126
|
+
if isinstance(error, TouchQuePasskeyRequiredError):
|
|
127
|
+
return _json({'error': 'PASSKEY_REQUIRED', 'requestId': error.request_id,
|
|
128
|
+
'message': 'Approve this sign-in with your passkey.'}, 409)
|
|
129
|
+
if isinstance(error, TouchQueTimeoutException):
|
|
130
|
+
return _json({'error': '2FA_TIMEOUT', 'message': 'User did not respond to the 2FA request in time.'}, 408)
|
|
131
|
+
# A real API error (e.g. 404 "no linked device for this user") still carries a useful
|
|
132
|
+
# client-range status + message - surface it. Only a network failure or an unexpected
|
|
133
|
+
# error falls through to 503.
|
|
134
|
+
status = getattr(error, 'status', None)
|
|
135
|
+
if isinstance(status, int) and not isinstance(status, bool) and status < 500:
|
|
136
|
+
data = getattr(error, 'data', None)
|
|
137
|
+
data = data if isinstance(data, dict) else {}
|
|
138
|
+
return _json({'error': data.get('error') or data.get('message') or str(error) or 'request_failed'}, status)
|
|
139
|
+
logger.error('[TouchQue Relay Error] %s: %s', type(error).__name__, error)
|
|
140
|
+
return _json({'error': '2FA_UNAVAILABLE', 'message': 'The 2FA verification service is temporarily unavailable.'},
|
|
141
|
+
503)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _relay_error(error: BaseException) -> RelayResponse:
|
|
145
|
+
"""For plain relay routes: a client-range TouchQue status is forwarded with its
|
|
146
|
+
machine-readable code only - never TouchQue's internal message text - anything
|
|
147
|
+
else becomes 502."""
|
|
148
|
+
if isinstance(error, TouchQueAPIException) and error.status is not None and error.status < 500:
|
|
149
|
+
data = error.data if isinstance(error.data, dict) else {}
|
|
150
|
+
return _json({'error': error.code or data.get('error') or 'request_failed'}, error.status)
|
|
151
|
+
return _json({'error': 'relay_error'}, 502)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class RelayHandler:
|
|
155
|
+
"""
|
|
156
|
+
The relay routes, independent of any web framework.
|
|
157
|
+
|
|
158
|
+
``client``: a ``TouchQue`` instance (default: ``get_default_client()``).
|
|
159
|
+
|
|
160
|
+
``get_user_id(request)``: REQUIRED. The signed-in user's identifier for
|
|
161
|
+
the "auth required" routes (enrollment, passkey registration and
|
|
162
|
+
management). Return ``None`` when nobody is signed in - the route answers 401.
|
|
163
|
+
|
|
164
|
+
``get_login_user(request)``: STRONGLY RECOMMENDED. The user who already
|
|
165
|
+
passed your first factor (e.g. the password step stored in the session).
|
|
166
|
+
When set, POST /login, /offline/challenge and /offline/totp act for this
|
|
167
|
+
user and IGNORE any username in the request body; when it returns nothing
|
|
168
|
+
they answer 401 ``FIRST_FACTOR_REQUIRED``.
|
|
169
|
+
|
|
170
|
+
``rate_limit``: built-in limits for the pre-auth routes, per client IP and
|
|
171
|
+
per username, per route. On by default (20 per IP and 5 per username per
|
|
172
|
+
minute). A dict with ``window_ms`` / ``per_ip`` / ``per_user`` overrides
|
|
173
|
+
them; ``False`` turns the limiter off (if you already limit in front).
|
|
174
|
+
The adapters use the framework's client address - make sure it is the
|
|
175
|
+
real client behind a proxy (e.g. werkzeug ``ProxyFix``).
|
|
176
|
+
|
|
177
|
+
``allow_offline_without_first_factor``: the offline routes answer 403
|
|
178
|
+
``OFFLINE_REQUIRES_FIRST_FACTOR`` unless ``get_login_user`` is set. Set
|
|
179
|
+
this to ``True`` only if they really should trust the username in the body.
|
|
180
|
+
|
|
181
|
+
``action_type``: action type for POST /login and the offline routes. Default ``'LOGIN'``.
|
|
182
|
+
|
|
183
|
+
``get_details(request)``: optional transaction context shown on the
|
|
184
|
+
approval screen. Build it from server-side state - the request body is
|
|
185
|
+
attacker-controlled.
|
|
186
|
+
|
|
187
|
+
``silent_sign_in``: silent sign-ins for POST /login (default off). When
|
|
188
|
+
on, a new sign-in whose browser sent its device-bound key proof
|
|
189
|
+
(``X-TouchQue-Browser``, added by ``@touchque/web`` with
|
|
190
|
+
``browserKey: true``) first asks TouchQue whether anything about it is
|
|
191
|
+
new. If nothing is, it succeeds at once with no push (``on_authenticated``
|
|
192
|
+
gets ``via: 'silent'``; the answer's ``assurance['method']`` is
|
|
193
|
+
``'silent'``). Otherwise, and on any error, the usual push starts and its
|
|
194
|
+
approval makes the browser known. Set ``get_login_user`` too: the user
|
|
195
|
+
must have passed your password step. ``{'max_age_seconds': n}`` sets your
|
|
196
|
+
own limit on how long ago the last phone approval in that browser may be.
|
|
197
|
+
|
|
198
|
+
``on_authenticated(request, response, info)``: called right before the
|
|
199
|
+
relay sends its own success response for POST /login (once approved),
|
|
200
|
+
/offline/verify, /offline/totp, /passkey/authenticate/verify and
|
|
201
|
+
/passkey/login/verify. ``info`` is ``{"externalUsername", "requestId"
|
|
202
|
+
(when known), "via": "login" | "passkey" | "offline" | "offline-totp" |
|
|
203
|
+
"silent"}``. Open your session here: mutate ``request`` (session) or
|
|
204
|
+
``response`` (``set_cookie``); do not build a response yourself.
|
|
205
|
+
|
|
206
|
+
Callbacks receive the framework's native request object.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
client: Optional['TouchQue'] = None,
|
|
212
|
+
get_user_id: Optional[Callable[[Any], Optional[str]]] = None,
|
|
213
|
+
get_login_user: Optional[Callable[[Any], Optional[str]]] = None,
|
|
214
|
+
rate_limit: Union[Dict[str, int], bool, None] = None,
|
|
215
|
+
allow_offline_without_first_factor: bool = False,
|
|
216
|
+
action_type: str = 'LOGIN',
|
|
217
|
+
get_details: Optional[Callable[[Any], Any]] = None,
|
|
218
|
+
on_authenticated: Optional[Callable[[Any, Any, Dict[str, Any]], Any]] = None,
|
|
219
|
+
silent_sign_in: SilentSignIn = False,
|
|
220
|
+
):
|
|
221
|
+
global _warned_pre_auth
|
|
222
|
+
if get_user_id is None:
|
|
223
|
+
raise TypeError('RelayHandler: get_user_id is required')
|
|
224
|
+
self._client = client
|
|
225
|
+
self.get_user_id = get_user_id
|
|
226
|
+
self.get_login_user = get_login_user
|
|
227
|
+
self.action_type = action_type
|
|
228
|
+
self.get_details = get_details
|
|
229
|
+
self.on_authenticated = on_authenticated
|
|
230
|
+
self.silent_sign_in = silent_sign_in
|
|
231
|
+
if rate_limit is False:
|
|
232
|
+
self.limiter = None # type: Optional[FixedWindowLimiter]
|
|
233
|
+
else:
|
|
234
|
+
opts = rate_limit if isinstance(rate_limit, dict) else {}
|
|
235
|
+
self.limiter = FixedWindowLimiter(
|
|
236
|
+
window_ms=opts.get('window_ms'), per_ip=opts.get('per_ip'), per_user=opts.get('per_user'))
|
|
237
|
+
self.offline_enabled = bool(get_login_user) or allow_offline_without_first_factor is True
|
|
238
|
+
|
|
239
|
+
if not get_login_user and not _warned_pre_auth:
|
|
240
|
+
_warned_pre_auth = True
|
|
241
|
+
logger.warning(
|
|
242
|
+
'[TouchQue] RelayHandler: POST /login takes the username from the request body%s. Set '
|
|
243
|
+
'`get_login_user` to bind them to your first factor (password step) so they cannot be used '
|
|
244
|
+
'against arbitrary users.',
|
|
245
|
+
', and so do the offline routes' if self.offline_enabled
|
|
246
|
+
else '; the offline routes are disabled until you set it')
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def client(self) -> 'TouchQue':
|
|
250
|
+
if self._client is None:
|
|
251
|
+
from .client import get_default_client
|
|
252
|
+
self._client = get_default_client()
|
|
253
|
+
return self._client
|
|
254
|
+
|
|
255
|
+
# ── entry point ─────────────────────────────────────────────────────
|
|
256
|
+
def handle(
|
|
257
|
+
self,
|
|
258
|
+
method: str,
|
|
259
|
+
path: str,
|
|
260
|
+
body: Any = None,
|
|
261
|
+
ip: Optional[str] = None,
|
|
262
|
+
user_agent: Optional[str] = None,
|
|
263
|
+
get_header: Optional[Callable[[str], Optional[str]]] = None,
|
|
264
|
+
request: Any = None,
|
|
265
|
+
response: Any = None,
|
|
266
|
+
run_async: Optional[Callable[[Any], Any]] = None,
|
|
267
|
+
) -> Optional[RelayResponse]:
|
|
268
|
+
"""Runs one relay route.
|
|
269
|
+
|
|
270
|
+
``method``/``path``: HTTP method and the sub-path below the mount
|
|
271
|
+
point (e.g. ``'/login'``). ``body``: the parsed JSON body (or None).
|
|
272
|
+
``get_header``: case-insensitive header getter. ``request`` /
|
|
273
|
+
``response``: the framework's native objects, passed to the callbacks
|
|
274
|
+
as-is. ``run_async``: how to resolve an awaitable returned by a
|
|
275
|
+
callback (the FastAPI adapter supplies it).
|
|
276
|
+
|
|
277
|
+
Returns the response to send, or ``None`` when no route matches.
|
|
278
|
+
"""
|
|
279
|
+
ctx = _Ctx(self, (method or '').upper(), _norm_path(path), body, ip, user_agent,
|
|
280
|
+
get_header or (lambda name: None), request, response, run_async)
|
|
281
|
+
try:
|
|
282
|
+
return ctx.dispatch()
|
|
283
|
+
except _Done as done:
|
|
284
|
+
return done.response
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _norm_path(path: str) -> str:
|
|
288
|
+
return '/' + (path or '').strip('/')
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class _Ctx:
|
|
292
|
+
"""One request through the relay."""
|
|
293
|
+
|
|
294
|
+
def __init__(self, h: RelayHandler, method: str, path: str, body: Any, ip: Optional[str],
|
|
295
|
+
user_agent: Optional[str], get_header: Callable[[str], Optional[str]], request: Any,
|
|
296
|
+
response: Any, run_async: Optional[Callable[[Any], Any]]):
|
|
297
|
+
self.h = h
|
|
298
|
+
self.method = method
|
|
299
|
+
self.path = path
|
|
300
|
+
self.body = body
|
|
301
|
+
self.ip = ip
|
|
302
|
+
self.user_agent = user_agent
|
|
303
|
+
self.get_header = get_header
|
|
304
|
+
self.request = request
|
|
305
|
+
self.response = response
|
|
306
|
+
self.run_async = run_async
|
|
307
|
+
|
|
308
|
+
# ── helpers ─────────────────────────────────────────────────────────
|
|
309
|
+
def call(self, fn: Callable, *args: Any) -> Any:
|
|
310
|
+
result = fn(*args)
|
|
311
|
+
if inspect.isawaitable(result):
|
|
312
|
+
if self.run_async is None:
|
|
313
|
+
if inspect.iscoroutine(result):
|
|
314
|
+
result.close()
|
|
315
|
+
raise TypeError('TouchQue relay: an async callback needs an async adapter (touchque.contrib.fastapi)')
|
|
316
|
+
result = self.run_async(result)
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
def require_user_id(self) -> str:
|
|
320
|
+
user_id = self.call(self.h.get_user_id, self.request)
|
|
321
|
+
if not user_id:
|
|
322
|
+
raise _Done(_json({
|
|
323
|
+
'error': 'UNAUTHORIZED_FOR_2FA',
|
|
324
|
+
'message': 'Could not find user ID in request. Put your own auth in front of this relay.',
|
|
325
|
+
}, 401))
|
|
326
|
+
return user_id
|
|
327
|
+
|
|
328
|
+
def offline_allowed(self) -> None:
|
|
329
|
+
if not self.h.offline_enabled:
|
|
330
|
+
raise _Done(_json({
|
|
331
|
+
'error': 'OFFLINE_REQUIRES_FIRST_FACTOR',
|
|
332
|
+
'message': 'Offline sign-in is disabled: configure get_login_user (or '
|
|
333
|
+
'allow_offline_without_first_factor) on the TouchQue relay.',
|
|
334
|
+
}, 403))
|
|
335
|
+
|
|
336
|
+
def allow(self, route: str, user: Optional[str] = None, ip_factor: int = 1) -> None:
|
|
337
|
+
"""Answers 429 when this IP or this user is over the limit for ``route``."""
|
|
338
|
+
limiter = self.h.limiter
|
|
339
|
+
if limiter is None:
|
|
340
|
+
return
|
|
341
|
+
ip_wait = limiter.hit('ip|%s|%s' % (route, self.ip or 'unknown'), limiter.per_ip * ip_factor)
|
|
342
|
+
user_wait = limiter.hit('user|%s|%s' % (route, user), limiter.per_user) if user else 0
|
|
343
|
+
wait = max(ip_wait, user_wait)
|
|
344
|
+
if not wait:
|
|
345
|
+
return
|
|
346
|
+
raise _Done(_json({'error': 'RATE_LIMITED', 'message': 'Too many attempts. Try again later.',
|
|
347
|
+
'retryAfter': wait}, 429, {'Retry-After': str(wait)}))
|
|
348
|
+
|
|
349
|
+
def expected_login_user(self) -> Optional[str]:
|
|
350
|
+
return self.call(self.h.get_login_user, self.request)
|
|
351
|
+
|
|
352
|
+
def login_user(self, body_field: str) -> str:
|
|
353
|
+
"""Who a pre-auth route acts for: the first-factor user when configured, otherwise the typed username."""
|
|
354
|
+
if self.h.get_login_user:
|
|
355
|
+
u = self.expected_login_user()
|
|
356
|
+
if not u:
|
|
357
|
+
raise _Done(_json({'error': 'FIRST_FACTOR_REQUIRED', 'message': 'Sign in with your password first.'},
|
|
358
|
+
401))
|
|
359
|
+
return str(u).lower()
|
|
360
|
+
typed = str(_field(self.body, body_field) or '').lower()
|
|
361
|
+
if not typed:
|
|
362
|
+
raise _Done(_json({'error': 'externalUsername_required'}, 400))
|
|
363
|
+
return typed
|
|
364
|
+
|
|
365
|
+
def details(self) -> Any:
|
|
366
|
+
return self.call(self.h.get_details, self.request) if self.h.get_details else None
|
|
367
|
+
|
|
368
|
+
def authenticated(self, info: Dict[str, Any]) -> None:
|
|
369
|
+
if self.h.on_authenticated:
|
|
370
|
+
self.call(self.h.on_authenticated, self.request, self.response, info)
|
|
371
|
+
|
|
372
|
+
def same_user(self, expected: Any, actual: str) -> bool:
|
|
373
|
+
return bool(expected) and str(expected).lower() == actual.lower()
|
|
374
|
+
|
|
375
|
+
# ── routing ─────────────────────────────────────────────────────────
|
|
376
|
+
def dispatch(self) -> Optional[RelayResponse]:
|
|
377
|
+
routes = {
|
|
378
|
+
('POST', '/enroll/start'): self.enroll_start,
|
|
379
|
+
('GET', '/enroll/status'): self.enroll_status,
|
|
380
|
+
('POST', '/login'): self.login,
|
|
381
|
+
('POST', '/offline/challenge'): self.offline_challenge,
|
|
382
|
+
('POST', '/offline/verify'): self.offline_verify,
|
|
383
|
+
('POST', '/offline/totp'): self.offline_totp,
|
|
384
|
+
('POST', '/passkey/authenticate/options'): self.passkey_authenticate_options,
|
|
385
|
+
('POST', '/passkey/authenticate/verify'): self.passkey_authenticate_verify,
|
|
386
|
+
('POST', '/passkey/login/options'): self.passkey_login_options,
|
|
387
|
+
('POST', '/passkey/login/verify'): self.passkey_login_verify,
|
|
388
|
+
('POST', '/passkey/register/options'): self.passkey_register_options,
|
|
389
|
+
('POST', '/passkey/register/verify'): self.passkey_register_verify,
|
|
390
|
+
('GET', '/passkey/credentials'): self.passkey_credentials,
|
|
391
|
+
} # type: Dict[Tuple[str, str], Callable[[], RelayResponse]]
|
|
392
|
+
route = routes.get((self.method, self.path))
|
|
393
|
+
if route is not None:
|
|
394
|
+
return route()
|
|
395
|
+
m = _CREDENTIAL_PATH.match(self.path)
|
|
396
|
+
if m and self.method == 'DELETE':
|
|
397
|
+
return self.passkey_credential_delete(m.group(1))
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
# ── classic push 2FA: enrollment ────────────────────────────────────
|
|
401
|
+
def enroll_start(self) -> RelayResponse:
|
|
402
|
+
user_id = self.require_user_id()
|
|
403
|
+
tq = self.h.client
|
|
404
|
+
try:
|
|
405
|
+
return _json(tq.auth.generate_secret(user_id))
|
|
406
|
+
except Exception as error: # noqa: BLE001 - mapped below, like the Node router
|
|
407
|
+
# 409: either a QR was issued and not scanned yet (safe to issue a new one - no
|
|
408
|
+
# phone to lose), or a phone is ALREADY linked. Replacing a linked phone must never
|
|
409
|
+
# happen from a plain session: that is exactly how an attacker with a stolen
|
|
410
|
+
# password would swap in their own phone.
|
|
411
|
+
if getattr(error, 'status', None) == 409:
|
|
412
|
+
try:
|
|
413
|
+
current = tq.auth.get_user(user_id)
|
|
414
|
+
if current.get('used') or current.get('deviceId'):
|
|
415
|
+
return _json({'error': 'already_linked',
|
|
416
|
+
'message': 'A phone is already linked to this account.'}, 409)
|
|
417
|
+
return _json(tq.auth.reset_secret(user_id))
|
|
418
|
+
except Exception as retry_error: # noqa: BLE001
|
|
419
|
+
return _relay_error(retry_error)
|
|
420
|
+
return _relay_error(error)
|
|
421
|
+
|
|
422
|
+
def enroll_status(self) -> RelayResponse:
|
|
423
|
+
user_id = self.require_user_id()
|
|
424
|
+
try:
|
|
425
|
+
u = self.h.client.auth.get_user(user_id)
|
|
426
|
+
out = {'linked': bool(u.get('used') and u.get('deviceId'))} # type: Dict[str, Any]
|
|
427
|
+
for key in ('used', 'deviceId'):
|
|
428
|
+
if key in u:
|
|
429
|
+
out[key] = u[key]
|
|
430
|
+
return _json(out)
|
|
431
|
+
except Exception as error: # noqa: BLE001
|
|
432
|
+
if getattr(error, 'status', None) == 404:
|
|
433
|
+
return _json({'linked': False, 'used': False, 'deviceId': None})
|
|
434
|
+
return _relay_error(error)
|
|
435
|
+
|
|
436
|
+
# ── classic push 2FA: login ─────────────────────────────────────────
|
|
437
|
+
def login(self) -> RelayResponse:
|
|
438
|
+
external_username = self.login_user('externalUsername')
|
|
439
|
+
details = self.details()
|
|
440
|
+
headers_in = guard_input_from_headers(self.get_header)
|
|
441
|
+
# Polling an approval already sent is not a new attempt: it has its own,
|
|
442
|
+
# larger per-IP budget and never uses the user's push budget.
|
|
443
|
+
if is_status_poll(self.h.client, external_username, self.h.action_type, details=details, **headers_in):
|
|
444
|
+
self.allow('login-poll', ip_factor=POLLS_PER_ATTEMPT)
|
|
445
|
+
else:
|
|
446
|
+
self.allow('login', external_username)
|
|
447
|
+
# Same two-phase contract as the step-up guard: 202 with the step (matching number /
|
|
448
|
+
# first-time QR / offline QR) until approved; the page re-sends with X-TouchQue-Token.
|
|
449
|
+
result = run_guard(
|
|
450
|
+
self.h.client, external_username, self.h.action_type,
|
|
451
|
+
details=details, ip=self.ip, user_agent=self.user_agent,
|
|
452
|
+
# Pre-auth: a typed username proves nothing, so no linking QR unless
|
|
453
|
+
# get_login_user ties this route to the password step.
|
|
454
|
+
enroll=bool(self.h.get_login_user),
|
|
455
|
+
**headers_in,
|
|
456
|
+
**silent_input_from_headers(self.h.silent_sign_in, self.get_header)
|
|
457
|
+
)
|
|
458
|
+
approved = result.get('approved')
|
|
459
|
+
if not approved:
|
|
460
|
+
headers = {}
|
|
461
|
+
retry = (result['body'].get('touchque') or {}).get('retryAfter')
|
|
462
|
+
if retry:
|
|
463
|
+
headers['Retry-After'] = str(retry)
|
|
464
|
+
headers['Cache-Control'] = 'no-store'
|
|
465
|
+
return _json(result['body'], result['status'], headers)
|
|
466
|
+
try:
|
|
467
|
+
confirmed = approved.get('confirmedVia')
|
|
468
|
+
via = 'offline-totp' if confirmed == 'OFFLINE_TOTP' else 'offline' if confirmed == 'OFFLINE_CODE' \
|
|
469
|
+
else 'silent' if confirmed == 'SILENT' else 'login'
|
|
470
|
+
self.authenticated({'externalUsername': external_username, 'requestId': approved.get('requestId'),
|
|
471
|
+
'via': via})
|
|
472
|
+
return _json(_compact({'status': 'success', 'requestId': approved.get('requestId'),
|
|
473
|
+
'assurance': approved.get('assurance')}))
|
|
474
|
+
except Exception as error: # noqa: BLE001
|
|
475
|
+
return _auth_result_error(error)
|
|
476
|
+
|
|
477
|
+
# ── offline sign: QR challenge / typed code ─────────────────────────
|
|
478
|
+
# The username used for on_authenticated is the one TouchQue returns for the
|
|
479
|
+
# challenge/code, never the one in the request body.
|
|
480
|
+
def offline_challenge(self) -> RelayResponse:
|
|
481
|
+
self.offline_allowed()
|
|
482
|
+
external_username = self.login_user('externalUsername')
|
|
483
|
+
self.allow('offline-challenge', external_username)
|
|
484
|
+
try:
|
|
485
|
+
# `requestId` (the push this QR follows) makes a phone-side rejection kill the QR
|
|
486
|
+
# and carries the number.
|
|
487
|
+
c = self.h.client.offline.challenge(
|
|
488
|
+
external_username, type=self.h.action_type, client_ip=self.ip, user_agent=self.user_agent,
|
|
489
|
+
details=self.details(), request_id=_non_empty_str(_field(self.body, 'requestId')),
|
|
490
|
+
)
|
|
491
|
+
out = {k: c[k] for k in ('challengeId', 'qr', 'qrDataUrl', 'expiresAt', 'expiresInSeconds',
|
|
492
|
+
'totpAvailable') if k in c}
|
|
493
|
+
if c.get('challengeCode'):
|
|
494
|
+
out['challengeCode'] = c['challengeCode']
|
|
495
|
+
return _json(out)
|
|
496
|
+
except Exception as error: # noqa: BLE001
|
|
497
|
+
return _relay_error(error)
|
|
498
|
+
|
|
499
|
+
def offline_verify(self) -> RelayResponse:
|
|
500
|
+
self.offline_allowed()
|
|
501
|
+
self.allow('offline-verify')
|
|
502
|
+
try:
|
|
503
|
+
r = self.h.client.offline.verify(str(_field(self.body, 'challengeId') or ''),
|
|
504
|
+
str(_field(self.body, 'code') or ''))
|
|
505
|
+
if not r.get('approved') or not r.get('externalUsername'):
|
|
506
|
+
return _json(_compact({'approved': False, 'reason': r.get('reason'),
|
|
507
|
+
'attemptsLeft': r.get('attemptsLeft')}),
|
|
508
|
+
401 if r.get('reason') == 'invalid_code' else 400)
|
|
509
|
+
# With a first factor, the code must belong to the user who passed it.
|
|
510
|
+
if self.h.get_login_user:
|
|
511
|
+
if not self.same_user(self.expected_login_user(), r['externalUsername']):
|
|
512
|
+
return _json({'approved': False, 'reason': 'invalid_code'}, 401)
|
|
513
|
+
self.authenticated({'externalUsername': r['externalUsername'], 'requestId': r.get('challengeId'),
|
|
514
|
+
'via': 'offline'})
|
|
515
|
+
return _json(_compact({'status': 'success', 'approved': True, 'requestId': r.get('challengeId')}))
|
|
516
|
+
except Exception as error: # noqa: BLE001
|
|
517
|
+
return _relay_error(error)
|
|
518
|
+
|
|
519
|
+
def offline_totp(self) -> RelayResponse:
|
|
520
|
+
self.offline_allowed()
|
|
521
|
+
external_username = self.login_user('externalUsername')
|
|
522
|
+
self.allow('offline-totp', external_username)
|
|
523
|
+
try:
|
|
524
|
+
r = self.h.client.offline.verify_totp(
|
|
525
|
+
external_username, str(_field(self.body, 'code') or ''), type=self.h.action_type,
|
|
526
|
+
client_ip=self.ip, request_id=_non_empty_str(_field(self.body, 'requestId')),
|
|
527
|
+
)
|
|
528
|
+
if not r.get('approved') or not r.get('externalUsername'):
|
|
529
|
+
return _json(_compact({'approved': False, 'reason': r.get('reason')}),
|
|
530
|
+
401 if r.get('reason') == 'invalid_code' else 400)
|
|
531
|
+
self.authenticated({'externalUsername': r['externalUsername'], 'via': 'offline-totp'})
|
|
532
|
+
return _json({'status': 'success', 'approved': True})
|
|
533
|
+
except Exception as error: # noqa: BLE001
|
|
534
|
+
return _relay_error(error)
|
|
535
|
+
|
|
536
|
+
# ── passkey: passwordless sign-in ───────────────────────────────────
|
|
537
|
+
def passkey_authenticate_options(self) -> RelayResponse:
|
|
538
|
+
email = str(_field(self.body, 'email') or '').lower()
|
|
539
|
+
self.allow('passkey-options', email or None)
|
|
540
|
+
try:
|
|
541
|
+
return _json(self.h.client.webauthn.primary_options(email or None))
|
|
542
|
+
except Exception as error: # noqa: BLE001
|
|
543
|
+
return _relay_error(error)
|
|
544
|
+
|
|
545
|
+
def passkey_authenticate_verify(self) -> RelayResponse:
|
|
546
|
+
self.allow('passkey-verify')
|
|
547
|
+
try:
|
|
548
|
+
r = self.h.client.webauthn.primary_verify(_field(self.body, 'attemptId'), _field(self.body, 'response'))
|
|
549
|
+
if r.get('success'):
|
|
550
|
+
self.authenticated({'externalUsername': r.get('externalUsername'), 'requestId': r.get('requestId'),
|
|
551
|
+
'via': 'passkey'})
|
|
552
|
+
return _json(_compact({'status': 'success', 'requestId': r.get('requestId')}))
|
|
553
|
+
if r.get('requiresStepUp'):
|
|
554
|
+
return _json({'requiresStepUp': True})
|
|
555
|
+
return _json({'error': 'verification_failed'}, 401)
|
|
556
|
+
except Exception as error: # noqa: BLE001
|
|
557
|
+
return _relay_error(error)
|
|
558
|
+
|
|
559
|
+
# ── passkey: approve an already-pending 2FA request (second factor) ─
|
|
560
|
+
def passkey_login_options(self) -> RelayResponse:
|
|
561
|
+
self.allow('passkey-approve')
|
|
562
|
+
try:
|
|
563
|
+
return _json(self.h.client.webauthn.authenticate_options(_field(self.body, 'requestId')))
|
|
564
|
+
except Exception as error: # noqa: BLE001
|
|
565
|
+
return _relay_error(error)
|
|
566
|
+
|
|
567
|
+
def passkey_login_verify(self) -> RelayResponse:
|
|
568
|
+
self.allow('passkey-approve')
|
|
569
|
+
try:
|
|
570
|
+
r = self.h.client.webauthn.authenticate_verify(_field(self.body, 'requestId'),
|
|
571
|
+
_field(self.body, 'response'))
|
|
572
|
+
approved_user = r.get('externalUsername')
|
|
573
|
+
# Open the session only for the user TouchQue says was approved. A name sent by the
|
|
574
|
+
# browser is never trusted: anyone could approve their own request and claim to be
|
|
575
|
+
# someone else.
|
|
576
|
+
if r.get('success') and self.h.get_login_user and isinstance(approved_user, str):
|
|
577
|
+
if not self.same_user(self.expected_login_user(), approved_user):
|
|
578
|
+
return _json({'error': 'verification_failed'}, 401)
|
|
579
|
+
if r.get('success') and isinstance(approved_user, str) and approved_user:
|
|
580
|
+
self.authenticated({'externalUsername': approved_user,
|
|
581
|
+
'requestId': r.get('requestId') or _field(self.body, 'requestId'),
|
|
582
|
+
'via': 'passkey'})
|
|
583
|
+
return _json(r)
|
|
584
|
+
except Exception as error: # noqa: BLE001
|
|
585
|
+
return _relay_error(error)
|
|
586
|
+
|
|
587
|
+
# ── passkey: enrollment / management (auth required) ────────────────
|
|
588
|
+
def passkey_register_options(self) -> RelayResponse:
|
|
589
|
+
"""A user with a linked phone approves adding a passkey there first: a
|
|
590
|
+
passkey approves requests on its own, so a stolen session of your app
|
|
591
|
+
must not be able to add one. Same two-phase contract as /login: 202
|
|
592
|
+
with the step (and number to show) until approved, then the options."""
|
|
593
|
+
user_id = self.require_user_id()
|
|
594
|
+
headers_in = guard_input_from_headers(self.get_header)
|
|
595
|
+
try:
|
|
596
|
+
if not headers_in.get('token'):
|
|
597
|
+
try:
|
|
598
|
+
return _json(self.h.client.webauthn.register_options(user_id, discoverable=True))
|
|
599
|
+
except TouchQueAPIException as error:
|
|
600
|
+
if not (error.status == 403 and error.code == 'enrollment_approval_required'):
|
|
601
|
+
raise
|
|
602
|
+
if is_status_poll(self.h.client, user_id, PASSKEY_ENROLL_ACTION, **headers_in):
|
|
603
|
+
self.allow('login-poll', ip_factor=POLLS_PER_ATTEMPT)
|
|
604
|
+
else:
|
|
605
|
+
self.allow('passkey-enroll', user_id)
|
|
606
|
+
result = run_guard(self.h.client, user_id, PASSKEY_ENROLL_ACTION, ip=self.ip, user_agent=self.user_agent,
|
|
607
|
+
enroll=False, **headers_in)
|
|
608
|
+
approved = result.get('approved')
|
|
609
|
+
if not approved:
|
|
610
|
+
headers = {'Cache-Control': 'no-store'}
|
|
611
|
+
retry = (result['body'].get('touchque') or {}).get('retryAfter')
|
|
612
|
+
if retry:
|
|
613
|
+
headers['Retry-After'] = str(retry)
|
|
614
|
+
return _json(result['body'], result['status'], headers)
|
|
615
|
+
return _json(self.h.client.webauthn.register_options(
|
|
616
|
+
user_id, discoverable=True, approval_request_id=approved.get('requestId')))
|
|
617
|
+
except Exception as error: # noqa: BLE001
|
|
618
|
+
return _relay_error(error)
|
|
619
|
+
|
|
620
|
+
def passkey_register_verify(self) -> RelayResponse:
|
|
621
|
+
user_id = self.require_user_id()
|
|
622
|
+
try:
|
|
623
|
+
return _json(self.h.client.webauthn.register_verify(
|
|
624
|
+
user_id, _field(self.body, 'response'), label=_field(self.body, 'label')))
|
|
625
|
+
except Exception as error: # noqa: BLE001
|
|
626
|
+
return _relay_error(error)
|
|
627
|
+
|
|
628
|
+
def passkey_credentials(self) -> RelayResponse:
|
|
629
|
+
user_id = self.require_user_id()
|
|
630
|
+
try:
|
|
631
|
+
return _json(self.h.client.webauthn.list_credentials(user_id))
|
|
632
|
+
except Exception as error: # noqa: BLE001
|
|
633
|
+
return _relay_error(error)
|
|
634
|
+
|
|
635
|
+
def passkey_credential_delete(self, credential_id: str) -> RelayResponse:
|
|
636
|
+
user_id = self.require_user_id()
|
|
637
|
+
try:
|
|
638
|
+
return _json(self.h.client.webauthn.delete_credential(credential_id, external_username=user_id))
|
|
639
|
+
except Exception as error: # noqa: BLE001
|
|
640
|
+
return _relay_error(error)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
__all__ = ['RelayHandler', 'RelayResponse', 'RELAY_ROUTES', 'FixedWindowLimiter']
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Empty init for resources module
|