From 99c60f0e165d476b60251c61a22099d34fa1f5c5 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 8 Dec 2025 22:39:57 +0000 Subject: [PATCH] Massive refactoring of remote auth and related functionality. (WIP) --- frontend/auth/admin/AdminApp.vue | 1 - frontend/auth/restricted/RestrictedApi.vue | 24 +- frontend/int/reset/ResetApp.vue | 6 +- frontend/src/admin/AdminUserDetail.vue | 1 - frontend/src/assets/style.css | 6 +- frontend/src/components/DeviceLinkView.vue | 20 +- frontend/src/components/PairingCodeEntry.vue | 913 ----------------- frontend/src/components/ProfileView.vue | 46 +- frontend/src/components/QRCodeDisplay.vue | 161 +++ .../src/components/RegistrationLinkModal.vue | 180 ++-- .../src/components/RemoteAuthComplete.vue | 237 ----- .../src/components/RemoteAuthLinkModal.vue | 430 -------- frontend/src/components/RemoteAuthPermit.vue | 969 ++++++++++++++++++ frontend/src/components/RemoteAuthRequest.vue | 692 +++++++++++++ frontend/src/components/RestrictedAuth.vue | 187 +++- frontend/src/components/UserBasicInfo.vue | 65 +- frontend/src/utils/awaitable-websocket.js | 32 +- paskia/authsession.py | 2 +- paskia/fastapi/api.py | 3 +- paskia/fastapi/auth_host.py | 2 +- paskia/fastapi/remote.py | 574 +++++++---- paskia/remoteauth.py | 131 ++- paskia/util/hostutil.py | 6 +- 23 files changed, 2645 insertions(+), 2043 deletions(-) delete mode 100644 frontend/src/components/PairingCodeEntry.vue create mode 100644 frontend/src/components/QRCodeDisplay.vue delete mode 100644 frontend/src/components/RemoteAuthComplete.vue delete mode 100644 frontend/src/components/RemoteAuthLinkModal.vue create mode 100644 frontend/src/components/RemoteAuthPermit.vue create mode 100644 frontend/src/components/RemoteAuthRequest.vue diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index fb42321..ab436ca 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -3,7 +3,6 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import Breadcrumbs from '@/components/Breadcrumbs.vue' import CredentialList from '@/components/CredentialList.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue' -import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import StatusMessage from '@/components/StatusMessage.vue' import LoadingView from '@/components/LoadingView.vue' import AuthRequiredMessage from '@/components/AccessDenied.vue' diff --git a/frontend/auth/restricted/RestrictedApi.vue b/frontend/auth/restricted/RestrictedApi.vue index 2e2bf46..056632a 100644 --- a/frontend/auth/restricted/RestrictedApi.vue +++ b/frontend/auth/restricted/RestrictedApi.vue @@ -1,15 +1,7 @@ diff --git a/frontend/src/components/UserBasicInfo.vue b/frontend/src/components/UserBasicInfo.vue index 0e09005..9dc2f06 100644 --- a/frontend/src/components/UserBasicInfo.vue +++ b/frontend/src/components/UserBasicInfo.vue @@ -1,5 +1,5 @@ @@ -44,13 +47,50 @@ const userLoaded = computed(() => !!props.name) diff --git a/frontend/src/utils/awaitable-websocket.js b/frontend/src/utils/awaitable-websocket.js index f68b734..84a1740 100644 --- a/frontend/src/utils/awaitable-websocket.js +++ b/frontend/src/utils/awaitable-websocket.js @@ -18,12 +18,36 @@ class AwaitableWebSocket extends WebSocket { } this.onclose = e => { if (!this.#opened) { - reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`)) + reject(new Error(`Failed to connect to server (code ${e.code})`)) return } - this.#err = e.wasClean - ? new Error(`Websocket ${this.url} closed ${e.code}`) - : new Error(`WebSocket ${this.url} closed with error ${e.code}`) + // Create user-friendly close messages + let message + if (e.wasClean) { + // Standard close codes + switch (e.code) { + case 1000: message = 'Connection closed normally'; break + case 1001: message = 'Server is going away'; break + case 1002: message = 'Protocol error'; break + case 1003: message = 'Unsupported data received'; break + case 1006: message = 'Connection lost unexpectedly'; break + case 1007: message = 'Invalid data received'; break + case 1008: message = 'Policy violation'; break + case 1009: message = 'Message too large'; break + case 1010: message = 'Extension negotiation failed'; break + case 1011: message = 'Server encountered an error'; break + case 1012: message = 'Server is restarting'; break + case 1013: message = 'Server is overloaded, try again later'; break + case 1014: message = 'Bad gateway'; break + case 1015: message = 'TLS handshake failed'; break + default: message = `Connection closed (code ${e.code})` + } + } else { + message = e.code === 1006 + ? 'Connection lost unexpectedly' + : `Connection closed with error (code ${e.code})` + } + this.#err = new Error(message) this.#waiting.splice(0).forEach(p => p.reject(this.#err)) } } diff --git a/paskia/authsession.py b/paskia/authsession.py index 31dc6f3..f67a4ae 100644 --- a/paskia/authsession.py +++ b/paskia/authsession.py @@ -71,7 +71,7 @@ async def get_reset(token: str) -> ResetToken: record = await db.instance.get_reset_token(reset_key(token)) if record and record.expiry >= datetime.now(timezone.utc): return record - raise ValueError("This reset link is invalid or has expired") + raise ValueError("This authentication link is no longer valid.") async def get_session(token: str, host: str | None = None) -> Session: diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index e97a7c3..67dd8d3 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -201,7 +201,8 @@ async def get_settings(): "rp_id": pk.rp_id, "rp_name": pk.rp_name, "ui_base_path": base_path, - "auth_host": hostutil.configured_auth_host(), + "auth_host": hostutil.dedicated_auth_host(), + "auth_site_url": hostutil.auth_site_url(), "session_cookie": AUTH_COOKIE_NAME, } diff --git a/paskia/fastapi/auth_host.py b/paskia/fastapi/auth_host.py index 2cacc25..1a8da01 100644 --- a/paskia/fastapi/auth_host.py +++ b/paskia/fastapi/auth_host.py @@ -73,7 +73,7 @@ def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Resp async def redirect_middleware(request: Request, call_next): """Middleware to handle auth host redirects.""" - cfg = hostutil.configured_auth_host() + cfg = hostutil.dedicated_auth_host() if not cfg: return await call_next(request) diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index 4db4c63..7aba2c9 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -19,9 +19,9 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from paskia import remoteauth from paskia.authsession import create_session from paskia.fastapi.session import infodict -from paskia.fastapi.wsutil import require_pow, validate_origin, websocket_error_handler +from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.globals import db, passkey -from paskia.util import hostutil, pow +from paskia.util import hostutil, passphrase, pow # Create a FastAPI subapp for remote auth WebSocket endpoints @@ -51,119 +51,196 @@ async def websocket_remote_auth_request(ws: WebSocket): if remoteauth.instance is None: raise ValueError("Remote authentication is not available") - # Require HARD PoW before creating the request (SECURITY) - await require_pow(ws, work=pow.HARD) - - metadata = infodict(ws, "remote-auth-request") - - # Create the remote auth request - token, pairing_code, expiry = await remoteauth.instance.create_request( - host=host, - ip=metadata.get("ip") or "", - user_agent=metadata.get("user_agent") or "", - ) - - # Build the URL for the authenticating device (same endpoint as reset tokens) - url = hostutil.auth_site_base_url() + token - - # Send the token, pairing code, and URL to the client - await ws.send_json( - { - "token": token, - "pairing_code": pairing_code, - "url": url, - "expires": expiry.isoformat().replace("+00:00", "Z"), - } - ) - - # Set up async notification - result_event = asyncio.Event() - result_data: dict = {} - - def on_complete( - session_token: str | None, - user_uuid: UUID | None, - credential_uuid: UUID | None, - ): - result_data["session_token"] = session_token - result_data["user_uuid"] = user_uuid - result_data["credential_uuid"] = credential_uuid - result_event.set() - - await remoteauth.instance.set_notify_callback(token, on_complete) - - # 5 minute timeout for the entire remote auth flow - timeout_seconds = 5 * 60 - + # Track this WebSocket connection for load-based PoW difficulty + remoteauth.instance.increment_connections() try: - # Wait for either: - # 1. Authentication to complete (result_event set) - # 2. Client to disconnect - # 3. Client to send a cancel message - # 4. Timeout after 5 minutes + # Send PoW challenge immediately with dynamic difficulty based on load + challenge = pow.generate_challenge() + work = remoteauth.instance.get_pow_difficulty() - async with asyncio.timeout(timeout_seconds): - while True: - # Use asyncio.wait to handle both event and websocket - receive_task = asyncio.create_task(ws.receive_json()) - event_task = asyncio.create_task(result_event.wait()) + await ws.send_json({ + "pow": { + "challenge": base64url.enc(challenge), + "work": work, + } + }) - done, pending = await asyncio.wait( - [receive_task, event_task], - return_when=asyncio.FIRST_COMPLETED, - ) + # Receive client response with PoW solution and action + response = await ws.receive_json() - # Cancel pending tasks - for task in pending: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass + # Verify PoW (required for this endpoint - SECURITY) + solution_b64 = response.get("pow") + if not solution_b64: + raise ValueError("PoW solution required") - if event_task in done: - # Authentication completed (or expired/cancelled) - if result_data.get("session_token"): - await ws.send_json( - { - "status": "authenticated", - "user_uuid": str(result_data["user_uuid"]), - "session_token": result_data["session_token"], - } - ) - else: - await ws.send_json( - { - "status": "expired", - "detail": "Remote authentication request expired or was cancelled", - } - ) - return + try: + solution = base64url.dec(solution_b64) + except Exception: + raise ValueError("Invalid PoW solution encoding") - if receive_task in done: - # Client sent a message - msg = receive_task.result() - if msg.get("action") == "cancel": - await remoteauth.instance.cancel_request(token) - await ws.send_json({"status": "cancelled"}) - return - # Ignore other messages + pow.verify_pow(challenge, solution, work) - except TimeoutError: - # 5 minute timeout reached - await remoteauth.instance.cancel_request(token) + # 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 + token, pairing_code, expiry = await remoteauth.instance.create_request( + host=host, + ip=metadata.get("ip") or "", + user_agent=metadata.get("user_agent") or "", + action=action, + ) + + # Send the token and pairing code to the client (URL built in frontend) await ws.send_json( { - "status": "timeout", - "detail": "Remote authentication request timed out after 5 minutes", + "token": token, + "pairing_code": pairing_code, + "expires": expiry.isoformat().replace("+00:00", "Z"), } ) - except WebSocketDisconnect: - # Client disconnected, cancel the request - await remoteauth.instance.cancel_request(token) - except Exception: - await remoteauth.instance.cancel_request(token) - raise + + # 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(token, 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(token, 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_uuid": str(result_data["user_uuid"]), + } + if result_data.get("session_token"): + response["session_token"] = 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(token) + 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(token, new_action) + # Ignore other messages + + except TimeoutError: + # 5 minute timeout reached + await remoteauth.instance.cancel_request(token) + 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(token, denied=True) + except Exception: + await remoteauth.instance.cancel_request(token) + raise + finally: + # Decrement connection count + remoteauth.instance.decrement_connections() @app.websocket("/pair") @@ -197,7 +274,7 @@ async def websocket_remote_auth_pair(ws: WebSocket): if remoteauth.instance is None: raise ValueError("Remote authentication is not available") - # Generate initial PoW challenge + # Generate initial PoW challenge (always NORMAL for authenticated users) challenge = pow.generate_challenge() work = pow.NORMAL @@ -210,126 +287,203 @@ async def websocket_remote_auth_pair(ws: WebSocket): request = None webauthn_challenge = None + explicitly_denied = False - while True: - msg = await ws.receive_json() + try: + while True: + msg = await ws.receive_json() - # Check if this is a 5-word token (from link) - skip PoW validation - code = msg.get("code", "") - is_link_token = len(code.split(".")) == 5 - - if not is_link_token: - # Validate PoW for 3-word 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() + # 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": 400, - "detail": str(e), + "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: + # Generate authentication options + options, webauthn_challenge = passkey.instance.auth_generate_options( + credential_ids=None + ) + await ws.send_json({"optionsJSON": options}) + + # Wait for WebAuthn response + credential = passkey.instance.auth_parse(await ws.receive_json()) + + # Fetch and verify credential + try: + stored_cred = await db.instance.get_credential_by_id(credential.raw_id) + except ValueError: + raise ValueError( + f"This passkey is no longer registered with {passkey.instance.rp_name}" + ) + + # Verify the credential + passkey.instance.auth_verify( + credential, webauthn_challenge, stored_cred, origin + ) + + # Update credential last_used + await db.instance.login(stored_cred.user_uuid, stored_cred) + + # Create a session for the REQUESTING device + assert stored_cred.uuid is not None + + session_token = None + reset_token = None + + if request.action == "register": + # For registration, create a reset token for device addition + from paskia.authsession import expires + from paskia.util import tokens + + token_str = passphrase.generate() + expiry = expires() + await db.instance.create_reset_token( + user_uuid=stored_cred.user_uuid, + key=tokens.reset_key(token_str), + expiry=expiry, + token_type="device addition", + ) + reset_token = token_str + # Also create a session so the device is logged in? + # User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities." + session_token = await create_session( + user_uuid=stored_cred.user_uuid, + credential_uuid=stored_cred.uuid, + host=request.host, + ip=request.ip, + user_agent=request.user_agent, + ) + else: + # Default login action + session_token = await create_session( + user_uuid=stored_cred.user_uuid, + credential_uuid=stored_cred.uuid, + host=request.host, + ip=request.ip, + user_agent=request.user_agent, + ) + + # Complete the remote auth request (notifies the waiting device) + completed = await remoteauth.instance.complete_request( + token=request.key, + session_token=session_token, + user_uuid=stored_cred.user_uuid, + credential_uuid=stored_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", "") + is_link_token = len(code.split(".")) == 5 + + if not is_link_token: + # Validate PoW for 3-word 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 or token + if is_link_token: + request = await remoteauth.instance.get_request(code) + else: + request = await remoteauth.instance.get_request_by_pairing_code(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 - # Handle authenticate request (after successful lookup) - if msg.get("authenticate") and request is not None: - # Generate authentication options - options, webauthn_challenge = passkey.instance.auth_generate_options( - credential_ids=None - ) - await ws.send_json({"optionsJSON": options}) + # 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 - # Wait for WebAuthn response - credential = passkey.instance.auth_parse(await ws.receive_json()) - - # Fetch and verify credential - try: - stored_cred = await db.instance.get_credential_by_id(credential.raw_id) - except ValueError: - raise ValueError( - f"This passkey is no longer registered with {passkey.instance.rp_name}" - ) - - # Verify the credential - passkey.instance.auth_verify( - credential, webauthn_challenge, stored_cred, origin - ) - - # Update credential last_used - await db.instance.login(stored_cred.user_uuid, stored_cred) - - # Create a session for the REQUESTING device - assert stored_cred.uuid is not None - session_token = await create_session( - user_uuid=stored_cred.user_uuid, - credential_uuid=stored_cred.uuid, - host=request.host, - ip=request.ip, - user_agent=request.user_agent, - ) - - # Complete the remote auth request (notifies the waiting device) - completed = await remoteauth.instance.complete_request( - token=request.key, - session_token=session_token, - user_uuid=stored_cred.user_uuid, - credential_uuid=stored_cred.uuid, - ) - - if not completed: - raise ValueError("Failed to complete remote authentication") + request.action = locked_action # Update local copy with locked value + # Send device info to the authenticating device await ws.send_json({ - "status": "success", - "message": "Authentication successful. The other device is now logged in.", - }) - break - - # Handle code lookup request - if not code: - raise ValueError("Pairing code required") - - # Look up the remote auth request by pairing code or token - if is_link_token: - request = await remoteauth.instance.get_request(code) - else: - request = await remoteauth.instance.get_request_by_pairing_code(code) - - # Generate new challenge for next request - challenge = pow.generate_challenge() - - if request is None: - await ws.send_json({ - "status": 404, - "detail": "Code not found", + "status": "found", + "host": request.host, + "user_agent_pretty": useragent.compact_user_agent(request.user_agent), + "client_ip": request.ip, + "action": request.action, "pow": { "challenge": base64url.enc(challenge), "work": work, } }) - request = None # Reset for next attempt - continue - - # Valid code found - send device info - await ws.send_json({ - "status": "found", - "host": request.host, - "user_agent_pretty": useragent.compact_user_agent(request.user_agent), - "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 diff --git a/paskia/remoteauth.py b/paskia/remoteauth.py index b691a74..d39b933 100644 --- a/paskia/remoteauth.py +++ b/paskia/remoteauth.py @@ -43,14 +43,21 @@ class RemoteAuthRequest: host: str # The host where the session should be created ip: str # IP of the requesting device user_agent: str # User agent of the requesting device + action: str = "login" # "login" or "register" + locked: bool = False # True once the authenticating device has entered the code # Callback to notify the requesting device when auth completes - # Takes (session_token, user_uuid, credential_uuid) or (None, None, None) on cancel/expire - notify: Callable[[str | None, UUID | None, UUID | None], None] | None = None + # Takes (session_token, user_uuid, credential_uuid, reset_token) or (None, None, None, None) on cancel/expire + notify: Callable[[str | None, UUID | None, UUID | None, str | None], None] | None = None + # Callback to notify the requesting device when action is locked + # Takes (action) to confirm what action was locked + action_locked_notify: Callable[[str], None] | None = None # Set when authentication completes completed: bool = False + denied: bool = False # True if explicitly denied by the authenticating device session_token: str | None = None user_uuid: UUID | None = None credential_uuid: UUID | None = None + reset_token: str | None = None def _generate_pairing_code() -> str: @@ -110,7 +117,7 @@ class RemoteAuthManager: self._by_pairing_code.pop(req.pairing_code, None) if req.notify and not req.completed: try: - req.notify(None, None, None) + req.notify(None, None, None, None) except Exception: pass @@ -119,6 +126,7 @@ class RemoteAuthManager: host: str, ip: str, user_agent: str, + action: str = "login", ) -> tuple[str, str, datetime]: """Create a new remote auth request. @@ -151,6 +159,7 @@ class RemoteAuthManager: host=host, ip=ip, user_agent=user_agent, + action=action, ) self._requests[token] = request @@ -200,7 +209,7 @@ class RemoteAuthManager: async def set_notify_callback( self, token: str, - callback: Callable[[str | None, UUID | None, UUID | None], None], + callback: Callable[[str | None, UUID | None, UUID | None, str | None], None], ) -> bool: """Set the notification callback for a request. @@ -213,12 +222,72 @@ class RemoteAuthManager: req.notify = callback return True + async def set_action_locked_callback( + self, + token: str, + callback: Callable[[str], None], + ) -> bool: + """Set the callback for when the action is locked. + + Returns True if the request exists and callback was set. + """ + async with self._lock: + req = self._requests.get(token) + if req is None: + return False + req.action_locked_notify = callback + return True + + async def update_action( + self, + token: str, + action: str, + ) -> bool: + """Update the action for a request (only if not locked). + + Returns True if the request exists and was updated. + """ + if action not in ("login", "register"): + return False + async with self._lock: + req = self._requests.get(token) + if req is None or req.locked: + return False + req.action = action + return True + + async def lock_action( + self, + token: str, + ) -> str | None: + """Lock the action for a request (called when authenticating device enters code). + + Returns the locked action, or None if request doesn't exist or is already locked. + Notifies the requesting device via action_locked_notify callback. + """ + async with self._lock: + req = self._requests.get(token) + if req is None: + return None + if req.locked: + # Already locked by another authenticating device + return None + req.locked = True + action = req.action + if req.action_locked_notify: + try: + req.action_locked_notify(action) + except Exception: + pass + return action + async def complete_request( self, token: str, - session_token: str, + session_token: str | None, user_uuid: UUID, credential_uuid: UUID, + reset_token: str | None = None, ) -> bool: """Mark a request as completed with the authentication result. @@ -233,27 +302,67 @@ class RemoteAuthManager: self._by_pairing_code.pop(req.pairing_code, None) if req.notify: try: - req.notify(session_token, user_uuid, credential_uuid) + req.notify(session_token, user_uuid, credential_uuid, reset_token) except Exception: pass return True - async def cancel_request(self, token: str) -> bool: + async def cancel_request(self, token: str, *, denied: bool = False) -> RemoteAuthRequest | None: """Cancel and remove a request. - Returns True if the request existed and was removed. + Args: + token: The request token + denied: If True, marks this as an explicit denial (not just timeout/disconnect) + + Returns the removed request if it existed, None otherwise. """ async with self._lock: req = self._requests.pop(token, None) if req is None: - return False + return None self._by_pairing_code.pop(req.pairing_code, None) + if denied: + req.denied = True if req.notify and not req.completed: try: - req.notify(None, None, None) + # Pass denied status through a special UUID value (all zeros means denied) + if denied: + req.notify(None, UUID(int=0), None, None) + else: + req.notify(None, None, None, None) except Exception: pass - return True + return req + + def get_connection_count(self) -> int: + """Get the current count of open WebSocket connections. + + This is used to determine PoW difficulty based on load. + """ + # Count is maintained externally by the WebSocket endpoints + return getattr(self, '_ws_count', 0) + + def increment_connections(self) -> None: + """Increment the WebSocket connection counter.""" + self._ws_count = getattr(self, '_ws_count', 0) + 1 + + def decrement_connections(self) -> None: + """Decrement the WebSocket connection counter.""" + self._ws_count = max(0, getattr(self, '_ws_count', 0) - 1) + + def get_pow_difficulty(self) -> int: + """Get PoW difficulty based on current WebSocket connection count. + + Uses NORMAL difficulty with low load (< 10 connections), + HARD difficulty with high load (>= 10 connections). + + Returns: + PoW work units (pow.NORMAL or pow.HARD) + """ + from paskia.util import pow + + count = self.get_connection_count() + return pow.HARD if count >= 10 else pow.NORMAL async def consume_request(self, token: str) -> RemoteAuthRequest | None: """Get and remove a request (for use by the authenticating device).""" diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index 2d792c1..1a4fa1b 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -19,7 +19,7 @@ def is_root_mode() -> bool: return _load_config().get("auth_host") is not None -def configured_auth_host() -> str | None: +def dedicated_auth_host() -> str | None: """Return configured auth_host netloc, or None.""" auth_host = _load_config().get("auth_host") if not auth_host: @@ -34,7 +34,7 @@ def ui_base_path() -> str: return "/" if is_root_mode() else "/auth/" -def auth_site_base_url() -> str: +def auth_site_url() -> str: """Return the base URL for the auth site UI (computed at startup).""" cfg = _load_config() return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/") @@ -42,7 +42,7 @@ def auth_site_base_url() -> str: def reset_link_url(token: str) -> str: """Generate a reset link URL for the given token.""" - return f"{auth_site_base_url()}{token}" + return f"{auth_site_url()}{token}" def normalize_origin(origin: str) -> str: