paskia 0.7.1__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.
- paskia/__init__.py +3 -0
- paskia/_version.py +34 -0
- paskia/aaguid/__init__.py +32 -0
- paskia/aaguid/combined_aaguid.json +1 -0
- paskia/authsession.py +112 -0
- paskia/bootstrap.py +190 -0
- paskia/config.py +25 -0
- paskia/db/__init__.py +415 -0
- paskia/db/sql.py +1424 -0
- paskia/fastapi/__init__.py +3 -0
- paskia/fastapi/__main__.py +335 -0
- paskia/fastapi/admin.py +850 -0
- paskia/fastapi/api.py +308 -0
- paskia/fastapi/auth_host.py +97 -0
- paskia/fastapi/authz.py +110 -0
- paskia/fastapi/mainapp.py +130 -0
- paskia/fastapi/remote.py +504 -0
- paskia/fastapi/reset.py +101 -0
- paskia/fastapi/session.py +52 -0
- paskia/fastapi/user.py +162 -0
- paskia/fastapi/ws.py +163 -0
- paskia/fastapi/wsutil.py +91 -0
- paskia/frontend-build/auth/admin/index.html +18 -0
- paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css +1 -0
- paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +8 -0
- paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +1 -0
- paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css +1 -0
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +1 -0
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js +2 -0
- paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +1 -0
- paskia/frontend-build/auth/assets/admin-Df5_Damp.js +1 -0
- paskia/frontend-build/auth/assets/auth-BU_O38k2.css +1 -0
- paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +1 -0
- paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js +1 -0
- paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +1 -0
- paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +1 -0
- paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +1 -0
- paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +1 -0
- paskia/frontend-build/auth/assets/restricted-C0IQufuH.js +1 -0
- paskia/frontend-build/auth/index.html +19 -0
- paskia/frontend-build/auth/restricted/index.html +16 -0
- paskia/frontend-build/int/forward/index.html +18 -0
- paskia/frontend-build/int/reset/index.html +15 -0
- paskia/globals.py +71 -0
- paskia/remoteauth.py +359 -0
- paskia/sansio.py +263 -0
- paskia/util/frontend.py +75 -0
- paskia/util/hostutil.py +76 -0
- paskia/util/htmlutil.py +47 -0
- paskia/util/passphrase.py +20 -0
- paskia/util/permutil.py +32 -0
- paskia/util/pow.py +45 -0
- paskia/util/querysafe.py +11 -0
- paskia/util/sessionutil.py +37 -0
- paskia/util/startupbox.py +75 -0
- paskia/util/timeutil.py +47 -0
- paskia/util/tokens.py +44 -0
- paskia/util/useragent.py +10 -0
- paskia/util/userinfo.py +159 -0
- paskia/util/wordlist.py +54 -0
- paskia-0.7.1.dist-info/METADATA +22 -0
- paskia-0.7.1.dist-info/RECORD +64 -0
- paskia-0.7.1.dist-info/WHEEL +4 -0
- paskia-0.7.1.dist-info/entry_points.txt +2 -0
paskia/fastapi/remote.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remote authentication WebSocket endpoints.
|
|
3
|
+
|
|
4
|
+
This module handles cross-device authentication where one device (requesting)
|
|
5
|
+
wants to log in and another device (authenticating) provides the passkey.
|
|
6
|
+
|
|
7
|
+
Endpoints:
|
|
8
|
+
- /request: Called by the device wanting to be authenticated
|
|
9
|
+
- /pair: Called by the authenticating device to complete the request
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
import base64url
|
|
16
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
17
|
+
|
|
18
|
+
from paskia import remoteauth
|
|
19
|
+
from paskia.authsession import create_session
|
|
20
|
+
from paskia.fastapi.session import infodict
|
|
21
|
+
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
|
22
|
+
from paskia.globals import db, passkey
|
|
23
|
+
from paskia.util import passphrase, pow
|
|
24
|
+
|
|
25
|
+
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
|
26
|
+
app = FastAPI()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.websocket("/request")
|
|
30
|
+
@websocket_error_handler
|
|
31
|
+
async def websocket_remote_auth_request(ws: WebSocket):
|
|
32
|
+
"""Request authentication from another device.
|
|
33
|
+
|
|
34
|
+
This endpoint is called by the device that wants to be authenticated.
|
|
35
|
+
It creates a remote auth request and waits for another device to authenticate.
|
|
36
|
+
|
|
37
|
+
Flow:
|
|
38
|
+
1. Client connects
|
|
39
|
+
2. Server sends HARD PoW challenge, client solves and responds
|
|
40
|
+
3. Server creates a 3-word pairing code and sends it with expiry
|
|
41
|
+
4. Server waits for another device to authenticate via /remote-auth/permit
|
|
42
|
+
5. When auth completes, server sends session_token to this client
|
|
43
|
+
6. Client can then use the session token to set a cookie
|
|
44
|
+
7. Connection times out after 5 minutes with explicit timeout message
|
|
45
|
+
"""
|
|
46
|
+
origin = validate_origin(ws)
|
|
47
|
+
host = origin.split("://", 1)[1]
|
|
48
|
+
|
|
49
|
+
if remoteauth.instance is None:
|
|
50
|
+
raise ValueError("Remote authentication is not available")
|
|
51
|
+
|
|
52
|
+
# Track this WebSocket connection for load-based PoW difficulty
|
|
53
|
+
remoteauth.instance.increment_connections()
|
|
54
|
+
try:
|
|
55
|
+
# Send PoW challenge immediately with dynamic difficulty based on load
|
|
56
|
+
challenge = pow.generate_challenge()
|
|
57
|
+
work = remoteauth.instance.get_pow_difficulty()
|
|
58
|
+
|
|
59
|
+
await ws.send_json(
|
|
60
|
+
{
|
|
61
|
+
"pow": {
|
|
62
|
+
"challenge": base64url.enc(challenge),
|
|
63
|
+
"work": work,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Receive client response with PoW solution and action
|
|
69
|
+
response = await ws.receive_json()
|
|
70
|
+
|
|
71
|
+
# Verify PoW (required for this endpoint - SECURITY)
|
|
72
|
+
solution_b64 = response.get("pow")
|
|
73
|
+
if not solution_b64:
|
|
74
|
+
raise ValueError("PoW solution required")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
solution = base64url.dec(solution_b64)
|
|
78
|
+
except Exception:
|
|
79
|
+
raise ValueError("Invalid PoW solution encoding")
|
|
80
|
+
|
|
81
|
+
pow.verify_pow(challenge, solution, work)
|
|
82
|
+
|
|
83
|
+
# Extract action from the same message
|
|
84
|
+
action = response.get("action", "login")
|
|
85
|
+
if action not in ("login", "register"):
|
|
86
|
+
action = "login"
|
|
87
|
+
|
|
88
|
+
metadata = infodict(ws, "remote-auth-request")
|
|
89
|
+
|
|
90
|
+
# Create the remote auth request
|
|
91
|
+
pairing_code, expiry = await remoteauth.instance.create_request(
|
|
92
|
+
host=host,
|
|
93
|
+
ip=metadata.get("ip") or "",
|
|
94
|
+
user_agent=metadata.get("user_agent") or "",
|
|
95
|
+
action=action,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Send the pairing code to the client
|
|
99
|
+
await ws.send_json(
|
|
100
|
+
{
|
|
101
|
+
"pairing_code": pairing_code,
|
|
102
|
+
"expires": expiry.isoformat().replace("+00:00", "Z"),
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Set up async notification for completion
|
|
107
|
+
result_event = asyncio.Event()
|
|
108
|
+
result_data: dict = {}
|
|
109
|
+
|
|
110
|
+
def on_complete(
|
|
111
|
+
session_token: str | None,
|
|
112
|
+
user_uuid: UUID | None,
|
|
113
|
+
credential_uuid: UUID | None,
|
|
114
|
+
reset_token: str | None,
|
|
115
|
+
):
|
|
116
|
+
# Check if this was an explicit denial (UUID(int=0) is the signal)
|
|
117
|
+
was_denied = user_uuid is not None and user_uuid == UUID(int=0)
|
|
118
|
+
result_data["session_token"] = session_token
|
|
119
|
+
result_data["user_uuid"] = user_uuid
|
|
120
|
+
result_data["credential_uuid"] = credential_uuid
|
|
121
|
+
result_data["reset_token"] = reset_token
|
|
122
|
+
result_data["was_denied"] = was_denied
|
|
123
|
+
result_event.set()
|
|
124
|
+
|
|
125
|
+
await remoteauth.instance.set_notify_callback(pairing_code, on_complete)
|
|
126
|
+
|
|
127
|
+
# Set up async notification for action lock
|
|
128
|
+
locked_event = asyncio.Event()
|
|
129
|
+
locked_data: dict = {}
|
|
130
|
+
|
|
131
|
+
def on_action_locked(action: str):
|
|
132
|
+
locked_data["action"] = action
|
|
133
|
+
locked_event.set()
|
|
134
|
+
|
|
135
|
+
await remoteauth.instance.set_action_locked_callback(
|
|
136
|
+
pairing_code, on_action_locked
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# 5 minute timeout for the entire remote auth flow
|
|
140
|
+
timeout_seconds = 5 * 60
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
# Wait for either:
|
|
144
|
+
# 1. Authentication to complete (result_event set)
|
|
145
|
+
# 2. Action locked (locked_event set)
|
|
146
|
+
# 3. Client to disconnect
|
|
147
|
+
# 4. Client to send a cancel or update_action message
|
|
148
|
+
# 5. Timeout after 5 minutes
|
|
149
|
+
|
|
150
|
+
async with asyncio.timeout(timeout_seconds):
|
|
151
|
+
while True:
|
|
152
|
+
# Use asyncio.wait to handle events and websocket
|
|
153
|
+
receive_task = asyncio.create_task(ws.receive_json())
|
|
154
|
+
result_wait_task = asyncio.create_task(result_event.wait())
|
|
155
|
+
locked_wait_task = asyncio.create_task(locked_event.wait())
|
|
156
|
+
|
|
157
|
+
tasks = [receive_task, result_wait_task]
|
|
158
|
+
# Only wait for locked event if not already locked
|
|
159
|
+
if not locked_event.is_set():
|
|
160
|
+
tasks.append(locked_wait_task)
|
|
161
|
+
|
|
162
|
+
done, pending = await asyncio.wait(
|
|
163
|
+
tasks,
|
|
164
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Cancel pending tasks
|
|
168
|
+
for task in pending:
|
|
169
|
+
task.cancel()
|
|
170
|
+
try:
|
|
171
|
+
await task
|
|
172
|
+
except asyncio.CancelledError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
if result_wait_task in done:
|
|
176
|
+
# Authentication completed (or expired/cancelled/denied)
|
|
177
|
+
was_denied = result_data.get("was_denied", False)
|
|
178
|
+
if result_data.get("session_token") or result_data.get(
|
|
179
|
+
"reset_token"
|
|
180
|
+
):
|
|
181
|
+
response = {
|
|
182
|
+
"status": "authenticated",
|
|
183
|
+
"user_uuid": str(result_data["user_uuid"]),
|
|
184
|
+
}
|
|
185
|
+
if result_data.get("session_token"):
|
|
186
|
+
response["session_token"] = result_data["session_token"]
|
|
187
|
+
if result_data.get("reset_token"):
|
|
188
|
+
response["reset_token"] = result_data["reset_token"]
|
|
189
|
+
await ws.send_json(response)
|
|
190
|
+
else:
|
|
191
|
+
# Check if it was explicitly denied
|
|
192
|
+
if was_denied:
|
|
193
|
+
await ws.send_json(
|
|
194
|
+
{
|
|
195
|
+
"status": "denied",
|
|
196
|
+
"detail": "Access denied",
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
await ws.send_json(
|
|
201
|
+
{
|
|
202
|
+
"status": "expired",
|
|
203
|
+
"detail": "Remote authentication request expired or was cancelled",
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if locked_wait_task in done:
|
|
209
|
+
# Action was locked by the authenticating device
|
|
210
|
+
await ws.send_json(
|
|
211
|
+
{
|
|
212
|
+
"status": "locked",
|
|
213
|
+
"action": locked_data.get("action", "login"),
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
# Continue waiting for result
|
|
217
|
+
|
|
218
|
+
if receive_task in done:
|
|
219
|
+
# Client sent a message
|
|
220
|
+
msg = receive_task.result()
|
|
221
|
+
if msg.get("action") == "cancel":
|
|
222
|
+
await remoteauth.instance.cancel_request(pairing_code)
|
|
223
|
+
await ws.send_json({"status": "cancelled"})
|
|
224
|
+
return
|
|
225
|
+
elif msg.get("action") == "update_action":
|
|
226
|
+
# Update the action (login/register) if not locked
|
|
227
|
+
new_action = "register" if msg.get("register") else "login"
|
|
228
|
+
await remoteauth.instance.update_action(
|
|
229
|
+
pairing_code, new_action
|
|
230
|
+
)
|
|
231
|
+
# Ignore other messages
|
|
232
|
+
|
|
233
|
+
except TimeoutError:
|
|
234
|
+
# 5 minute timeout reached
|
|
235
|
+
await remoteauth.instance.cancel_request(pairing_code)
|
|
236
|
+
await ws.send_json(
|
|
237
|
+
{
|
|
238
|
+
"status": "timeout",
|
|
239
|
+
"detail": "Remote authentication request timed out after 5 minutes",
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
except WebSocketDisconnect:
|
|
243
|
+
# Client disconnected, cancel the request and mark as denied
|
|
244
|
+
await remoteauth.instance.cancel_request(pairing_code, denied=True)
|
|
245
|
+
except Exception:
|
|
246
|
+
await remoteauth.instance.cancel_request(pairing_code)
|
|
247
|
+
raise
|
|
248
|
+
finally:
|
|
249
|
+
# Decrement connection count
|
|
250
|
+
remoteauth.instance.decrement_connections()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@app.websocket("/permit")
|
|
254
|
+
@websocket_error_handler
|
|
255
|
+
async def websocket_remote_auth_permit(ws: WebSocket):
|
|
256
|
+
"""Complete a remote authentication request using a 3-word pairing code.
|
|
257
|
+
|
|
258
|
+
This endpoint is called from the user's profile on the authenticating device.
|
|
259
|
+
The user enters the pairing code displayed on the requesting device.
|
|
260
|
+
|
|
261
|
+
Protocol:
|
|
262
|
+
1. Server sends PoW challenge immediately on connect
|
|
263
|
+
2. Client sends {code: "word.word.word", pow: "<base64>"} for 3-word pairing code
|
|
264
|
+
3. Server validates PoW and code:
|
|
265
|
+
- If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}}
|
|
266
|
+
- If valid: {status: "found", host: "...", user_agent_pretty: "...", pow: {challenge, work}}
|
|
267
|
+
4. Client can then send {authenticate: true} to start WebAuthn
|
|
268
|
+
5. Server sends {optionsJSON: ...}
|
|
269
|
+
6. Client sends WebAuthn response
|
|
270
|
+
7. Server sends {status: "success", message: "..."}
|
|
271
|
+
"""
|
|
272
|
+
from paskia.util import useragent
|
|
273
|
+
|
|
274
|
+
origin = validate_origin(ws)
|
|
275
|
+
|
|
276
|
+
if remoteauth.instance is None:
|
|
277
|
+
raise ValueError("Remote authentication is not available")
|
|
278
|
+
|
|
279
|
+
# Generate initial PoW challenge (always NORMAL for authenticated users)
|
|
280
|
+
challenge = pow.generate_challenge()
|
|
281
|
+
work = pow.NORMAL
|
|
282
|
+
|
|
283
|
+
await ws.send_json(
|
|
284
|
+
{
|
|
285
|
+
"pow": {
|
|
286
|
+
"challenge": base64url.enc(challenge),
|
|
287
|
+
"work": work,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
request = None
|
|
293
|
+
webauthn_challenge = None
|
|
294
|
+
explicitly_denied = False
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
while True:
|
|
298
|
+
msg = await ws.receive_json()
|
|
299
|
+
|
|
300
|
+
# Handle deny request first (no PoW needed - already validated during lookup)
|
|
301
|
+
if msg.get("deny") and request is not None:
|
|
302
|
+
# Cancel the request and mark it as denied
|
|
303
|
+
explicitly_denied = True
|
|
304
|
+
await remoteauth.instance.cancel_request(request.key, denied=True)
|
|
305
|
+
await ws.send_json(
|
|
306
|
+
{
|
|
307
|
+
"status": "denied",
|
|
308
|
+
"message": "Request denied",
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
# Handle authenticate request (no PoW needed - already validated during lookup)
|
|
314
|
+
if msg.get("authenticate") and request is not None:
|
|
315
|
+
# Generate authentication options
|
|
316
|
+
options, webauthn_challenge = passkey.instance.auth_generate_options(
|
|
317
|
+
credential_ids=None
|
|
318
|
+
)
|
|
319
|
+
await ws.send_json({"optionsJSON": options})
|
|
320
|
+
|
|
321
|
+
# Wait for WebAuthn response
|
|
322
|
+
credential = passkey.instance.auth_parse(await ws.receive_json())
|
|
323
|
+
|
|
324
|
+
# Fetch and verify credential
|
|
325
|
+
try:
|
|
326
|
+
stored_cred = await db.instance.get_credential_by_id(
|
|
327
|
+
credential.raw_id
|
|
328
|
+
)
|
|
329
|
+
except ValueError:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Verify the credential
|
|
335
|
+
passkey.instance.auth_verify(
|
|
336
|
+
credential, webauthn_challenge, stored_cred, origin
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Update credential last_used
|
|
340
|
+
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
|
341
|
+
|
|
342
|
+
# Create a session for the REQUESTING device
|
|
343
|
+
assert stored_cred.uuid is not None
|
|
344
|
+
|
|
345
|
+
session_token = None
|
|
346
|
+
reset_token = None
|
|
347
|
+
|
|
348
|
+
if request.action == "register":
|
|
349
|
+
# For registration, create a reset token for device addition
|
|
350
|
+
from paskia.authsession import expires
|
|
351
|
+
from paskia.util import tokens
|
|
352
|
+
|
|
353
|
+
token_str = passphrase.generate()
|
|
354
|
+
expiry = expires()
|
|
355
|
+
await db.instance.create_reset_token(
|
|
356
|
+
user_uuid=stored_cred.user_uuid,
|
|
357
|
+
key=tokens.reset_key(token_str),
|
|
358
|
+
expiry=expiry,
|
|
359
|
+
token_type="device addition",
|
|
360
|
+
)
|
|
361
|
+
reset_token = token_str
|
|
362
|
+
# Also create a session so the device is logged in?
|
|
363
|
+
# User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities."
|
|
364
|
+
session_token = await create_session(
|
|
365
|
+
user_uuid=stored_cred.user_uuid,
|
|
366
|
+
credential_uuid=stored_cred.uuid,
|
|
367
|
+
host=request.host,
|
|
368
|
+
ip=request.ip,
|
|
369
|
+
user_agent=request.user_agent,
|
|
370
|
+
)
|
|
371
|
+
else:
|
|
372
|
+
# Default login action
|
|
373
|
+
session_token = await create_session(
|
|
374
|
+
user_uuid=stored_cred.user_uuid,
|
|
375
|
+
credential_uuid=stored_cred.uuid,
|
|
376
|
+
host=request.host,
|
|
377
|
+
ip=request.ip,
|
|
378
|
+
user_agent=request.user_agent,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Complete the remote auth request (notifies the waiting device)
|
|
382
|
+
completed = await remoteauth.instance.complete_request(
|
|
383
|
+
token=request.key,
|
|
384
|
+
session_token=session_token,
|
|
385
|
+
user_uuid=stored_cred.user_uuid,
|
|
386
|
+
credential_uuid=stored_cred.uuid,
|
|
387
|
+
reset_token=reset_token,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if not completed:
|
|
391
|
+
raise ValueError("Failed to complete remote authentication")
|
|
392
|
+
|
|
393
|
+
msg = "Authentication successful."
|
|
394
|
+
if request.action == "register":
|
|
395
|
+
msg += " The other device can now register a passkey."
|
|
396
|
+
else:
|
|
397
|
+
msg += " The other device is now logged in."
|
|
398
|
+
|
|
399
|
+
await ws.send_json(
|
|
400
|
+
{
|
|
401
|
+
"status": "success",
|
|
402
|
+
"message": msg,
|
|
403
|
+
}
|
|
404
|
+
)
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
# Handle code lookup request - requires PoW validation
|
|
408
|
+
code = msg.get("code", "")
|
|
409
|
+
|
|
410
|
+
# Validate PoW for pairing codes
|
|
411
|
+
solution_b64 = msg.get("pow")
|
|
412
|
+
if not solution_b64:
|
|
413
|
+
raise ValueError("PoW solution required")
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
solution = base64url.dec(solution_b64)
|
|
417
|
+
except Exception:
|
|
418
|
+
raise ValueError("Invalid PoW solution encoding")
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
pow.verify_pow(challenge, solution, work)
|
|
422
|
+
except ValueError as e:
|
|
423
|
+
# Invalid PoW - send new challenge
|
|
424
|
+
challenge = pow.generate_challenge()
|
|
425
|
+
await ws.send_json(
|
|
426
|
+
{
|
|
427
|
+
"status": 400,
|
|
428
|
+
"detail": str(e),
|
|
429
|
+
"pow": {
|
|
430
|
+
"challenge": base64url.enc(challenge),
|
|
431
|
+
"work": work,
|
|
432
|
+
},
|
|
433
|
+
}
|
|
434
|
+
)
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
if not code:
|
|
438
|
+
raise ValueError("Pairing code required")
|
|
439
|
+
|
|
440
|
+
# Look up the remote auth request by pairing code
|
|
441
|
+
request = await remoteauth.instance.get_request(code)
|
|
442
|
+
|
|
443
|
+
# Generate new challenge for next request (always NORMAL for authenticated users)
|
|
444
|
+
challenge = pow.generate_challenge()
|
|
445
|
+
|
|
446
|
+
if request is None:
|
|
447
|
+
await ws.send_json(
|
|
448
|
+
{
|
|
449
|
+
"status": 404,
|
|
450
|
+
"detail": "Code not found",
|
|
451
|
+
"pow": {
|
|
452
|
+
"challenge": base64url.enc(challenge),
|
|
453
|
+
"work": work,
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
)
|
|
457
|
+
request = None # Reset for next attempt
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
# Valid code found - lock the action so it can't be changed anymore
|
|
461
|
+
# This also notifies the requesting device
|
|
462
|
+
locked_action = await remoteauth.instance.lock_action(request.key)
|
|
463
|
+
if locked_action is None:
|
|
464
|
+
# Already locked by another device
|
|
465
|
+
await ws.send_json(
|
|
466
|
+
{
|
|
467
|
+
"status": 409,
|
|
468
|
+
"detail": "This request is already being processed in another window",
|
|
469
|
+
"pow": {
|
|
470
|
+
"challenge": base64url.enc(challenge),
|
|
471
|
+
"work": work,
|
|
472
|
+
},
|
|
473
|
+
}
|
|
474
|
+
)
|
|
475
|
+
request = None # Reset for next attempt
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
request.action = locked_action # Update local copy with locked value
|
|
479
|
+
|
|
480
|
+
# Send device info to the authenticating device
|
|
481
|
+
await ws.send_json(
|
|
482
|
+
{
|
|
483
|
+
"status": "found",
|
|
484
|
+
"host": request.host,
|
|
485
|
+
"user_agent_pretty": useragent.compact_user_agent(
|
|
486
|
+
request.user_agent
|
|
487
|
+
),
|
|
488
|
+
"client_ip": request.ip,
|
|
489
|
+
"action": request.action,
|
|
490
|
+
"pow": {
|
|
491
|
+
"challenge": base64url.enc(challenge),
|
|
492
|
+
"work": work,
|
|
493
|
+
},
|
|
494
|
+
}
|
|
495
|
+
)
|
|
496
|
+
except Exception:
|
|
497
|
+
# If websocket disconnects without explicit denial, unlock the request
|
|
498
|
+
if request and not explicitly_denied:
|
|
499
|
+
# Unlock the request so the code can be used again
|
|
500
|
+
async with remoteauth.instance._lock:
|
|
501
|
+
req = remoteauth.instance._requests.get(request.key)
|
|
502
|
+
if req and req.locked:
|
|
503
|
+
req.locked = False
|
|
504
|
+
raise
|
paskia/fastapi/reset.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""CLI support for creating user credential reset links.
|
|
2
|
+
|
|
3
|
+
Usage (via main CLI):
|
|
4
|
+
paskia reset [query]
|
|
5
|
+
|
|
6
|
+
If query is omitted, the master admin (first Administration role user in
|
|
7
|
+
an organization granting auth:admin) is targeted. Otherwise query is
|
|
8
|
+
matched as either an exact UUID or a case-insensitive substring of the
|
|
9
|
+
display name. If multiple users match, they are listed and the command
|
|
10
|
+
aborts. A new one-time reset link is always created.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from paskia import authsession as _authsession
|
|
19
|
+
from paskia import globals as _g
|
|
20
|
+
from paskia.util import hostutil, passphrase
|
|
21
|
+
from paskia.util import tokens as _tokens
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _resolve_targets(query: str | None):
|
|
25
|
+
if query:
|
|
26
|
+
# Try UUID
|
|
27
|
+
targets: list[tuple] = []
|
|
28
|
+
try:
|
|
29
|
+
q_uuid = UUID(query)
|
|
30
|
+
perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin")
|
|
31
|
+
for o in perm_orgs:
|
|
32
|
+
users = await _g.db.instance.get_organization_users(str(o.uuid))
|
|
33
|
+
for u, role_name in users:
|
|
34
|
+
if u.uuid == q_uuid:
|
|
35
|
+
return [(u, role_name)]
|
|
36
|
+
# UUID not found among admin orgs -> fall back to substring search (rare case)
|
|
37
|
+
except ValueError:
|
|
38
|
+
pass
|
|
39
|
+
# Substring search
|
|
40
|
+
needle = query.lower()
|
|
41
|
+
perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin")
|
|
42
|
+
for o in perm_orgs:
|
|
43
|
+
users = await _g.db.instance.get_organization_users(str(o.uuid))
|
|
44
|
+
for u, role_name in users:
|
|
45
|
+
if needle in (u.display_name or "").lower():
|
|
46
|
+
targets.append((u, role_name))
|
|
47
|
+
# De-duplicate
|
|
48
|
+
seen = set()
|
|
49
|
+
deduped = []
|
|
50
|
+
for u, role_name in targets:
|
|
51
|
+
if u.uuid not in seen:
|
|
52
|
+
seen.add(u.uuid)
|
|
53
|
+
deduped.append((u, role_name))
|
|
54
|
+
return deduped
|
|
55
|
+
# No query -> master admin
|
|
56
|
+
perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin")
|
|
57
|
+
if not perm_orgs:
|
|
58
|
+
return []
|
|
59
|
+
users = await _g.db.instance.get_organization_users(str(perm_orgs[0].uuid))
|
|
60
|
+
admin_users = [pair for pair in users if pair[1] == "Administration"]
|
|
61
|
+
return admin_users[:1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _create_reset(user, role_name: str):
|
|
65
|
+
token = passphrase.generate()
|
|
66
|
+
expiry = _authsession.reset_expires()
|
|
67
|
+
await _g.db.instance.create_reset_token(
|
|
68
|
+
user_uuid=user.uuid,
|
|
69
|
+
key=_tokens.reset_key(token),
|
|
70
|
+
expiry=expiry,
|
|
71
|
+
token_type="manual reset",
|
|
72
|
+
)
|
|
73
|
+
return hostutil.reset_link_url(token), token
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _main(query: str | None) -> int:
|
|
77
|
+
try:
|
|
78
|
+
candidates = await _resolve_targets(query)
|
|
79
|
+
if not candidates:
|
|
80
|
+
print("No matching users found")
|
|
81
|
+
return 1
|
|
82
|
+
if len(candidates) > 1:
|
|
83
|
+
print("Multiple matches. Refine your query:")
|
|
84
|
+
for u, role_name in candidates:
|
|
85
|
+
print(f" - {u.display_name} ({u.uuid}) role={role_name}")
|
|
86
|
+
return 2
|
|
87
|
+
user, role_name = candidates[0]
|
|
88
|
+
link, token = await _create_reset(user, role_name)
|
|
89
|
+
print(f"Reset link for {user.display_name} ({user.uuid}):\n{link}\n")
|
|
90
|
+
return 0
|
|
91
|
+
except Exception as e: # pragma: no cover
|
|
92
|
+
print("Failed to create reset link:", e)
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run(query: str | None) -> int:
|
|
97
|
+
"""Synchronous wrapper for CLI entrypoint."""
|
|
98
|
+
return asyncio.run(_main(query))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["run"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI-specific session management for WebAuthn authentication.
|
|
3
|
+
|
|
4
|
+
This module provides FastAPI-specific session management functionality:
|
|
5
|
+
- Extracting client information from FastAPI requests
|
|
6
|
+
- Setting and clearing HTTP-only cookies via FastAPI Response objects
|
|
7
|
+
|
|
8
|
+
Generic session management functions have been moved to authsession.py
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from fastapi import Cookie, Request, Response, WebSocket
|
|
12
|
+
|
|
13
|
+
from paskia.authsession import EXPIRES
|
|
14
|
+
|
|
15
|
+
AUTH_COOKIE_NAME = "__Host-paskia"
|
|
16
|
+
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def infodict(request: Request | WebSocket, type: str) -> dict:
|
|
20
|
+
"""Extract client information from request."""
|
|
21
|
+
return {
|
|
22
|
+
"ip": request.client.host if request.client else None,
|
|
23
|
+
"user_agent": request.headers.get("user-agent", "")[:500] or None,
|
|
24
|
+
"session_type": type,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_session_cookie(response: Response, token: str) -> None:
|
|
29
|
+
"""Set the session token as an HTTP-only cookie."""
|
|
30
|
+
response.set_cookie(
|
|
31
|
+
key=AUTH_COOKIE_NAME,
|
|
32
|
+
value=token,
|
|
33
|
+
max_age=int(EXPIRES.total_seconds()),
|
|
34
|
+
httponly=True,
|
|
35
|
+
secure=True,
|
|
36
|
+
path="/",
|
|
37
|
+
samesite="lax",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clear_session_cookie(response: Response) -> None:
|
|
42
|
+
# FastAPI's delete_cookie does not set the secure attribute
|
|
43
|
+
response.set_cookie(
|
|
44
|
+
key=AUTH_COOKIE_NAME,
|
|
45
|
+
value="",
|
|
46
|
+
max_age=0,
|
|
47
|
+
expires=0,
|
|
48
|
+
httponly=True,
|
|
49
|
+
secure=True,
|
|
50
|
+
path="/",
|
|
51
|
+
samesite="lax",
|
|
52
|
+
)
|