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/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from urllib.parse import urlparse
|
|
3
|
+
|
|
4
|
+
from .exceptions import TouchQueConfigException
|
|
5
|
+
|
|
6
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
7
|
+
DEFAULT_BASE_URL = "https://api.touchque.com"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _assert_safe_base_url(base_url: str) -> None:
|
|
11
|
+
"""Allow plaintext http only for localhost / loopback (local development)."""
|
|
12
|
+
parsed = urlparse(base_url)
|
|
13
|
+
if parsed.scheme == "https":
|
|
14
|
+
return
|
|
15
|
+
if parsed.scheme == "http" and (parsed.hostname or "").lower() in _LOCAL_HOSTS:
|
|
16
|
+
return
|
|
17
|
+
if parsed.scheme == "http":
|
|
18
|
+
raise TouchQueConfigException(
|
|
19
|
+
f'Refusing a plaintext http:// base_url for "{parsed.hostname}". '
|
|
20
|
+
"The API key and request signature would be sent in the clear — use https://."
|
|
21
|
+
)
|
|
22
|
+
raise TouchQueConfigException(f"base_url must be http(s): {base_url}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Config:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: str = None,
|
|
29
|
+
api_secret: str = None,
|
|
30
|
+
base_url: str = None,
|
|
31
|
+
timeout: int = 10000,
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Falls back to the environment when an argument is omitted:
|
|
35
|
+
``TQ_API_KEY``, ``TQ_API_SECRET``, ``TQ_API_URL`` (default
|
|
36
|
+
``https://api.touchque.com``).
|
|
37
|
+
"""
|
|
38
|
+
api_key = api_key if api_key is not None else os.environ.get("TQ_API_KEY")
|
|
39
|
+
api_secret = api_secret if api_secret is not None else os.environ.get("TQ_API_SECRET")
|
|
40
|
+
base_url = base_url if base_url is not None else os.environ.get("TQ_API_URL", DEFAULT_BASE_URL)
|
|
41
|
+
|
|
42
|
+
if not api_key or not api_secret:
|
|
43
|
+
raise TouchQueConfigException(
|
|
44
|
+
"Set TQ_API_KEY and TQ_API_SECRET (or pass api_key=/api_secret=)."
|
|
45
|
+
)
|
|
46
|
+
if not api_key.startswith('tq_'):
|
|
47
|
+
raise TouchQueConfigException(
|
|
48
|
+
'api_key must start with "tq_". Did you accidentally swap api_key and api_secret?'
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_assert_safe_base_url(base_url)
|
|
52
|
+
|
|
53
|
+
self.api_key = api_key
|
|
54
|
+
self.api_secret = api_secret
|
|
55
|
+
self.base_url = base_url.rstrip('/')
|
|
56
|
+
self.timeout = timeout
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
"""Framework adapters. Each is a thin wrapper over ``touchque.guard.run_guard``
|
|
2
|
+
(step-up decorator) and ``touchque.relay.RelayHandler`` (relay routes)
|
|
3
|
+
— import only the one you use, so the others' framework isn't a hard
|
|
4
|
+
dependency (``touchque.contrib.flask`` needs Flask installed, etc.)."""
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django: one line per protected view.
|
|
3
|
+
|
|
4
|
+
from touchque.contrib.django import require_touchque
|
|
5
|
+
|
|
6
|
+
@require_touchque('SEND_MONEY', details=lambda req: {'Amount': f'{req.POST["amount"]} EUR'})
|
|
7
|
+
def transfer(request):
|
|
8
|
+
return JsonResponse({'ok': True, 'touchque': request.touchque})
|
|
9
|
+
|
|
10
|
+
Until the user approves on their phone this answers
|
|
11
|
+
202 {"touchque": step, "token": ...}. Your page shows the step in its own
|
|
12
|
+
design and sends the same request again with header
|
|
13
|
+
``X-TouchQue-Token: <token>``. Once approved the view runs ONCE, with
|
|
14
|
+
``request.touchque`` set to the approval.
|
|
15
|
+
|
|
16
|
+
Relay routes (POST /login, enrollment, offline codes, passkey ceremonies)
|
|
17
|
+
under one URL prefix - see ``touchque.relay.RelayHandler`` for every option:
|
|
18
|
+
|
|
19
|
+
# urls.py
|
|
20
|
+
from touchque.contrib.django import touchque_relay_urls
|
|
21
|
+
|
|
22
|
+
def opened(request, response, info):
|
|
23
|
+
request.session['user'] = info['externalUsername']
|
|
24
|
+
|
|
25
|
+
urlpatterns = [
|
|
26
|
+
path('touchque/', include(touchque_relay_urls(
|
|
27
|
+
get_user_id=lambda req: req.session.get('user'),
|
|
28
|
+
get_login_user=lambda req: req.session.get('password_ok'),
|
|
29
|
+
on_authenticated=opened,
|
|
30
|
+
))),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
Django's CSRF protection stays on for these POST/DELETE routes: send the
|
|
34
|
+
token from the page (``@touchque/web``: ``headers: {'X-CSRFToken': ...}``),
|
|
35
|
+
or pass ``csrf_exempt=True`` if you protect them another way.
|
|
36
|
+
"""
|
|
37
|
+
import functools
|
|
38
|
+
import json
|
|
39
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
40
|
+
|
|
41
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
42
|
+
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
|
|
43
|
+
from django.urls import re_path
|
|
44
|
+
from django.views.decorators.csrf import csrf_exempt as _csrf_exempt
|
|
45
|
+
|
|
46
|
+
from ..client import TouchQue, get_default_client
|
|
47
|
+
from ..guard import SilentSignIn, guard_input_from_headers, run_guard, silent_input_from_headers
|
|
48
|
+
from ..relay import RelayHandler
|
|
49
|
+
from ..steps import LoginDetails
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _default_user(request: HttpRequest) -> Optional[str]:
|
|
53
|
+
u = getattr(request, 'user', None)
|
|
54
|
+
if u is None or not getattr(u, 'is_authenticated', True):
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(u, str):
|
|
57
|
+
return u
|
|
58
|
+
email = getattr(u, 'email', None)
|
|
59
|
+
return email or getattr(u, 'username', None) or str(getattr(u, 'pk', '') or '') or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require_touchque(
|
|
63
|
+
action: str,
|
|
64
|
+
client: Optional[TouchQue] = None,
|
|
65
|
+
user: Optional[Callable[[HttpRequest], Optional[str]]] = None,
|
|
66
|
+
details: Optional[Callable[[HttpRequest], Optional[LoginDetails]]] = None,
|
|
67
|
+
reference_id: Optional[Callable[[HttpRequest], Optional[str]]] = None,
|
|
68
|
+
silent_sign_in: SilentSignIn = False,
|
|
69
|
+
context: Optional[Callable[[HttpRequest], Optional[Dict[str, Any]]]] = None,
|
|
70
|
+
) -> Callable:
|
|
71
|
+
"""
|
|
72
|
+
``user``: who is approving, given the request. Default:
|
|
73
|
+
``request.user.email`` (Django auth). For the login step, return the user
|
|
74
|
+
who just passed your password check, e.g.
|
|
75
|
+
``user=lambda req: req.session.get('password_ok')``.
|
|
76
|
+
|
|
77
|
+
``silent_sign_in`` (default off): when on, a new attempt whose browser
|
|
78
|
+
sent its device-bound key proof (``X-TouchQue-Browser``, added by
|
|
79
|
+
``@touchque/web`` with ``browserKey: true``) first asks TouchQue whether
|
|
80
|
+
anything about it is new. If nothing is, your view runs at once with no
|
|
81
|
+
push (``request.touchque['assurance']['method'] == 'silent'``,
|
|
82
|
+
``request.touchque['reasons']``). Otherwise, and on any error, the usual push
|
|
83
|
+
starts and its approval makes the browser known.
|
|
84
|
+
``{'max_age_seconds': n}`` sets your own limit on how long ago the last
|
|
85
|
+
phone approval in that browser may be.
|
|
86
|
+
|
|
87
|
+
``context``: for your TouchQue Actions only (``event.context``), e.g. the
|
|
88
|
+
user's roles. Never shown on the phone.
|
|
89
|
+
"""
|
|
90
|
+
user_of = user or _default_user
|
|
91
|
+
tq = client
|
|
92
|
+
|
|
93
|
+
def decorator(view: Callable) -> Callable:
|
|
94
|
+
@functools.wraps(view)
|
|
95
|
+
def wrapped(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
|
|
96
|
+
c = tq or get_default_client()
|
|
97
|
+
uid = user_of(request)
|
|
98
|
+
result = run_guard(
|
|
99
|
+
c, uid, action,
|
|
100
|
+
details=details(request) if details else None,
|
|
101
|
+
reference_id=reference_id(request) if reference_id else None,
|
|
102
|
+
context=context(request) if context else None,
|
|
103
|
+
ip=request.META.get('REMOTE_ADDR'), user_agent=request.META.get('HTTP_USER_AGENT'),
|
|
104
|
+
**guard_input_from_headers(lambda name: request.headers.get(name)),
|
|
105
|
+
**silent_input_from_headers(silent_sign_in, lambda name: request.headers.get(name)),
|
|
106
|
+
)
|
|
107
|
+
if 'approved' in result:
|
|
108
|
+
request.touchque = result['approved']
|
|
109
|
+
return view(request, *args, **kwargs)
|
|
110
|
+
resp = JsonResponse(result['body'], status=result['status'])
|
|
111
|
+
resp['Cache-Control'] = 'no-store'
|
|
112
|
+
retry = result['body']['touchque'].get('retryAfter')
|
|
113
|
+
if retry:
|
|
114
|
+
resp['Retry-After'] = str(retry)
|
|
115
|
+
return resp
|
|
116
|
+
return wrapped
|
|
117
|
+
return decorator
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _json_body(request: HttpRequest) -> Any:
|
|
121
|
+
if request.content_type != 'application/json' or not request.body:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
return json.loads(request.body)
|
|
125
|
+
except ValueError:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def touchque_relay_view(
|
|
130
|
+
client: Optional[TouchQue] = None,
|
|
131
|
+
csrf_exempt: bool = False,
|
|
132
|
+
handler: Optional[RelayHandler] = None,
|
|
133
|
+
**options: Any
|
|
134
|
+
) -> Callable:
|
|
135
|
+
"""One view for every relay route; the sub-path comes from the ``sub``
|
|
136
|
+
URL kwarg (see ``touchque_relay_urls``). ``options`` are
|
|
137
|
+
``RelayHandler``'s (``get_user_id`` is required); or pass a ready ``handler``.
|
|
138
|
+
|
|
139
|
+
Callbacks get the ``HttpRequest``; ``on_authenticated(request, response,
|
|
140
|
+
info)`` gets the ``HttpResponse`` that will be sent, so it may call
|
|
141
|
+
``response.set_cookie(...)`` or write ``request.session``.
|
|
142
|
+
``REMOTE_ADDR`` is the client IP the rate limiter uses: behind a proxy,
|
|
143
|
+
make sure it holds the real client address.
|
|
144
|
+
"""
|
|
145
|
+
relay = handler or RelayHandler(client, **options)
|
|
146
|
+
|
|
147
|
+
def view(request: HttpRequest, sub: str = '') -> HttpResponse:
|
|
148
|
+
response = HttpResponse(content_type='application/json')
|
|
149
|
+
result = relay.handle(
|
|
150
|
+
request.method or '', '/' + sub, body=_json_body(request),
|
|
151
|
+
ip=request.META.get('REMOTE_ADDR'), user_agent=request.META.get('HTTP_USER_AGENT'),
|
|
152
|
+
get_header=lambda name: request.headers.get(name),
|
|
153
|
+
request=request, response=response,
|
|
154
|
+
)
|
|
155
|
+
if result is None:
|
|
156
|
+
raise Http404()
|
|
157
|
+
response.status_code = result.status
|
|
158
|
+
response.content = json.dumps(result.body, cls=DjangoJSONEncoder).encode('utf-8')
|
|
159
|
+
for key, value in result.headers.items():
|
|
160
|
+
response[key] = value
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
return _csrf_exempt(view) if csrf_exempt else view
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def touchque_relay_urls(
|
|
167
|
+
client: Optional[TouchQue] = None,
|
|
168
|
+
csrf_exempt: bool = False,
|
|
169
|
+
handler: Optional[RelayHandler] = None,
|
|
170
|
+
**options: Any
|
|
171
|
+
) -> List[Any]:
|
|
172
|
+
"""URL patterns for the relay, to mount with
|
|
173
|
+
``path('touchque/', include(touchque_relay_urls(...)))``."""
|
|
174
|
+
view = touchque_relay_view(client, csrf_exempt=csrf_exempt, handler=handler, **options)
|
|
175
|
+
return [re_path(r'^(?P<sub>.*)$', view, name='touchque_relay')]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI: one line per protected route, as a dependency.
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends, Request
|
|
5
|
+
from touchque.contrib.fastapi import RequireTouchQue
|
|
6
|
+
|
|
7
|
+
@app.post('/transfer')
|
|
8
|
+
async def transfer(request: Request, touchque=Depends(
|
|
9
|
+
RequireTouchQue('SEND_MONEY', details=lambda req: {'Amount': f'{req.state.amount} EUR'})
|
|
10
|
+
)):
|
|
11
|
+
return {'ok': True, 'touchque': touchque}
|
|
12
|
+
|
|
13
|
+
Until the user approves on their phone the dependency raises
|
|
14
|
+
``HTTPException(202, detail={"touchque": step, "token": ...})``. Your page
|
|
15
|
+
shows the step in its own design and sends the same request again with
|
|
16
|
+
header ``X-TouchQue-Token: <token>``. Once approved the dependency resolves
|
|
17
|
+
to the approval (once) and your route runs.
|
|
18
|
+
|
|
19
|
+
FastAPI's default exception handler puts that JSON under a top-level
|
|
20
|
+
``"detail"`` key (``{"detail": {"touchque": ..., "token": ...}}``) — unlike
|
|
21
|
+
the Django/Flask adapters, which return it at the top level. Call
|
|
22
|
+
``install_touchque_exception_handler(app)`` once at startup to flatten it and
|
|
23
|
+
match the other adapters exactly.
|
|
24
|
+
|
|
25
|
+
``run_guard`` makes a blocking HTTP call (the ``requests`` library); it runs
|
|
26
|
+
in FastAPI's threadpool via ``anyio.to_thread`` so it never blocks the event
|
|
27
|
+
loop.
|
|
28
|
+
|
|
29
|
+
Relay routes (POST /login, enrollment, offline codes, passkey ceremonies)
|
|
30
|
+
as one APIRouter - see ``touchque.relay.RelayHandler`` for every option.
|
|
31
|
+
Callbacks may be sync or async:
|
|
32
|
+
|
|
33
|
+
from touchque.contrib.fastapi import touchque_relay_router
|
|
34
|
+
|
|
35
|
+
async def opened(request, response, info):
|
|
36
|
+
response.set_cookie('session', await create_session(info['externalUsername']))
|
|
37
|
+
|
|
38
|
+
app.include_router(touchque_relay_router(
|
|
39
|
+
tq,
|
|
40
|
+
get_user_id=lambda req: req.session.get('user'),
|
|
41
|
+
get_login_user=lambda req: req.session.get('password_ok'),
|
|
42
|
+
on_authenticated=opened,
|
|
43
|
+
), prefix='/touchque')
|
|
44
|
+
"""
|
|
45
|
+
import json
|
|
46
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
47
|
+
|
|
48
|
+
import anyio
|
|
49
|
+
import anyio.from_thread
|
|
50
|
+
import anyio.to_thread
|
|
51
|
+
import fastapi
|
|
52
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
53
|
+
from fastapi.responses import JSONResponse, Response
|
|
54
|
+
|
|
55
|
+
from ..client import TouchQue, get_default_client
|
|
56
|
+
from ..guard import SilentSignIn, guard_input_from_headers, run_guard, silent_input_from_headers
|
|
57
|
+
from ..relay import RELAY_ROUTES, RelayHandler
|
|
58
|
+
from ..steps import LoginDetails
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _default_user(request: Request) -> Optional[str]:
|
|
62
|
+
u = getattr(request.state, 'user', None)
|
|
63
|
+
if u is None:
|
|
64
|
+
return None
|
|
65
|
+
if isinstance(u, str):
|
|
66
|
+
return u
|
|
67
|
+
return getattr(u, 'email', None) or getattr(u, 'id', None) or getattr(u, 'username', None)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def RequireTouchQue( # noqa: N802 — reads as a dependency factory, PascalCase is idiomatic here
|
|
71
|
+
action: str,
|
|
72
|
+
client: Optional[TouchQue] = None,
|
|
73
|
+
user: Optional[Callable[[Request], Any]] = None,
|
|
74
|
+
details: Optional[Callable[[Request], Optional[LoginDetails]]] = None,
|
|
75
|
+
reference_id: Optional[Callable[[Request], Any]] = None,
|
|
76
|
+
silent_sign_in: SilentSignIn = False,
|
|
77
|
+
context: Optional[Callable[[Request], Any]] = None,
|
|
78
|
+
) -> Callable[[Request], Any]:
|
|
79
|
+
"""
|
|
80
|
+
``user``: async or sync callable resolving who is approving from the
|
|
81
|
+
request. Default: ``request.state.user``. For the login step, return the
|
|
82
|
+
user who just passed your password check.
|
|
83
|
+
|
|
84
|
+
``silent_sign_in`` (default off): when on, a new attempt whose browser
|
|
85
|
+
sent its device-bound key proof (``X-TouchQue-Browser``, added by
|
|
86
|
+
``@touchque/web`` with ``browserKey: true``) first asks TouchQue whether
|
|
87
|
+
anything about it is new. If nothing is, the dependency resolves at once
|
|
88
|
+
with no push (``approval['assurance']['method'] == 'silent'``,
|
|
89
|
+
``approval['reasons']``). Otherwise, and on any error, the usual push
|
|
90
|
+
starts and its approval makes the browser known.
|
|
91
|
+
``{'max_age_seconds': n}`` sets your own limit on how long ago the last
|
|
92
|
+
phone approval in that browser may be.
|
|
93
|
+
|
|
94
|
+
``context``: for your TouchQue Actions only (``event.context``), e.g. the
|
|
95
|
+
user's roles. Never shown on the phone.
|
|
96
|
+
"""
|
|
97
|
+
user_of = user or _default_user
|
|
98
|
+
tq = client
|
|
99
|
+
|
|
100
|
+
async def dependency(request: Request) -> Any:
|
|
101
|
+
c = tq or get_default_client()
|
|
102
|
+
uid = user_of(request)
|
|
103
|
+
if hasattr(uid, '__await__'):
|
|
104
|
+
uid = await uid
|
|
105
|
+
det = details(request) if details else None
|
|
106
|
+
if hasattr(det, '__await__'):
|
|
107
|
+
det = await det
|
|
108
|
+
ref = reference_id(request) if reference_id else None
|
|
109
|
+
if hasattr(ref, '__await__'):
|
|
110
|
+
ref = await ref
|
|
111
|
+
ctx = context(request) if context else None
|
|
112
|
+
if hasattr(ctx, '__await__'):
|
|
113
|
+
ctx = await ctx
|
|
114
|
+
|
|
115
|
+
result = await anyio.to_thread.run_sync(lambda: run_guard(
|
|
116
|
+
c, uid, action, details=det, reference_id=ref, context=ctx,
|
|
117
|
+
ip=request.client.host if request.client else None,
|
|
118
|
+
user_agent=request.headers.get('user-agent'),
|
|
119
|
+
**guard_input_from_headers(lambda name: request.headers.get(name)),
|
|
120
|
+
**silent_input_from_headers(silent_sign_in, lambda name: request.headers.get(name)),
|
|
121
|
+
))
|
|
122
|
+
if 'approved' in result:
|
|
123
|
+
return result['approved']
|
|
124
|
+
headers = {'Cache-Control': 'no-store'}
|
|
125
|
+
retry = result['body']['touchque'].get('retryAfter')
|
|
126
|
+
if retry:
|
|
127
|
+
headers['Retry-After'] = str(retry)
|
|
128
|
+
raise HTTPException(status_code=result['status'], detail=result['body'], headers=headers)
|
|
129
|
+
|
|
130
|
+
return dependency
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def install_touchque_exception_handler(app: fastapi.FastAPI) -> None:
|
|
134
|
+
"""Optional: makes a raised TouchQue step return the SAME JSON shape as
|
|
135
|
+
the Django/Flask adapters (no ``{"detail": ...}`` wrapper). Every other
|
|
136
|
+
``HTTPException`` goes to FastAPI's own handler, unchanged (no body on a
|
|
137
|
+
204 or 304)."""
|
|
138
|
+
from fastapi import Request as _Request
|
|
139
|
+
from fastapi.exception_handlers import http_exception_handler
|
|
140
|
+
from fastapi.responses import JSONResponse
|
|
141
|
+
|
|
142
|
+
@app.exception_handler(HTTPException)
|
|
143
|
+
async def _touchque_exception_handler(request: _Request, exc: HTTPException): # type: ignore[no-redef]
|
|
144
|
+
if isinstance(exc.detail, dict) and 'touchque' in exc.detail:
|
|
145
|
+
return JSONResponse(exc.detail, status_code=exc.status_code, headers=exc.headers)
|
|
146
|
+
return await http_exception_handler(request, exc)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _await(awaitable: Awaitable[Any]) -> Any:
|
|
150
|
+
return await awaitable
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _run_async(awaitable: Awaitable[Any]) -> Any:
|
|
154
|
+
"""Resolves an async callback's result from the relay's worker thread."""
|
|
155
|
+
return anyio.from_thread.run(_await, awaitable)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def touchque_relay_router(
|
|
159
|
+
client: Optional[TouchQue] = None,
|
|
160
|
+
prefix: str = '',
|
|
161
|
+
handler: Optional[RelayHandler] = None,
|
|
162
|
+
**options: Any
|
|
163
|
+
) -> APIRouter:
|
|
164
|
+
"""An APIRouter with every relay route. ``options`` are ``RelayHandler``'s
|
|
165
|
+
(``get_user_id`` is required); or pass a ready ``handler``. Callbacks may
|
|
166
|
+
be sync or async.
|
|
167
|
+
|
|
168
|
+
Callbacks get the Starlette ``Request``; ``on_authenticated(request,
|
|
169
|
+
response, info)`` gets a ``fastapi.Response`` whose headers (e.g.
|
|
170
|
+
``response.set_cookie(...)``) are copied onto the JSON response - the same
|
|
171
|
+
pattern as a FastAPI ``response: Response`` parameter - or it may write
|
|
172
|
+
``request.session``. ``request.client.host`` is the client IP the rate
|
|
173
|
+
limiter uses: behind a proxy, run uvicorn with ``--proxy-headers``.
|
|
174
|
+
The relay (blocking HTTP calls) runs in the threadpool.
|
|
175
|
+
"""
|
|
176
|
+
relay = handler or RelayHandler(client, **options)
|
|
177
|
+
router = APIRouter(prefix=prefix)
|
|
178
|
+
|
|
179
|
+
def make_endpoint(method: str, sub: str) -> Callable[[Request], Awaitable[Response]]:
|
|
180
|
+
async def endpoint(request: Request) -> Response:
|
|
181
|
+
raw = await request.body()
|
|
182
|
+
body = None
|
|
183
|
+
if raw and request.headers.get('content-type', '').split(';')[0].strip() == 'application/json':
|
|
184
|
+
try:
|
|
185
|
+
body = json.loads(raw)
|
|
186
|
+
except ValueError:
|
|
187
|
+
body = None
|
|
188
|
+
native = Response()
|
|
189
|
+
path = sub.replace('<id>', request.path_params.get('cred_id', ''))
|
|
190
|
+
result = await anyio.to_thread.run_sync(lambda: relay.handle(
|
|
191
|
+
method, path, body=body,
|
|
192
|
+
ip=request.client.host if request.client else None,
|
|
193
|
+
user_agent=request.headers.get('user-agent'),
|
|
194
|
+
get_header=lambda name: request.headers.get(name),
|
|
195
|
+
request=request, response=native, run_async=_run_async,
|
|
196
|
+
))
|
|
197
|
+
if result is None:
|
|
198
|
+
raise HTTPException(status_code=404)
|
|
199
|
+
out = JSONResponse(result.body, status_code=result.status, headers=result.headers)
|
|
200
|
+
out.raw_headers.extend(
|
|
201
|
+
(k, v) for k, v in native.raw_headers if k not in (b'content-length', b'content-type'))
|
|
202
|
+
return out
|
|
203
|
+
return endpoint
|
|
204
|
+
|
|
205
|
+
for method, sub in RELAY_ROUTES:
|
|
206
|
+
router.add_api_route(sub.replace('<id>', '{cred_id}'), make_endpoint(method, sub), methods=[method],
|
|
207
|
+
include_in_schema=False)
|
|
208
|
+
return router
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask: one line per protected route.
|
|
3
|
+
|
|
4
|
+
from touchque.contrib.flask import require_touchque
|
|
5
|
+
|
|
6
|
+
@app.post('/transfer')
|
|
7
|
+
@require_touchque('SEND_MONEY', details=lambda: {'Amount': f'{request.json["amount"]} EUR'})
|
|
8
|
+
def transfer():
|
|
9
|
+
return jsonify(ok=True, touchque=g.touchque)
|
|
10
|
+
|
|
11
|
+
Until the user approves on their phone this answers
|
|
12
|
+
202 {"touchque": step, "token": ...}. Your page shows the step in its own
|
|
13
|
+
design and sends the same request again with header
|
|
14
|
+
``X-TouchQue-Token: <token>``. Once approved the view runs ONCE, with
|
|
15
|
+
``flask.g.touchque`` set to the approval.
|
|
16
|
+
|
|
17
|
+
Relay routes (POST /login, enrollment, offline codes, passkey ceremonies)
|
|
18
|
+
as one Blueprint - see ``touchque.relay.RelayHandler`` for every option:
|
|
19
|
+
|
|
20
|
+
from touchque.contrib.flask import touchque_relay_blueprint
|
|
21
|
+
|
|
22
|
+
def opened(req, resp, info):
|
|
23
|
+
session['user'] = info['externalUsername']
|
|
24
|
+
|
|
25
|
+
app.register_blueprint(touchque_relay_blueprint(
|
|
26
|
+
tq,
|
|
27
|
+
get_user_id=lambda req: session.get('user'),
|
|
28
|
+
get_login_user=lambda req: session.get('password_ok'),
|
|
29
|
+
on_authenticated=opened,
|
|
30
|
+
), url_prefix='/touchque')
|
|
31
|
+
"""
|
|
32
|
+
import functools
|
|
33
|
+
from typing import Any, Callable, Dict, Optional
|
|
34
|
+
|
|
35
|
+
from flask import Blueprint, abort, current_app, g, json, jsonify, request
|
|
36
|
+
|
|
37
|
+
from ..client import TouchQue, get_default_client
|
|
38
|
+
from ..guard import SilentSignIn, guard_input_from_headers, run_guard, silent_input_from_headers
|
|
39
|
+
from ..relay import RELAY_ROUTES, RelayHandler
|
|
40
|
+
from ..steps import LoginDetails
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_user() -> Optional[str]:
|
|
44
|
+
u = getattr(g, 'user', None)
|
|
45
|
+
if u is None:
|
|
46
|
+
return None
|
|
47
|
+
if isinstance(u, str):
|
|
48
|
+
return u
|
|
49
|
+
return getattr(u, 'email', None) or getattr(u, 'id', None) or getattr(u, 'username', None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_touchque(
|
|
53
|
+
action: str,
|
|
54
|
+
client: Optional[TouchQue] = None,
|
|
55
|
+
user: Optional[Callable[[], Optional[str]]] = None,
|
|
56
|
+
details: Optional[Callable[[], Optional[LoginDetails]]] = None,
|
|
57
|
+
reference_id: Optional[Callable[[], Optional[str]]] = None,
|
|
58
|
+
silent_sign_in: SilentSignIn = False,
|
|
59
|
+
context: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
|
|
60
|
+
) -> Callable:
|
|
61
|
+
"""
|
|
62
|
+
``user``: who is approving. Default: ``flask.g.user`` (string, or an
|
|
63
|
+
object with ``.email``/``.id``/``.username``). For the login step, return
|
|
64
|
+
the user who just passed your password check, e.g.
|
|
65
|
+
``user=lambda: session.get('password_ok')``.
|
|
66
|
+
|
|
67
|
+
``silent_sign_in`` (default off): when on, a new attempt whose browser
|
|
68
|
+
sent its device-bound key proof (``X-TouchQue-Browser``, added by
|
|
69
|
+
``@touchque/web`` with ``browserKey: true``) first asks TouchQue whether
|
|
70
|
+
anything about it is new. If nothing is, your view runs at once with no
|
|
71
|
+
push (``g.touchque['assurance']['method'] == 'silent'``,
|
|
72
|
+
``g.touchque['reasons']``). Otherwise, and on any error, the usual push
|
|
73
|
+
starts and its approval makes the browser known.
|
|
74
|
+
``{'max_age_seconds': n}`` sets your own limit on how long ago the last
|
|
75
|
+
phone approval in that browser may be.
|
|
76
|
+
|
|
77
|
+
``context``: for your TouchQue Actions only (``event.context``), e.g.
|
|
78
|
+
``context=lambda: {'roles': g.user.roles}``. Never shown on the phone.
|
|
79
|
+
"""
|
|
80
|
+
user_of = user or _default_user
|
|
81
|
+
tq = client
|
|
82
|
+
|
|
83
|
+
def decorator(view: Callable) -> Callable:
|
|
84
|
+
@functools.wraps(view)
|
|
85
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
86
|
+
c = tq or get_default_client()
|
|
87
|
+
uid = user_of()
|
|
88
|
+
result = run_guard(
|
|
89
|
+
c, uid, action,
|
|
90
|
+
details=details() if details else None,
|
|
91
|
+
reference_id=reference_id() if reference_id else None,
|
|
92
|
+
context=context() if context else None,
|
|
93
|
+
ip=request.remote_addr, user_agent=request.headers.get('User-Agent'),
|
|
94
|
+
**guard_input_from_headers(lambda name: request.headers.get(name)),
|
|
95
|
+
**silent_input_from_headers(silent_sign_in, lambda name: request.headers.get(name)),
|
|
96
|
+
)
|
|
97
|
+
if 'approved' in result:
|
|
98
|
+
g.touchque = result['approved']
|
|
99
|
+
return view(*args, **kwargs)
|
|
100
|
+
resp = jsonify(result['body'])
|
|
101
|
+
resp.status_code = result['status']
|
|
102
|
+
resp.headers['Cache-Control'] = 'no-store'
|
|
103
|
+
retry = result['body']['touchque'].get('retryAfter')
|
|
104
|
+
if retry:
|
|
105
|
+
resp.headers['Retry-After'] = str(retry)
|
|
106
|
+
return resp
|
|
107
|
+
return wrapped
|
|
108
|
+
return decorator
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def touchque_relay_blueprint(
|
|
112
|
+
client: Optional[TouchQue] = None,
|
|
113
|
+
name: str = 'touchque_relay',
|
|
114
|
+
url_prefix: Optional[str] = None,
|
|
115
|
+
handler: Optional[RelayHandler] = None,
|
|
116
|
+
**options: Any
|
|
117
|
+
) -> Blueprint:
|
|
118
|
+
"""A Blueprint with every relay route. ``options`` are ``RelayHandler``'s
|
|
119
|
+
(``get_user_id`` is required); or pass a ready ``handler``.
|
|
120
|
+
|
|
121
|
+
Callbacks get ``flask.request``; ``on_authenticated(request, response,
|
|
122
|
+
info)`` gets the Flask response that will be sent, so it may call
|
|
123
|
+
``response.set_cookie(...)`` or write ``flask.session``.
|
|
124
|
+
``request.remote_addr`` is the client IP the rate limiter uses: behind a
|
|
125
|
+
proxy, wrap the app in werkzeug's ``ProxyFix``.
|
|
126
|
+
"""
|
|
127
|
+
relay = handler or RelayHandler(client, **options)
|
|
128
|
+
bp = Blueprint(name, __name__, url_prefix=url_prefix)
|
|
129
|
+
|
|
130
|
+
def make_view(method: str, sub: str) -> Callable:
|
|
131
|
+
def view(cred_id: Optional[str] = None) -> Any:
|
|
132
|
+
response = current_app.response_class(mimetype='application/json')
|
|
133
|
+
result = relay.handle(
|
|
134
|
+
method, sub.replace('<id>', cred_id or ''),
|
|
135
|
+
body=request.get_json(silent=True),
|
|
136
|
+
ip=request.remote_addr, user_agent=request.headers.get('User-Agent'),
|
|
137
|
+
get_header=lambda name_: request.headers.get(name_),
|
|
138
|
+
request=request._get_current_object(), # type: ignore[attr-defined]
|
|
139
|
+
response=response,
|
|
140
|
+
)
|
|
141
|
+
if result is None:
|
|
142
|
+
abort(404)
|
|
143
|
+
response.set_data(json.dumps(result.body))
|
|
144
|
+
response.status_code = result.status
|
|
145
|
+
for key, value in result.headers.items():
|
|
146
|
+
response.headers[key] = value
|
|
147
|
+
return response
|
|
148
|
+
return view
|
|
149
|
+
|
|
150
|
+
for method, sub in RELAY_ROUTES:
|
|
151
|
+
endpoint = method.lower() + sub.replace('/', '_').replace('<id>', 'id')
|
|
152
|
+
bp.add_url_rule(sub.replace('<id>', '<cred_id>'), endpoint=endpoint,
|
|
153
|
+
view_func=make_view(method, sub), methods=[method])
|
|
154
|
+
return bp
|