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
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
# Label -> value, or an ordered list of {'label': ..., 'value': ...}.
|
|
7
|
+
LoginDetails = Union[Dict[str, Union[str, int, float]], List[Dict[str, Any]]]
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
from ..http_client import HttpClient
|
|
10
|
+
from ..exceptions import TouchQueTimeoutException, TouchQueRejectedException, TouchQuePasskeyRequiredError, TouchQueConfigException
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger('touchque')
|
|
13
|
+
|
|
14
|
+
SECURITY_CHANGE_KINDS = ('password', 'mfa', 'device', 'email', 'phone')
|
|
15
|
+
DESTINATION_KINDS = ('iban', 'account', 'wallet', 'address', 'payee')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _security_changes_body(changes: Dict[str, Any]) -> Dict[str, Any]:
|
|
19
|
+
"""datetimes go out as ISO 8601 (a naive one is taken as UTC); strings and epoch milliseconds as given."""
|
|
20
|
+
out: Dict[str, Any] = {}
|
|
21
|
+
for kind, at in changes.items():
|
|
22
|
+
if at is None:
|
|
23
|
+
continue
|
|
24
|
+
if isinstance(at, datetime):
|
|
25
|
+
out[kind] = (at if at.tzinfo else at.replace(tzinfo=timezone.utc)).isoformat()
|
|
26
|
+
else:
|
|
27
|
+
out[kind] = at
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Login:
|
|
32
|
+
def __init__(self, http: HttpClient):
|
|
33
|
+
self._http = http
|
|
34
|
+
|
|
35
|
+
def request(self, external_username: str, type: str = 'LOGIN', reference_id: Optional[str] = None, client_ip: Optional[str] = None, user_agent: Optional[str] = None, require_biometric: bool = False, require_number_match: bool = False, details: Optional[LoginDetails] = None, browser_proof: Optional[str] = None, context: Optional[Dict[str, Any]] = None, challenge_digits: Optional[int] = None, expires_in: Optional[int] = None, client_signals: Optional[str] = None, security_changes: Optional[Dict[str, Any]] = None, destination: Optional[Dict[str, Any]] = None, locale: Optional[str] = None, qr_pairing: Optional[str] = None, device_risk: Optional[str] = None, device_risk_request_id: Optional[str] = None, reply: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Request a 2FA login/approval from the user's mobile device.
|
|
38
|
+
|
|
39
|
+
When the integration has behavioral biometrics enabled
|
|
40
|
+
(``TenantPolicy.behavioralBiometricsEnabled``), the response also
|
|
41
|
+
contains ``telemetryToken`` — pass it plus ``requestId`` to your
|
|
42
|
+
frontend to initialize the ``@touchque/web`` behavioral widget
|
|
43
|
+
(``tq.behavioral.attach(...)``). There is no widget in this server SDK.
|
|
44
|
+
|
|
45
|
+
``details`` is transaction context shown on the approval screen, e.g.
|
|
46
|
+
``{'Amount': '1,250.00 USD', 'Recipient': 'Jane Doe'}`` (shown in
|
|
47
|
+
order). At most 8 entries, labels <= 40 and values <= 120
|
|
48
|
+
characters; the API rejects (never truncates) anything outside those
|
|
49
|
+
limits. Build it from server-side state, never from browser input.
|
|
50
|
+
|
|
51
|
+
``browser_proof`` (silent sign-ins): the browser's device-bound key
|
|
52
|
+
proof, exactly as the browser sent it (``X-TouchQue-Browser`` header,
|
|
53
|
+
made by ``@touchque/web``). When the user approves this request on the
|
|
54
|
+
phone (or with a passkey), TouchQue remembers the browser, so a later
|
|
55
|
+
``risk.check`` can let the same browser in without asking. Pass it
|
|
56
|
+
through untouched; an invalid proof only means "a browser TouchQue
|
|
57
|
+
does not know".
|
|
58
|
+
|
|
59
|
+
``context``: for your TouchQue Actions only (``event.context``): roles,
|
|
60
|
+
plan, amount… Any JSON object up to 4 KB. Never shown on the phone.
|
|
61
|
+
|
|
62
|
+
``expires_in``: how long the push waits for the person, in seconds:
|
|
63
|
+
30 (default) to 300; longer suits a request the person has to read
|
|
64
|
+
first, such as an AI agent asking on their behalf.
|
|
65
|
+
|
|
66
|
+
``qr_pairing``: a browser pairing by QR code (``client.qr_pairings``),
|
|
67
|
+
scanned and confirmed on the phone: only the phone that scanned it is
|
|
68
|
+
asked, once, with number matching, for the user it scanned for.
|
|
69
|
+
|
|
70
|
+
``device_risk_request_id``: the page's identification from ``@touchque/web``
|
|
71
|
+
(``deviceRisk.identify()``), for this account only; or ``device_risk``,
|
|
72
|
+
the page's report (``deviceRisk.collect({ purpose })``), passed through untouched. Used
|
|
73
|
+
only when your workspace turned Device risk on: its signals only ever
|
|
74
|
+
ask for more, and a device your admin marked "step up always" asks at once.
|
|
75
|
+
|
|
76
|
+
``client_signals``: the end user's page check from ``@touchque/web``
|
|
77
|
+
(``startClientSignals().encode()``), passed through untouched. A browser
|
|
78
|
+
driven by software asks with number matching, or is refused where the
|
|
79
|
+
workspace blocks it (403 ``automated_client``).
|
|
80
|
+
|
|
81
|
+
``security_changes``: when the user's sign-in security last changed in
|
|
82
|
+
your system, ``{'password', 'mfa', 'device', 'email', 'phone'}`` each a
|
|
83
|
+
datetime, ISO 8601 string or epoch milliseconds. TouchQue adds what it
|
|
84
|
+
saw itself; access rules and Actions read the minutes since each
|
|
85
|
+
(``minutesSince.mfaChange``, ``.deviceChange``, ...), e.g. refuse
|
|
86
|
+
``PAYOUT_CHANGE`` within 24 hours of a 2FA change.
|
|
87
|
+
|
|
88
|
+
``destination``: where the action sends money or value,
|
|
89
|
+
``{'kind': 'iban'|'account'|'wallet'|'address'|'payee', 'value': ..., 'known': [...]}``.
|
|
90
|
+
The phone shows it in full in groups of four, with a warning when it
|
|
91
|
+
looks like one the user used before but is not the same; the answer's
|
|
92
|
+
``destination`` is ``{'known', 'lookalike'}``. Do not repeat it in ``details``.
|
|
93
|
+
|
|
94
|
+
``locale``: ``'tr'`` (default) or ``'en'``, the language of those rows.
|
|
95
|
+
|
|
96
|
+
``reply``: ask the person to type back on the phone a sign-in code another service sent
|
|
97
|
+
them, ``{'kind': 'code', 'key': new_code_reply_key()['public_key'], 'channel': 'sms',
|
|
98
|
+
'target': '+**********26'}``. The phone encrypts it to ``key``; ``status()['reply']['ct']``
|
|
99
|
+
holds only the ciphertext: open it with ``open_code_reply``. Such a request lives 5
|
|
100
|
+
minutes. Not with ``qr_pairing``.
|
|
101
|
+
"""
|
|
102
|
+
payload = {
|
|
103
|
+
'externalUsername': external_username,
|
|
104
|
+
'type': type
|
|
105
|
+
}
|
|
106
|
+
if reference_id is not None:
|
|
107
|
+
payload['referenceId'] = reference_id
|
|
108
|
+
if client_ip is not None:
|
|
109
|
+
payload['clientIp'] = client_ip
|
|
110
|
+
if user_agent is not None:
|
|
111
|
+
payload['userAgent'] = user_agent
|
|
112
|
+
if require_biometric:
|
|
113
|
+
payload['requireBiometric'] = True
|
|
114
|
+
if require_number_match:
|
|
115
|
+
payload['requireNumberMatch'] = True
|
|
116
|
+
if challenge_digits in (2, 3):
|
|
117
|
+
payload['challengeDigits'] = challenge_digits
|
|
118
|
+
if expires_in is not None:
|
|
119
|
+
payload['expiresIn'] = expires_in
|
|
120
|
+
if qr_pairing:
|
|
121
|
+
payload['qrPairing'] = qr_pairing
|
|
122
|
+
if reply is not None:
|
|
123
|
+
payload['reply'] = reply
|
|
124
|
+
if client_signals:
|
|
125
|
+
payload['clientSignals'] = client_signals
|
|
126
|
+
if device_risk:
|
|
127
|
+
payload['deviceRisk'] = device_risk
|
|
128
|
+
if device_risk_request_id:
|
|
129
|
+
payload['deviceRiskRequestId'] = device_risk_request_id
|
|
130
|
+
if details:
|
|
131
|
+
payload['details'] = details
|
|
132
|
+
if browser_proof:
|
|
133
|
+
payload['browserProof'] = browser_proof
|
|
134
|
+
if context:
|
|
135
|
+
payload['context'] = context
|
|
136
|
+
if security_changes:
|
|
137
|
+
payload['securityChanges'] = _security_changes_body(security_changes)
|
|
138
|
+
if destination:
|
|
139
|
+
payload['destination'] = destination
|
|
140
|
+
if locale:
|
|
141
|
+
payload['locale'] = locale
|
|
142
|
+
|
|
143
|
+
return self._http.post('/login/request', payload)
|
|
144
|
+
|
|
145
|
+
def status(self, request_id: str) -> Dict[str, Any]:
|
|
146
|
+
"""Check the current status of a login request."""
|
|
147
|
+
return self._http.get(f"/login/status/{quote(request_id, safe='')}")
|
|
148
|
+
|
|
149
|
+
def wait_for_approval(self, request_id: str, timeout_ms: int = 30000, poll_interval_ms: int = 400) -> Dict[str, Any]:
|
|
150
|
+
"""Wait (poll) until the user approves or rejects a request you already sent with ``request``.
|
|
151
|
+
|
|
152
|
+
Returns ``{'approved': True, 'status': 'CONFIRMED', 'requestId', 'assurance', 'confirmedVia',
|
|
153
|
+
'provisional'}``. Raises ``TouchQueRejectedException`` when the user said no,
|
|
154
|
+
``TouchQueTimeoutException`` when the request expired or ``timeout_ms`` ran out, and
|
|
155
|
+
``TouchQuePasskeyRequiredError`` (``reason='step_up'``) when the user tapped Approve
|
|
156
|
+
during a call and the request now waits for a passkey in the browser::
|
|
157
|
+
|
|
158
|
+
req = tq.login.request(user, 'SEND_MONEY')
|
|
159
|
+
tq.login.wait_for_approval(req['requestId'], timeout_ms=60000)"""
|
|
160
|
+
start_time = time.time() * 1000
|
|
161
|
+
|
|
162
|
+
while True:
|
|
163
|
+
status_result = self.status(request_id)
|
|
164
|
+
status = status_result.get('status')
|
|
165
|
+
|
|
166
|
+
if status == 'CONFIRMED':
|
|
167
|
+
return {
|
|
168
|
+
'approved': True,
|
|
169
|
+
'status': status,
|
|
170
|
+
'requestId': request_id,
|
|
171
|
+
'assurance': status_result.get('assurance'),
|
|
172
|
+
'confirmedVia': status_result.get('confirmedVia'),
|
|
173
|
+
# Vishing Shield: approved during a call; critical actions wait for the phone to re-confirm.
|
|
174
|
+
'provisional': bool(status_result.get('provisional')),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if status == 'REJECTED':
|
|
178
|
+
raise TouchQueRejectedException(f"Request {request_id} was rejected by the user.")
|
|
179
|
+
|
|
180
|
+
if status == 'EXPIRED':
|
|
181
|
+
raise TouchQueTimeoutException(f"Request {request_id} expired before it was approved.")
|
|
182
|
+
|
|
183
|
+
# Vishing Shield: tapped during a call, the request now waits for a passkey in the browser.
|
|
184
|
+
if status == 'PENDING' and status_result.get('requiresPasskey'):
|
|
185
|
+
raise TouchQuePasskeyRequiredError(request_id, 'step_up')
|
|
186
|
+
|
|
187
|
+
elapsed = (time.time() * 1000) - start_time
|
|
188
|
+
if elapsed > timeout_ms:
|
|
189
|
+
raise TouchQueTimeoutException(f"Waiting for approval timed out after {timeout_ms}ms")
|
|
190
|
+
|
|
191
|
+
time.sleep(poll_interval_ms / 1000)
|
|
192
|
+
|
|
193
|
+
def verify(self, external_username: str, type: str = 'LOGIN', reference_id: Optional[str] = None, client_ip: Optional[str] = None, user_agent: Optional[str] = None, require_biometric: bool = False, require_number_match: bool = False, timeout_ms: int = 30000, details: Optional[LoginDetails] = None, browser_proof: Optional[str] = None, on_challenge: Optional[Callable[[Dict[str, Any]], Any]] = None, context: Optional[Dict[str, Any]] = None, challenge_digits: Optional[int] = None, security_changes: Optional[Dict[str, Any]] = None, destination: Optional[Dict[str, Any]] = None, locale: Optional[str] = None) -> Dict[str, Any]:
|
|
194
|
+
"""Request a login and wait (poll) until the user approves or rejects it.
|
|
195
|
+
|
|
196
|
+
With number matching (your ``require_number_match``, a workspace policy
|
|
197
|
+
or the risk engine can turn it on) the user must pick a number on the
|
|
198
|
+
phone. ``on_challenge`` is called once, right after the request is
|
|
199
|
+
created and before any waiting, with ``{'requestId', 'challengeCode',
|
|
200
|
+
'expiresAt', 'reasons', 'caseCode'}``: show ``challengeCode`` to the
|
|
201
|
+
user now; the phone asks for it. An exception it raises is logged and
|
|
202
|
+
ignored (verify keeps waiting). It is not called when only a passkey
|
|
203
|
+
can approve::
|
|
204
|
+
|
|
205
|
+
tq.login.verify(user, 'SEND_MONEY',
|
|
206
|
+
on_challenge=lambda c: c.get('challengeCode') and show(f"Pick {c['challengeCode']} on your phone"))
|
|
207
|
+
|
|
208
|
+
Raises ``TouchQuePasskeyRequiredError`` when a passkey must approve it:
|
|
209
|
+
a phishing-resistant policy (``reason='policy'``, no push sent), or the
|
|
210
|
+
user tapped Approve during a call (``reason='step_up'``, Vishing Shield):
|
|
211
|
+
finish it in the browser with ``@touchque/web``."""
|
|
212
|
+
request_result = self.request(external_username, type, reference_id, client_ip, user_agent, require_biometric, require_number_match, details, browser_proof, context, challenge_digits, security_changes=security_changes, destination=destination, locale=locale)
|
|
213
|
+
request_id = request_result['requestId']
|
|
214
|
+
if request_result.get('requiresPasskey'):
|
|
215
|
+
raise TouchQuePasskeyRequiredError(request_id)
|
|
216
|
+
|
|
217
|
+
if on_challenge is not None:
|
|
218
|
+
challenge = {'requestId': request_id, 'expiresAt': request_result.get('expiresAt')}
|
|
219
|
+
for key in ('challengeCode', 'reasons', 'caseCode'):
|
|
220
|
+
if request_result.get(key):
|
|
221
|
+
challenge[key] = request_result[key]
|
|
222
|
+
try:
|
|
223
|
+
on_challenge(challenge)
|
|
224
|
+
except Exception as err: # noqa: BLE001 — the user's callback must not stop the wait
|
|
225
|
+
# err.__class__, not type(err): the parameter `type` shadows the builtin here.
|
|
226
|
+
logger.warning('[TouchQue] on_challenge failed: %s: %s', err.__class__.__name__, err)
|
|
227
|
+
|
|
228
|
+
start_time = time.time() * 1000
|
|
229
|
+
poll_interval_sec = 0.4
|
|
230
|
+
|
|
231
|
+
while True:
|
|
232
|
+
status_result = self.status(request_id)
|
|
233
|
+
status = status_result.get('status')
|
|
234
|
+
|
|
235
|
+
if status == 'CONFIRMED':
|
|
236
|
+
return {
|
|
237
|
+
'approved': True,
|
|
238
|
+
'status': status,
|
|
239
|
+
'requestId': request_id,
|
|
240
|
+
'challengeCode': request_result.get('challengeCode'),
|
|
241
|
+
'assurance': status_result.get('assurance'),
|
|
242
|
+
'confirmedVia': status_result.get('confirmedVia'),
|
|
243
|
+
# Vishing Shield: approved during a call; critical actions wait for the phone to re-confirm.
|
|
244
|
+
'provisional': bool(status_result.get('provisional')),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if status == 'REJECTED':
|
|
248
|
+
raise TouchQueRejectedException(f"Request {request_id} was rejected by the user.")
|
|
249
|
+
|
|
250
|
+
if status == 'EXPIRED':
|
|
251
|
+
raise TouchQueTimeoutException(f"Request {request_id} expired before it was approved.")
|
|
252
|
+
|
|
253
|
+
# Vishing Shield: tapped during a call, the request now waits for a passkey in the browser.
|
|
254
|
+
if status == 'PENDING' and status_result.get('requiresPasskey'):
|
|
255
|
+
raise TouchQuePasskeyRequiredError(request_id, 'step_up')
|
|
256
|
+
|
|
257
|
+
elapsed = (time.time() * 1000) - start_time
|
|
258
|
+
if elapsed > timeout_ms:
|
|
259
|
+
raise TouchQueTimeoutException(f"Login verification timed out after {timeout_ms}ms")
|
|
260
|
+
|
|
261
|
+
time.sleep(poll_interval_sec)
|
|
262
|
+
|
|
263
|
+
def approve_with_recovery_code(self, request_id: str, code: str) -> Dict[str, Any]:
|
|
264
|
+
"""Approve a pending 2FA request using a Recovery Code (bypasses mobile device)."""
|
|
265
|
+
return self._http.post('/login/recovery', {
|
|
266
|
+
'requestId': request_id,
|
|
267
|
+
'code': code
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
def consume(self, request_id: str) -> Dict[str, Any]:
|
|
271
|
+
"""Uses an approved request exactly once. The first call on a
|
|
272
|
+
CONFIRMED request wins atomically; every later call (a replayed
|
|
273
|
+
token, a retried form post) raises ``TouchQueAPIException`` with
|
|
274
|
+
``code='already_used'`` (409)."""
|
|
275
|
+
return self._http.post(f"/login/{quote(request_id, safe='')}/consume", {})
|
|
276
|
+
|
|
277
|
+
FRAUD_REASONS = ('chargeback', 'account_takeover', 'social_engineering', 'other')
|
|
278
|
+
|
|
279
|
+
def report_fraud(self, request_id: str, reason: Optional[str] = None, note: Optional[str] = None) -> Dict[str, Any]:
|
|
280
|
+
"""Report a request you later learned was fraud — a chargeback, a
|
|
281
|
+
confirmed account takeover, a scam the user was talked into. TouchQue's
|
|
282
|
+
risk engine learns from it (it is the strongest signal it gets) and the
|
|
283
|
+
report is kept in your audit log. The request's status does not change.
|
|
284
|
+
Safe to call more than once: a repeat answers ``alreadyReported: True``.
|
|
285
|
+
|
|
286
|
+
:param reason: one of ``Login.FRAUD_REASONS`` (default ``'other'``).
|
|
287
|
+
:param note: free text for your audit log, at most 500 characters.
|
|
288
|
+
"""
|
|
289
|
+
if not request_id:
|
|
290
|
+
raise TouchQueConfigException('report_fraud() needs the request id')
|
|
291
|
+
payload: Dict[str, Any] = {}
|
|
292
|
+
if reason:
|
|
293
|
+
payload['reason'] = reason
|
|
294
|
+
if note:
|
|
295
|
+
payload['note'] = note
|
|
296
|
+
return self._http.post(f"/login/{quote(request_id, safe='')}/report-fraud", payload)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TouchQue Mobile SDK, your backend's side: your own iOS/Android app is the
|
|
3
|
+
approver of your users (white-label); they never install the TouchQue app.
|
|
4
|
+
|
|
5
|
+
1. A user signed in to your app turns approvals on: ``create_enrollment``
|
|
6
|
+
gives a one-time token; your app hands it to the SDK (``TouchQue.enroll``).
|
|
7
|
+
2. Ask for approvals as always (``login.request``, ``start``, guards...):
|
|
8
|
+
they reach your app over the SDK's encrypted connection.
|
|
9
|
+
3. TouchQue holds no push key of your app. For each request it sends you a
|
|
10
|
+
signed MOBILE_PUSH webhook: verify it (``webhook.verify``), then
|
|
11
|
+
``mobile_push_from_webhook(event)`` gives the APNs payload / FCM data to
|
|
12
|
+
send with your own credentials.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Dict, Optional
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
from ..exceptions import TouchQueConfigException
|
|
20
|
+
from ..http_client import HttpClient
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mobile_push_from_webhook(event: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
24
|
+
"""The push to send for a verified MOBILE_PUSH webhook, or None for any other event.
|
|
25
|
+
|
|
26
|
+
Returns ``deviceId``, ``externalUsername``, ``platform``, ``pushToken`` (None: send by your
|
|
27
|
+
own user -> device mapping), ``requestId``, ``expiresAt``, ``kind``, ``alert`` (English,
|
|
28
|
+
translate it), ``apns`` (the APNs payload: alert + the custom key ``touchque``) and
|
|
29
|
+
``fcmData`` (the FCM data map). The data carries no secret, no details and no number.
|
|
30
|
+
"""
|
|
31
|
+
if not isinstance(event, dict) or event.get('event') != 'MOBILE_PUSH':
|
|
32
|
+
return None
|
|
33
|
+
data = event.get('data') or {}
|
|
34
|
+
touchque = data.get('touchque') if isinstance(data.get('touchque'), str) else json.dumps(data.get('touchque') or {})
|
|
35
|
+
alert = event.get('alert') or None
|
|
36
|
+
aps: Dict[str, Any] = {'sound': 'default', 'interruption-level': 'time-sensitive'}
|
|
37
|
+
if alert:
|
|
38
|
+
aps['alert'] = alert
|
|
39
|
+
return {
|
|
40
|
+
'deviceId': event.get('deviceId'),
|
|
41
|
+
'externalUsername': event.get('externalUsername'),
|
|
42
|
+
'platform': 'android' if event.get('platform') == 'android' else 'ios',
|
|
43
|
+
'pushToken': event.get('pushToken') if isinstance(event.get('pushToken'), str) else None,
|
|
44
|
+
'requestId': event.get('requestId'),
|
|
45
|
+
'expiresAt': event.get('expiresAt'),
|
|
46
|
+
'kind': event['kind'] if event.get('kind') in ('notice', 'test') else 'approval', # test: a test push from the dashboard
|
|
47
|
+
'alert': alert,
|
|
48
|
+
'apns': {'aps': aps, 'touchque': touchque},
|
|
49
|
+
'fcmData': {'touchque': touchque},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_mobile_push_expired(push: Dict[str, Any], now: Optional[datetime] = None) -> bool:
|
|
54
|
+
"""True once a push is past its request's expiry (don't send it any more)."""
|
|
55
|
+
expires = push.get('expiresAt') if isinstance(push, dict) else None
|
|
56
|
+
if not expires:
|
|
57
|
+
return False
|
|
58
|
+
at = datetime.fromisoformat(str(expires).replace('Z', '+00:00'))
|
|
59
|
+
return at <= (now or datetime.now(timezone.utc))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Mobile:
|
|
63
|
+
"""Enrollment tokens, the devices inside your app and your Mobile SDK settings."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, http: HttpClient):
|
|
66
|
+
self._http = http
|
|
67
|
+
|
|
68
|
+
def create_enrollment(self, external_username: str, replace: bool = False,
|
|
69
|
+
ttl_seconds: Optional[int] = None) -> Dict[str, Any]:
|
|
70
|
+
"""A one-time enrollment token for a user you signed in. Whoever spends it becomes the
|
|
71
|
+
user's approver, so authenticate the user first. Raises TouchQueAPIException
|
|
72
|
+
``already_enrolled`` (pass ``replace=True``) or ``account_frozen``."""
|
|
73
|
+
if not external_username:
|
|
74
|
+
raise TouchQueConfigException('mobile.create_enrollment() needs the user (external_username)')
|
|
75
|
+
body: Dict[str, Any] = {'externalUsername': external_username}
|
|
76
|
+
if replace:
|
|
77
|
+
body['replace'] = True
|
|
78
|
+
if ttl_seconds is not None:
|
|
79
|
+
body['ttlSeconds'] = ttl_seconds
|
|
80
|
+
return self._http.post('/mobile/enrollments', body)
|
|
81
|
+
|
|
82
|
+
def list_devices(self, external_username: Optional[str] = None, status: Optional[str] = None,
|
|
83
|
+
limit: Optional[int] = None, cursor: Optional[str] = None) -> Dict[str, Any]:
|
|
84
|
+
"""The devices inside your app (default: active ones), newest first: ``{devices, nextCursor}``."""
|
|
85
|
+
params: Dict[str, Any] = {}
|
|
86
|
+
if external_username:
|
|
87
|
+
params['externalUsername'] = external_username
|
|
88
|
+
if status:
|
|
89
|
+
params['status'] = status
|
|
90
|
+
if limit is not None:
|
|
91
|
+
params['limit'] = limit
|
|
92
|
+
if cursor:
|
|
93
|
+
params['cursor'] = cursor
|
|
94
|
+
return self._http.get('/mobile/devices', params)
|
|
95
|
+
|
|
96
|
+
def get_device(self, device_id: str) -> Dict[str, Any]:
|
|
97
|
+
if not device_id:
|
|
98
|
+
raise TouchQueConfigException('mobile.get_device() needs the device id')
|
|
99
|
+
return self._http.get('/mobile/devices/' + quote(device_id, safe=''))['device']
|
|
100
|
+
|
|
101
|
+
def remove_device(self, device_id: str) -> Dict[str, Any]:
|
|
102
|
+
"""Stops a device from approving (the user has no approver until you enrol again)."""
|
|
103
|
+
if not device_id:
|
|
104
|
+
raise TouchQueConfigException('mobile.remove_device() needs the device id')
|
|
105
|
+
return self._http.delete('/mobile/devices/' + quote(device_id, safe=''))
|
|
106
|
+
|
|
107
|
+
def test_push(self, device_id: str) -> Dict[str, Any]:
|
|
108
|
+
"""A harmless push to the device, the way a request would reach it: ``via`` 'touchque' (your key for
|
|
109
|
+
its platform) or 'webhook' (MOBILE_PUSH, kind 'test'); ``ok``: APNs / FCM accepted it."""
|
|
110
|
+
if not device_id:
|
|
111
|
+
raise TouchQueConfigException('mobile.test_push() needs the device id')
|
|
112
|
+
return self._http.post('/mobile/devices/' + quote(device_id, safe='') + '/test-push', {})
|
|
113
|
+
|
|
114
|
+
def settings(self) -> Dict[str, Any]:
|
|
115
|
+
return self._http.get('/mobile/settings')['settings']
|
|
116
|
+
|
|
117
|
+
def update_settings(self, **changes: Any) -> Dict[str, Any]:
|
|
118
|
+
"""Which apps may enrol, whether they must prove themselves, and your push keys: ``ios_app_ids``,
|
|
119
|
+
``android_packages``, ``android_certs`` ({package: [SHA-256 of its signing certificate]}),
|
|
120
|
+
``attestation`` ('off' | 'optional' | 'required'), ``enroll_ttl_seconds``,
|
|
121
|
+
``apns`` ({keyId, teamId, topic, key: the .p8 text, production}; TouchQue then sends the iOS push
|
|
122
|
+
itself) and ``fcm`` ({serviceAccount: the JSON}; the Android push). None removes a key; the keys
|
|
123
|
+
are never shown back (``settings()['push']``)."""
|
|
124
|
+
names = {'ios_app_ids': 'iosAppIds', 'android_packages': 'androidPackages', 'android_certs': 'androidCerts',
|
|
125
|
+
'attestation': 'attestation', 'enroll_ttl_seconds': 'enrollTtlSeconds',
|
|
126
|
+
'apns': 'apns', 'fcm': 'fcm'}
|
|
127
|
+
unknown = set(changes) - set(names)
|
|
128
|
+
if unknown:
|
|
129
|
+
raise TouchQueConfigException('mobile.update_settings(): unknown setting ' + ', '.join(sorted(unknown)))
|
|
130
|
+
return self._http.put('/mobile/settings', {names[k]: v for k, v in changes.items()})['settings']
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..http_client import HttpClient
|
|
8
|
+
from ..exceptions import TouchQueAPIException
|
|
9
|
+
|
|
10
|
+
_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Offline:
|
|
14
|
+
"""Offline Sign — QR challenge / typed code approvals that work with the
|
|
15
|
+
phone offline (no internet on the device)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, http: HttpClient):
|
|
18
|
+
self._http = http
|
|
19
|
+
|
|
20
|
+
def challenge(
|
|
21
|
+
self,
|
|
22
|
+
external_username: str,
|
|
23
|
+
type: str = 'LOGIN',
|
|
24
|
+
details: Optional[Any] = None,
|
|
25
|
+
client_ip: Optional[str] = None,
|
|
26
|
+
user_agent: Optional[str] = None,
|
|
27
|
+
ttl_seconds: Optional[int] = None,
|
|
28
|
+
include_qr_image: bool = True,
|
|
29
|
+
request_id: Optional[str] = None,
|
|
30
|
+
require_number_match: bool = False,
|
|
31
|
+
) -> Dict[str, Any]:
|
|
32
|
+
"""Issues an offline QR challenge. Returns
|
|
33
|
+
``{challengeId, qr, qrDataUrl, expiresAt, expiresInSeconds, totpAvailable}``
|
|
34
|
+
(+ ``challengeCode`` when number matching applies: print it under the QR; the
|
|
35
|
+
phone offers it among two decoys and the user taps the match).
|
|
36
|
+
``details`` is REQUIRED for critical action types.
|
|
37
|
+
|
|
38
|
+
``request_id`` is the push this QR is a fallback for: once the phone REJECTS it the QR
|
|
39
|
+
is dead (no new QR is issued, a code for the old one is refused with ``request_rejected``),
|
|
40
|
+
and a push with number matching makes the QR show the same number. Always pass it
|
|
41
|
+
when the QR follows a push.
|
|
42
|
+
"""
|
|
43
|
+
body: Dict[str, Any] = {'externalUsername': external_username, 'type': type}
|
|
44
|
+
if details is not None:
|
|
45
|
+
body['details'] = details
|
|
46
|
+
if client_ip is not None:
|
|
47
|
+
body['clientIp'] = client_ip
|
|
48
|
+
if user_agent is not None:
|
|
49
|
+
body['userAgent'] = user_agent
|
|
50
|
+
if ttl_seconds is not None:
|
|
51
|
+
body['ttlSeconds'] = ttl_seconds
|
|
52
|
+
if not include_qr_image:
|
|
53
|
+
body['includeQrImage'] = False
|
|
54
|
+
if request_id is not None:
|
|
55
|
+
body['requestId'] = request_id
|
|
56
|
+
if require_number_match:
|
|
57
|
+
body['requireNumberMatch'] = True
|
|
58
|
+
return self._http.post('/offline/challenge', body)
|
|
59
|
+
|
|
60
|
+
def verify(self, challenge_id: str, code: str) -> Dict[str, Any]:
|
|
61
|
+
"""Verifies the 7-character code shown on the phone. Never raises for
|
|
62
|
+
a wrong/expired/used code — check ``approved``; ``reason`` is one of
|
|
63
|
+
``invalid_code | locked | expired | used | unknown_challenge |
|
|
64
|
+
request_rejected | too_many_failures | device_not_enrolled``.
|
|
65
|
+
"""
|
|
66
|
+
return self._not_approved_as_result(
|
|
67
|
+
lambda: self._http.post('/offline/verify', {'challengeId': challenge_id, 'code': code})
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def verify_totp(self, external_username: str, code: str, type: str = 'LOGIN',
|
|
71
|
+
client_ip: Optional[str] = None, request_id: Optional[str] = None) -> Dict[str, Any]:
|
|
72
|
+
"""Verifies the rolling time-based code (no QR scan needed). Refused
|
|
73
|
+
for critical action types. ``request_id``: the push this sign-in belongs
|
|
74
|
+
to — a code is refused (``request_rejected``) once the phone rejected it."""
|
|
75
|
+
body: Dict[str, Any] = {'externalUsername': external_username, 'code': code, 'type': type}
|
|
76
|
+
if client_ip is not None:
|
|
77
|
+
body['clientIp'] = client_ip
|
|
78
|
+
if request_id is not None:
|
|
79
|
+
body['requestId'] = request_id
|
|
80
|
+
return self._not_approved_as_result(lambda: self._http.post('/offline/totp/verify', body))
|
|
81
|
+
|
|
82
|
+
def totp_verifiers(self, external_usernames: List[str], from_step: Optional[int] = None,
|
|
83
|
+
steps: Optional[int] = None) -> Dict[str, Any]:
|
|
84
|
+
"""For checking typed time-based codes while your server cannot reach
|
|
85
|
+
TouchQue (an outage): salted, slow digests of each user's next codes,
|
|
86
|
+
never a key. Up to 1,000 users and 24,000 user-steps a call; ``steps`` 1-480 (default 480, four hours)
|
|
87
|
+
from ``from_step`` (default: the current 30-second step). Returns
|
|
88
|
+
``{stepSeconds, fromStep, steps, notAfterStep, kdf, users: [{externalUsername,
|
|
89
|
+
fromStep, salt, digests}]}`` with only the users who can use time-based codes
|
|
90
|
+
now. Check a typed code with :meth:`match_totp_verifier`. Requires the
|
|
91
|
+
workspace policies ``offlineTotpEnabled`` and ``outageVerifiersEnabled``.
|
|
92
|
+
Fetch again every few minutes: removed and frozen users fall out."""
|
|
93
|
+
body: Dict[str, Any] = {'externalUsernames': list(external_usernames)}
|
|
94
|
+
if from_step is not None:
|
|
95
|
+
body['fromStep'] = from_step
|
|
96
|
+
if steps is not None:
|
|
97
|
+
body['steps'] = steps
|
|
98
|
+
return self._http.post('/offline/totp/verifiers', body)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def match_totp_verifier(verifiers: Dict[str, Any], user: Dict[str, Any], code: str,
|
|
102
|
+
at: Optional[float] = None) -> Optional[int]:
|
|
103
|
+
"""Checks a typed code against one user's digests from
|
|
104
|
+
:meth:`totp_verifiers` without calling TouchQue. Returns the step the
|
|
105
|
+
code belongs to (the current one or one either side), or ``None``.
|
|
106
|
+
Accept each step once: remember the step you accepted for the user and
|
|
107
|
+
refuse codes for it or an older one. ``at``: Unix time (default now)."""
|
|
108
|
+
given = ''.join(code.upper().split()).replace('-', '') if isinstance(code, str) else ''
|
|
109
|
+
if len(given) != 7 or any(c not in _CODE_ALPHABET for c in given):
|
|
110
|
+
return None
|
|
111
|
+
kdf = verifiers['kdf']
|
|
112
|
+
length = kdf['length']
|
|
113
|
+
now = int((time.time() if at is None else at) // verifiers['stepSeconds'])
|
|
114
|
+
salt = base64.b64decode(user['salt'])
|
|
115
|
+
digests = base64.b64decode(user['digests'])
|
|
116
|
+
for step in (now, now - 1, now + 1):
|
|
117
|
+
if step < user['fromStep'] or step > verifiers['notAfterStep']:
|
|
118
|
+
continue
|
|
119
|
+
i = (step - user['fromStep']) * 2 * length
|
|
120
|
+
if i + 2 * length > len(digests):
|
|
121
|
+
continue
|
|
122
|
+
got = hashlib.pbkdf2_hmac('sha256', given.encode(), salt + f':{step}'.encode(), kdf['iterations'], length)
|
|
123
|
+
if hmac.compare_digest(digests[i:i + length], got) or hmac.compare_digest(digests[i + length:i + 2 * length], got):
|
|
124
|
+
return step
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _not_approved_as_result(call) -> Dict[str, Any]:
|
|
129
|
+
try:
|
|
130
|
+
return call()
|
|
131
|
+
except TouchQueAPIException as err:
|
|
132
|
+
if err.status is not None and err.status < 500:
|
|
133
|
+
return {
|
|
134
|
+
'approved': False,
|
|
135
|
+
'reason': err.reason or err.code or (err.data or {}).get('error'),
|
|
136
|
+
'attemptsLeft': err.attempts_left,
|
|
137
|
+
}
|
|
138
|
+
raise
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Pairing a browser by QR code: your page shows a QR code, the person scans it
|
|
2
|
+
with the TouchQue app, and the phone that scanned is the one asked to approve.
|
|
3
|
+
|
|
4
|
+
1. Your page gets the thumbprint of its browser key (``@touchque/web``
|
|
5
|
+
``browserKey.thumbprint()``) and sends it to your server.
|
|
6
|
+
2. ``create(key_thumbprint, client_ip=..., user_agent=...)`` registers a
|
|
7
|
+
pairing and returns ``uri`` (``touchque://pair-browser?...``): show it as a
|
|
8
|
+
QR code. It can be scanned once, for ``ttl_seconds`` (default 120). The
|
|
9
|
+
nonce in it never leaves your server and the QR: TouchQue keeps only its hash.
|
|
10
|
+
3. Poll ``get(pairing_id)``: ``scanned`` once a phone holding one of your users'
|
|
11
|
+
accounts read it (``externalUsername``, ``proximity``), ``confirmed`` once
|
|
12
|
+
the person went on, ``reported`` when they said someone told them to scan
|
|
13
|
+
it (their 2FA freezes).
|
|
14
|
+
4. ``login.request(external_username, qr_pairing=pairing_id)`` asks that phone,
|
|
15
|
+
once, with number matching; the browser key's fingerprint is one of the rows
|
|
16
|
+
the phone signs. When your own checks say no, ``refuse(pairing_id, reason,
|
|
17
|
+
message)`` and the phone shows why.
|
|
18
|
+
"""
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import os as _os # create() takes an `os` argument, which would hide the module there
|
|
22
|
+
import re
|
|
23
|
+
from typing import Any, Dict, Optional
|
|
24
|
+
from urllib.parse import quote
|
|
25
|
+
|
|
26
|
+
from ..exceptions import TouchQueConfigException
|
|
27
|
+
from ..http_client import HttpClient
|
|
28
|
+
|
|
29
|
+
#: What ``refuse`` may tell the phone.
|
|
30
|
+
QR_PAIRING_REFUSAL_REASONS = ('policy', 'device_not_approved', 'too_many_browsers', 'rate_limited', 'no_account', 'other')
|
|
31
|
+
|
|
32
|
+
_B64URL_43 = re.compile(r'^[A-Za-z0-9_-]{43}$')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _b64url(raw: bytes) -> str:
|
|
36
|
+
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def qr_pairing_uri(pairing_id: str, nonce: str, key_thumbprint: str) -> str:
|
|
40
|
+
"""What the QR code says: ``touchque://pair-browser?v=1&p=<pairing>&n=<nonce>&k=<key thumbprint>``."""
|
|
41
|
+
q = lambda v: quote(v, safe='') # noqa: E731
|
|
42
|
+
return f'touchque://pair-browser?v=1&p={q(pairing_id)}&n={q(nonce)}&k={q(key_thumbprint)}'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _path(pairing_id: str, method: str, rest: str = '') -> str:
|
|
46
|
+
if not isinstance(pairing_id, str) or not pairing_id:
|
|
47
|
+
raise TouchQueConfigException(f'qr_pairings.{method}() needs the pairing_id.')
|
|
48
|
+
return f'/qr-pairings/{quote(pairing_id, safe="")}{rest}'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class QrPairings:
|
|
52
|
+
"""Browser pairings by QR code (``POST /qr-pairings``)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, http: HttpClient):
|
|
55
|
+
self._http = http
|
|
56
|
+
|
|
57
|
+
def create(self, key_thumbprint: str, client_ip: Optional[str] = None, user_agent: Optional[str] = None,
|
|
58
|
+
browser: Optional[str] = None, os_name: Optional[str] = None, ttl_seconds: Optional[int] = None,
|
|
59
|
+
os: Optional[str] = None) -> Dict[str, Any]:
|
|
60
|
+
"""Registers a pairing for one browser: ``{pairingId, expiresAt, nonce, uri}``; show ``uri`` as a QR code.
|
|
61
|
+
``os=`` is the same as ``os_name=`` (the name the other SDKs use)."""
|
|
62
|
+
if os_name is None:
|
|
63
|
+
os_name = os
|
|
64
|
+
if not isinstance(key_thumbprint, str) or not _B64URL_43.match(key_thumbprint):
|
|
65
|
+
raise TouchQueConfigException('qr_pairings.create() needs key_thumbprint: the RFC 7638 thumbprint of the browser key, base64url (43 characters).')
|
|
66
|
+
if ttl_seconds is not None and (not isinstance(ttl_seconds, int) or isinstance(ttl_seconds, bool) or not 30 <= ttl_seconds <= 300):
|
|
67
|
+
raise TouchQueConfigException('qr_pairings.create(): ttl_seconds must be 30 to 300.')
|
|
68
|
+
nonce = _b64url(_os.urandom(32))
|
|
69
|
+
body: Dict[str, Any] = {'nonceHash': _b64url(hashlib.sha256(nonce.encode('utf-8')).digest()), 'keyThumbprint': key_thumbprint}
|
|
70
|
+
for key, value in (('clientIp', client_ip), ('userAgent', user_agent), ('browser', browser), ('os', os_name), ('ttlSeconds', ttl_seconds)):
|
|
71
|
+
if value is not None and value != '':
|
|
72
|
+
body[key] = value
|
|
73
|
+
created = self._http.post('/qr-pairings', body)
|
|
74
|
+
return {**created, 'nonce': nonce, 'uri': qr_pairing_uri(created['pairingId'], nonce, key_thumbprint)}
|
|
75
|
+
|
|
76
|
+
def get(self, pairing_id: str) -> Dict[str, Any]:
|
|
77
|
+
"""Where a pairing stands; once scanned, who scanned it and how near the phone was."""
|
|
78
|
+
return self._http.get(_path(pairing_id, 'get'))
|
|
79
|
+
|
|
80
|
+
def refuse(self, pairing_id: str, reason: str, message: str, case_code: Optional[str] = None,
|
|
81
|
+
device_code: Optional[str] = None) -> Dict[str, Any]:
|
|
82
|
+
"""Your own checks refused a scanned pairing; the phone shows ``message``.
|
|
83
|
+
Only a ``scanned`` or ``confirmed`` pairing can be refused (409 ``not_open``)."""
|
|
84
|
+
if reason not in QR_PAIRING_REFUSAL_REASONS:
|
|
85
|
+
raise TouchQueConfigException(f'qr_pairings.refuse(): reason must be one of {", ".join(QR_PAIRING_REFUSAL_REASONS)}.')
|
|
86
|
+
if not isinstance(message, str) or not message.strip():
|
|
87
|
+
raise TouchQueConfigException('qr_pairings.refuse() needs message: what the phone shows (up to 300 characters).')
|
|
88
|
+
body: Dict[str, Any] = {'reason': reason, 'message': message}
|
|
89
|
+
if case_code:
|
|
90
|
+
body['caseCode'] = case_code
|
|
91
|
+
if device_code:
|
|
92
|
+
body['deviceCode'] = device_code
|
|
93
|
+
return self._http.post(_path(pairing_id, 'refuse', '/outcome'), body)
|