479 lines
18 KiB
Python
479 lines
18 KiB
Python
"""
|
|
Remote authentication WebSocket endpoints.
|
|
|
|
This module handles cross-device authentication where one device (requesting)
|
|
wants to log in and another device (authenticating) provides the passkey.
|
|
|
|
Endpoints:
|
|
- /request: Called by the device wanting to be authenticated
|
|
- /pair: Called by the authenticating device to complete the request
|
|
"""
|
|
|
|
import asyncio
|
|
from datetime import UTC, datetime
|
|
from uuid import UUID
|
|
|
|
import base64url
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
|
|
from paskia import authcode, db, remoteauth
|
|
from paskia.authcode import CookieCode
|
|
from paskia.authsession import expires
|
|
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
|
from paskia.fastapi.wschat import authenticate_and_login
|
|
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
|
from paskia.domains import current_domain, registry
|
|
from paskia.util import pow, useragent
|
|
|
|
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
|
|
|
|
@app.websocket("/request")
|
|
@websocket_error_handler
|
|
async def websocket_remote_auth_request(ws: WebSocket):
|
|
"""Request authentication from another device.
|
|
|
|
This endpoint is called by the device that wants to be authenticated.
|
|
It creates a remote auth request and waits for another device to authenticate.
|
|
|
|
Flow:
|
|
1. Client connects
|
|
2. Server sends HARD PoW challenge, client solves and responds
|
|
3. Server creates a 3-word pairing code and sends it with expiry
|
|
4. Server waits for another device to authenticate via /remote-auth/permit
|
|
5. When auth completes, server sends session_token to this client
|
|
6. Client can then use the session token to set a cookie
|
|
7. Connection times out after 5 minutes with explicit timeout message
|
|
"""
|
|
origin = validate_origin(ws)
|
|
host = origin.split("://", 1)[1]
|
|
|
|
if remoteauth.instance is None:
|
|
raise ValueError("Remote authentication is not available")
|
|
|
|
# Track this WebSocket connection for load-based PoW difficulty
|
|
remoteauth.instance.increment_connections()
|
|
try:
|
|
# Send PoW challenge immediately with dynamic difficulty based on load
|
|
challenge = pow.generate_challenge()
|
|
work = remoteauth.instance.get_pow_difficulty()
|
|
|
|
await ws.send_json(
|
|
{
|
|
"pow": {
|
|
"challenge": base64url.enc(challenge),
|
|
"work": work,
|
|
}
|
|
}
|
|
)
|
|
|
|
# Receive client response with PoW solution and action
|
|
response = await ws.receive_json()
|
|
|
|
# Verify PoW (required for this endpoint - SECURITY)
|
|
solution_b64 = response.get("pow")
|
|
if not solution_b64:
|
|
raise ValueError("PoW solution required")
|
|
|
|
try:
|
|
solution = base64url.dec(solution_b64)
|
|
except Exception:
|
|
raise ValueError("Invalid PoW solution encoding")
|
|
|
|
pow.verify_pow(challenge, solution, work)
|
|
|
|
# Extract action from the same message
|
|
action = response.get("action", "login")
|
|
if action not in ("login", "register"):
|
|
action = "login"
|
|
|
|
metadata = infodict(ws, "remote-auth-request")
|
|
|
|
# Create the remote auth request
|
|
pairing_code, expiry = await remoteauth.instance.create_request(
|
|
host=host,
|
|
ip=metadata.get("ip") or "",
|
|
user_agent=metadata.get("user_agent") or "",
|
|
rp_id=current_domain().rp_id,
|
|
action=action,
|
|
)
|
|
|
|
# Send the pairing code to the client
|
|
await ws.send_json(
|
|
{
|
|
"pairing_code": pairing_code,
|
|
"expires": expiry.isoformat().replace("+00:00", "Z"),
|
|
}
|
|
)
|
|
|
|
# Set up async notification for completion
|
|
result_event = asyncio.Event()
|
|
result_data: dict = {}
|
|
|
|
def on_complete(
|
|
session_token: str | None,
|
|
user_uuid: UUID | None,
|
|
credential_uuid: UUID | None,
|
|
reset_token: str | None,
|
|
):
|
|
# Check if this was an explicit denial (UUID(int=0) is the signal)
|
|
was_denied = user_uuid is not None and user_uuid == UUID(int=0)
|
|
result_data["session_token"] = session_token
|
|
result_data["user_uuid"] = user_uuid
|
|
result_data["credential_uuid"] = credential_uuid
|
|
result_data["reset_token"] = reset_token
|
|
result_data["was_denied"] = was_denied
|
|
result_event.set()
|
|
|
|
await remoteauth.instance.set_notify_callback(pairing_code, on_complete)
|
|
|
|
# Set up async notification for action lock
|
|
locked_event = asyncio.Event()
|
|
locked_data: dict = {}
|
|
|
|
def on_action_locked(action: str):
|
|
locked_data["action"] = action
|
|
locked_event.set()
|
|
|
|
await remoteauth.instance.set_action_locked_callback(
|
|
pairing_code, on_action_locked
|
|
)
|
|
|
|
# 5 minute timeout for the entire remote auth flow
|
|
timeout_seconds = 5 * 60
|
|
|
|
try:
|
|
# Wait for either:
|
|
# 1. Authentication to complete (result_event set)
|
|
# 2. Action locked (locked_event set)
|
|
# 3. Client to disconnect
|
|
# 4. Client to send a cancel or update_action message
|
|
# 5. Timeout after 5 minutes
|
|
|
|
async with asyncio.timeout(timeout_seconds):
|
|
while True:
|
|
# Use asyncio.wait to handle events and websocket
|
|
receive_task = asyncio.create_task(ws.receive_json())
|
|
result_wait_task = asyncio.create_task(result_event.wait())
|
|
locked_wait_task = asyncio.create_task(locked_event.wait())
|
|
|
|
tasks = [receive_task, result_wait_task]
|
|
# Only wait for locked event if not already locked
|
|
if not locked_event.is_set():
|
|
tasks.append(locked_wait_task)
|
|
|
|
done, pending = await asyncio.wait(
|
|
tasks,
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
|
|
# Cancel pending tasks
|
|
for task in pending:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
if result_wait_task in done:
|
|
# Authentication completed (or expired/cancelled/denied)
|
|
was_denied = result_data.get("was_denied", False)
|
|
if result_data.get("session_token") or result_data.get(
|
|
"reset_token"
|
|
):
|
|
response = {
|
|
"status": "authenticated",
|
|
"user": str(result_data["user_uuid"]),
|
|
}
|
|
if result_data.get("session_token"):
|
|
response["exchange_code"] = result_data["session_token"]
|
|
if result_data.get("reset_token"):
|
|
response["reset_token"] = result_data["reset_token"]
|
|
await ws.send_json(response)
|
|
else:
|
|
# Check if it was explicitly denied
|
|
if was_denied:
|
|
await ws.send_json(
|
|
{
|
|
"status": "denied",
|
|
"detail": "Access denied",
|
|
}
|
|
)
|
|
else:
|
|
await ws.send_json(
|
|
{
|
|
"status": "expired",
|
|
"detail": "Remote authentication request expired or was cancelled",
|
|
}
|
|
)
|
|
return
|
|
|
|
if locked_wait_task in done:
|
|
# Action was locked by the authenticating device
|
|
await ws.send_json(
|
|
{
|
|
"status": "locked",
|
|
"action": locked_data.get("action", "login"),
|
|
}
|
|
)
|
|
# Continue waiting for result
|
|
|
|
if receive_task in done:
|
|
# Client sent a message
|
|
msg = receive_task.result()
|
|
if msg.get("action") == "cancel":
|
|
await remoteauth.instance.cancel_request(pairing_code)
|
|
await ws.send_json({"status": "cancelled"})
|
|
return
|
|
elif msg.get("action") == "update_action":
|
|
# Update the action (login/register) if not locked
|
|
new_action = "register" if msg.get("register") else "login"
|
|
await remoteauth.instance.update_action(
|
|
pairing_code, new_action
|
|
)
|
|
# Ignore other messages
|
|
|
|
except TimeoutError:
|
|
# 5 minute timeout reached
|
|
await remoteauth.instance.cancel_request(pairing_code)
|
|
await ws.send_json(
|
|
{
|
|
"status": "timeout",
|
|
"detail": "Remote authentication request timed out after 5 minutes",
|
|
}
|
|
)
|
|
except WebSocketDisconnect:
|
|
# Client disconnected, cancel the request and mark as denied
|
|
await remoteauth.instance.cancel_request(pairing_code, denied=True)
|
|
except Exception:
|
|
await remoteauth.instance.cancel_request(pairing_code)
|
|
raise
|
|
finally:
|
|
# Decrement connection count
|
|
remoteauth.instance.decrement_connections()
|
|
|
|
|
|
@app.websocket("/permit")
|
|
@websocket_error_handler
|
|
async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
|
"""Complete a remote authentication request using a 3-word pairing code.
|
|
|
|
This endpoint is called from the user's profile on the authenticating device.
|
|
The user enters the pairing code displayed on the requesting device.
|
|
|
|
Protocol:
|
|
1. Server sends PoW challenge immediately on connect
|
|
2. Client sends {code: "word.word.word", pow: "<base64>"} for 3-word pairing code
|
|
3. Server validates PoW and code:
|
|
- If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}}
|
|
- If valid: {status: "found", host: "...", user_agent_pretty: "...", pow: {challenge, work}}
|
|
4. Client can then send {authenticate: true} to start WebAuthn
|
|
5. Server sends {optionsJSON: ...}
|
|
6. Client sends WebAuthn response
|
|
7. Server sends {status: "success", message: "..."}
|
|
"""
|
|
|
|
validate_origin(ws)
|
|
|
|
if remoteauth.instance is None:
|
|
raise ValueError("Remote authentication is not available")
|
|
|
|
# Generate initial PoW challenge (always NORMAL for authenticated users)
|
|
challenge = pow.generate_challenge()
|
|
work = pow.NORMAL
|
|
|
|
await ws.send_json(
|
|
{
|
|
"pow": {
|
|
"challenge": base64url.enc(challenge),
|
|
"work": work,
|
|
}
|
|
}
|
|
)
|
|
|
|
request = None
|
|
explicitly_denied = False
|
|
|
|
try:
|
|
while True:
|
|
msg = await ws.receive_json()
|
|
|
|
# Handle deny request first (no PoW needed - already validated during lookup)
|
|
if msg.get("deny") and request is not None:
|
|
# Cancel the request and mark it as denied
|
|
explicitly_denied = True
|
|
await remoteauth.instance.cancel_request(request.key, denied=True)
|
|
await ws.send_json(
|
|
{
|
|
"status": "denied",
|
|
"message": "Request denied",
|
|
}
|
|
)
|
|
break
|
|
|
|
# Handle authenticate request (no PoW needed - already validated during lookup)
|
|
if msg.get("authenticate") and request is not None:
|
|
ctx, secret = await authenticate_and_login(
|
|
ws,
|
|
auth,
|
|
session_host=request.host,
|
|
session_ip=request.ip,
|
|
session_user_agent=request.user_agent,
|
|
)
|
|
|
|
reset_token = None
|
|
|
|
if request.action == "register":
|
|
# For registration, create a reset token for device addition
|
|
expiry = expires()
|
|
reset_token = db.create_reset_token(
|
|
user_uuid=ctx.user.uuid,
|
|
expiry=expiry,
|
|
token_type="device addition",
|
|
user=str(ctx.user.uuid),
|
|
)
|
|
|
|
# Create exchange code for the session (don't expose raw secret)
|
|
# Stamped with the *requesting* device's domain: it redeems the
|
|
# code on its own host, which dispatches to that domain.
|
|
exchange_code = authcode.store_cookie(
|
|
CookieCode(
|
|
session_key=secret,
|
|
created=datetime.now(UTC),
|
|
rp_id=request.rp_id,
|
|
)
|
|
)
|
|
|
|
# Complete the remote auth request (notifies the waiting device)
|
|
cred = db.data().credentials[ctx.session.credential_uuid]
|
|
completed = await remoteauth.instance.complete_request(
|
|
token=request.key,
|
|
session_token=exchange_code,
|
|
user_uuid=ctx.user.uuid,
|
|
credential_uuid=cred.uuid,
|
|
reset_token=reset_token,
|
|
)
|
|
|
|
if not completed:
|
|
raise ValueError("Failed to complete remote authentication")
|
|
|
|
msg = "Authentication successful."
|
|
if request.action == "register":
|
|
msg += " The other device can now register a passkey."
|
|
else:
|
|
msg += " The other device is now logged in."
|
|
|
|
await ws.send_json(
|
|
{
|
|
"status": "success",
|
|
"message": msg,
|
|
}
|
|
)
|
|
break
|
|
|
|
# Handle code lookup request - requires PoW validation
|
|
code = msg.get("code", "")
|
|
|
|
# Validate PoW for pairing codes
|
|
solution_b64 = msg.get("pow")
|
|
if not solution_b64:
|
|
raise ValueError("PoW solution required")
|
|
|
|
try:
|
|
solution = base64url.dec(solution_b64)
|
|
except Exception:
|
|
raise ValueError("Invalid PoW solution encoding")
|
|
|
|
try:
|
|
pow.verify_pow(challenge, solution, work)
|
|
except ValueError as e:
|
|
# Invalid PoW - send new challenge
|
|
challenge = pow.generate_challenge()
|
|
await ws.send_json(
|
|
{
|
|
"status": 400,
|
|
"detail": str(e),
|
|
"pow": {
|
|
"challenge": base64url.enc(challenge),
|
|
"work": work,
|
|
},
|
|
}
|
|
)
|
|
continue
|
|
|
|
if not code:
|
|
raise ValueError("Pairing code required")
|
|
|
|
# Look up the remote auth request by pairing code
|
|
request = await remoteauth.instance.get_request(code)
|
|
|
|
# Generate new challenge for next request (always NORMAL for authenticated users)
|
|
challenge = pow.generate_challenge()
|
|
|
|
if request is None:
|
|
await ws.send_json(
|
|
{
|
|
"status": 404,
|
|
"detail": "Code not found",
|
|
"pow": {
|
|
"challenge": base64url.enc(challenge),
|
|
"work": work,
|
|
},
|
|
}
|
|
)
|
|
request = None # Reset for next attempt
|
|
continue
|
|
|
|
# Valid code found - lock the action so it can't be changed anymore
|
|
# This also notifies the requesting device
|
|
locked_action = await remoteauth.instance.lock_action(request.key)
|
|
if locked_action is None:
|
|
# Already locked by another device
|
|
await ws.send_json(
|
|
{
|
|
"status": 409,
|
|
"detail": "This request is already being processed in another window",
|
|
"pow": {
|
|
"challenge": base64url.enc(challenge),
|
|
"work": work,
|
|
},
|
|
}
|
|
)
|
|
request = None # Reset for next attempt
|
|
continue
|
|
|
|
request.action = locked_action # Update local copy with locked value
|
|
|
|
# Send device info to the authenticating device, including the
|
|
# requesting device's domain (may differ from the approver's)
|
|
requesting_domain = registry().get(request.rp_id)
|
|
await ws.send_json(
|
|
{
|
|
"status": "found",
|
|
"host": request.host,
|
|
"rp_id": request.rp_id,
|
|
"rp_name": (
|
|
requesting_domain.rp_name if requesting_domain else request.rp_id
|
|
),
|
|
"user_agent_pretty": useragent.compact_user_agent(
|
|
request.user_agent
|
|
),
|
|
"client_ip": request.ip,
|
|
"action": request.action,
|
|
"pow": {
|
|
"challenge": base64url.enc(challenge),
|
|
"work": work,
|
|
},
|
|
}
|
|
)
|
|
except Exception:
|
|
# If websocket disconnects without explicit denial, unlock the request
|
|
if request and not explicitly_denied:
|
|
# Unlock the request so the code can be used again
|
|
async with remoteauth.instance._lock:
|
|
req = remoteauth.instance._requests.get(request.key)
|
|
if req and req.locked:
|
|
req.locked = False
|
|
raise
|