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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" style="background: transparent">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<script type="module" crossorigin src="/auth/assets/restricted-C0IQufuH.js"></script>
|
|
7
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-rKFEraYH.js">
|
|
8
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/pow-2N9bxgAo.js">
|
|
9
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BLMK7-nL.js">
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css">
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-DgdJyscT.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="app"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Access Restricted</title>
|
|
7
|
+
<script type="module" crossorigin src="/auth/assets/forward-Dzg-aE1C.js"></script>
|
|
8
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-rKFEraYH.js">
|
|
9
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/pow-2N9bxgAo.js">
|
|
10
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BLMK7-nL.js">
|
|
11
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-DzjFIx78.js">
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css">
|
|
13
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-DgdJyscT.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="app"></div>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Complete Passkey Setup</title>
|
|
7
|
+
<script type="module" crossorigin src="/auth/assets/reset-C_Td1_jn.js"></script>
|
|
8
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-rKFEraYH.js">
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css">
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/reset-BWF4cWKR.css">
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="app"></div>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
paskia/globals.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from typing import Generic, TypeVar
|
|
2
|
+
|
|
3
|
+
from paskia.db import DatabaseInterface
|
|
4
|
+
from paskia.sansio import Passkey
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Manager(Generic[T]):
|
|
10
|
+
"""Generic manager for global instances."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, name: str):
|
|
13
|
+
self._instance: T | None = None
|
|
14
|
+
self._name = name
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def instance(self) -> T:
|
|
18
|
+
if self._instance is None:
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
f"{self._name} not initialized. Call globals.init() first."
|
|
21
|
+
)
|
|
22
|
+
return self._instance
|
|
23
|
+
|
|
24
|
+
@instance.setter
|
|
25
|
+
def instance(self, instance: T) -> None:
|
|
26
|
+
self._instance = instance
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def init(
|
|
30
|
+
rp_id: str = "localhost",
|
|
31
|
+
rp_name: str | None = None,
|
|
32
|
+
origins: list[str] | None = None,
|
|
33
|
+
*,
|
|
34
|
+
bootstrap: bool = True,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialize global passkey + database.
|
|
37
|
+
|
|
38
|
+
If bootstrap=True (default) the system bootstrap_if_needed() will be invoked.
|
|
39
|
+
In FastAPI lifespan we call with bootstrap=False to avoid duplicate bootstrapping
|
|
40
|
+
since the CLI performs it once before servers start.
|
|
41
|
+
"""
|
|
42
|
+
from . import remoteauth
|
|
43
|
+
|
|
44
|
+
# Initialize passkey instance with provided parameters
|
|
45
|
+
passkey.instance = Passkey(
|
|
46
|
+
rp_id=rp_id,
|
|
47
|
+
rp_name=rp_name or rp_id,
|
|
48
|
+
origins=origins,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Test if we have a database already initialized, otherwise use SQL
|
|
52
|
+
try:
|
|
53
|
+
db.instance
|
|
54
|
+
except RuntimeError:
|
|
55
|
+
from .db import sql
|
|
56
|
+
|
|
57
|
+
await sql.init()
|
|
58
|
+
|
|
59
|
+
# Initialize remote auth manager
|
|
60
|
+
await remoteauth.init()
|
|
61
|
+
|
|
62
|
+
if bootstrap:
|
|
63
|
+
# Bootstrap system if needed
|
|
64
|
+
from .bootstrap import bootstrap_if_needed
|
|
65
|
+
|
|
66
|
+
await bootstrap_if_needed()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Global instances
|
|
70
|
+
passkey = Manager[Passkey]("Passkey")
|
|
71
|
+
db = Manager[DatabaseInterface]("Database")
|
paskia/remoteauth.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-device (remote) authentication support.
|
|
3
|
+
|
|
4
|
+
This module manages the flow for authenticating from another device:
|
|
5
|
+
1. Device A (requesting) creates a remote auth request and displays QR/link
|
|
6
|
+
2. Device B (authenticating) opens the link and authenticates with passkey
|
|
7
|
+
3. Device A receives the session via WebSocket notification
|
|
8
|
+
|
|
9
|
+
Alternative flow (initiated from profile/authenticating device):
|
|
10
|
+
1. Device A (requesting) creates request and displays short pairing code
|
|
11
|
+
2. Device B (authenticating) enters the pairing code in their profile
|
|
12
|
+
3. Device B authenticates, Device A receives the session
|
|
13
|
+
|
|
14
|
+
The requests are stored in-memory with short expiration (5 minutes).
|
|
15
|
+
The link uses the same /{token} endpoint as reset tokens, but the server
|
|
16
|
+
distinguishes between them by checking if the token exists in remoteauth first.
|
|
17
|
+
The first 3 words of the token serve as the pairing code for manual entry.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from typing import Callable
|
|
25
|
+
from uuid import UUID
|
|
26
|
+
|
|
27
|
+
from paskia.util import passphrase
|
|
28
|
+
|
|
29
|
+
# Remote auth requests expire after this duration
|
|
30
|
+
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RemoteAuthRequest:
|
|
35
|
+
"""A pending remote authentication request."""
|
|
36
|
+
|
|
37
|
+
key: str # The 3-word passphrase code
|
|
38
|
+
created_at: datetime
|
|
39
|
+
host: str # The host where the session should be created
|
|
40
|
+
ip: str # IP of the requesting device
|
|
41
|
+
user_agent: str # User agent of the requesting device
|
|
42
|
+
action: str = "login" # "login" or "register"
|
|
43
|
+
locked: bool = False # True once the authenticating device has entered the code
|
|
44
|
+
# Callback to notify the requesting device when auth completes
|
|
45
|
+
# Takes (session_token, user_uuid, credential_uuid, reset_token) or (None, None, None, None) on cancel/expire
|
|
46
|
+
notify: (
|
|
47
|
+
Callable[[str | None, UUID | None, UUID | None, str | None], None] | None
|
|
48
|
+
) = None
|
|
49
|
+
# Callback to notify the requesting device when action is locked
|
|
50
|
+
# Takes (action) to confirm what action was locked
|
|
51
|
+
action_locked_notify: Callable[[str], None] | None = None
|
|
52
|
+
# Set when authentication completes
|
|
53
|
+
completed: bool = False
|
|
54
|
+
denied: bool = False # True if explicitly denied by the authenticating device
|
|
55
|
+
session_token: str | None = None
|
|
56
|
+
user_uuid: UUID | None = None
|
|
57
|
+
credential_uuid: UUID | None = None
|
|
58
|
+
reset_token: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RemoteAuthManager:
|
|
62
|
+
"""Manages pending remote authentication requests."""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by 3-word code
|
|
66
|
+
self._cleanup_task: asyncio.Task | None = None
|
|
67
|
+
self._lock = asyncio.Lock()
|
|
68
|
+
|
|
69
|
+
async def start(self):
|
|
70
|
+
"""Start the cleanup background task."""
|
|
71
|
+
if self._cleanup_task is None:
|
|
72
|
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
|
73
|
+
|
|
74
|
+
async def stop(self):
|
|
75
|
+
"""Stop the cleanup background task."""
|
|
76
|
+
if self._cleanup_task:
|
|
77
|
+
self._cleanup_task.cancel()
|
|
78
|
+
try:
|
|
79
|
+
await self._cleanup_task
|
|
80
|
+
except asyncio.CancelledError:
|
|
81
|
+
pass
|
|
82
|
+
self._cleanup_task = None
|
|
83
|
+
|
|
84
|
+
async def _cleanup_loop(self):
|
|
85
|
+
"""Periodically clean up expired requests."""
|
|
86
|
+
while True:
|
|
87
|
+
try:
|
|
88
|
+
await asyncio.sleep(60) # Check every minute
|
|
89
|
+
await self._cleanup_expired()
|
|
90
|
+
except asyncio.CancelledError:
|
|
91
|
+
break
|
|
92
|
+
except Exception:
|
|
93
|
+
logging.exception("Error in remote auth cleanup loop")
|
|
94
|
+
|
|
95
|
+
async def _cleanup_expired(self):
|
|
96
|
+
"""Remove expired requests and notify waiting clients."""
|
|
97
|
+
now = datetime.now(timezone.utc)
|
|
98
|
+
expired_keys = []
|
|
99
|
+
async with self._lock:
|
|
100
|
+
for key, req in self._requests.items():
|
|
101
|
+
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
|
102
|
+
expired_keys.append(key)
|
|
103
|
+
for key in expired_keys:
|
|
104
|
+
req = self._requests.pop(key)
|
|
105
|
+
if req.notify and not req.completed:
|
|
106
|
+
try:
|
|
107
|
+
req.notify(None, None, None, None)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
async def create_request(
|
|
112
|
+
self,
|
|
113
|
+
host: str,
|
|
114
|
+
ip: str,
|
|
115
|
+
user_agent: str,
|
|
116
|
+
action: str = "login",
|
|
117
|
+
) -> tuple[str, datetime]:
|
|
118
|
+
"""Create a new remote auth request.
|
|
119
|
+
|
|
120
|
+
The code is a 3-word passphrase.
|
|
121
|
+
We ensure uniqueness across concurrent requests.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
(code, expiry) - The 3-word passphrase code and expiration time
|
|
125
|
+
"""
|
|
126
|
+
now = datetime.now(timezone.utc)
|
|
127
|
+
expiry = now + REMOTE_AUTH_LIFETIME
|
|
128
|
+
|
|
129
|
+
async with self._lock:
|
|
130
|
+
# Generate unique 3-word code
|
|
131
|
+
max_attempts = 100
|
|
132
|
+
for _ in range(max_attempts):
|
|
133
|
+
code = passphrase.generate(n=passphrase.N_WORDS_SHORT)
|
|
134
|
+
if code not in self._requests:
|
|
135
|
+
break
|
|
136
|
+
else:
|
|
137
|
+
# Extremely unlikely but handle gracefully
|
|
138
|
+
raise ValueError("Unable to generate unique code")
|
|
139
|
+
|
|
140
|
+
request = RemoteAuthRequest(
|
|
141
|
+
key=code,
|
|
142
|
+
created_at=now,
|
|
143
|
+
host=host,
|
|
144
|
+
ip=ip,
|
|
145
|
+
user_agent=user_agent,
|
|
146
|
+
action=action,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
self._requests[code] = request
|
|
150
|
+
|
|
151
|
+
return code, expiry
|
|
152
|
+
|
|
153
|
+
async def get_request(self, code: str) -> RemoteAuthRequest | None:
|
|
154
|
+
"""Get a pending request by code, if valid and not expired."""
|
|
155
|
+
# Normalize: lowercase, dot-separated words
|
|
156
|
+
normalized = code.lower().strip().replace(" ", ".")
|
|
157
|
+
if not passphrase.is_well_formed(normalized, n=passphrase.N_WORDS_SHORT):
|
|
158
|
+
return None
|
|
159
|
+
async with self._lock:
|
|
160
|
+
req = self._requests.get(normalized)
|
|
161
|
+
if req is None:
|
|
162
|
+
return None
|
|
163
|
+
now = datetime.now(timezone.utc)
|
|
164
|
+
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
|
165
|
+
# Expired
|
|
166
|
+
del self._requests[normalized]
|
|
167
|
+
return None
|
|
168
|
+
return req
|
|
169
|
+
|
|
170
|
+
async def set_notify_callback(
|
|
171
|
+
self,
|
|
172
|
+
token: str,
|
|
173
|
+
callback: Callable[[str | None, UUID | None, UUID | None, str | None], None],
|
|
174
|
+
) -> bool:
|
|
175
|
+
"""Set the notification callback for a request.
|
|
176
|
+
|
|
177
|
+
Returns True if the request exists and callback was set.
|
|
178
|
+
"""
|
|
179
|
+
async with self._lock:
|
|
180
|
+
req = self._requests.get(token)
|
|
181
|
+
if req is None:
|
|
182
|
+
return False
|
|
183
|
+
req.notify = callback
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
async def set_action_locked_callback(
|
|
187
|
+
self,
|
|
188
|
+
token: str,
|
|
189
|
+
callback: Callable[[str], None],
|
|
190
|
+
) -> bool:
|
|
191
|
+
"""Set the callback for when the action is locked.
|
|
192
|
+
|
|
193
|
+
Returns True if the request exists and callback was set.
|
|
194
|
+
"""
|
|
195
|
+
async with self._lock:
|
|
196
|
+
req = self._requests.get(token)
|
|
197
|
+
if req is None:
|
|
198
|
+
return False
|
|
199
|
+
req.action_locked_notify = callback
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
async def update_action(
|
|
203
|
+
self,
|
|
204
|
+
token: str,
|
|
205
|
+
action: str,
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""Update the action for a request (only if not locked).
|
|
208
|
+
|
|
209
|
+
Returns True if the request exists and was updated.
|
|
210
|
+
"""
|
|
211
|
+
if action not in ("login", "register"):
|
|
212
|
+
return False
|
|
213
|
+
async with self._lock:
|
|
214
|
+
req = self._requests.get(token)
|
|
215
|
+
if req is None or req.locked:
|
|
216
|
+
return False
|
|
217
|
+
req.action = action
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
async def lock_action(
|
|
221
|
+
self,
|
|
222
|
+
token: str,
|
|
223
|
+
) -> str | None:
|
|
224
|
+
"""Lock the action for a request (called when authenticating device enters code).
|
|
225
|
+
|
|
226
|
+
Returns the locked action, or None if request doesn't exist or is already locked.
|
|
227
|
+
Notifies the requesting device via action_locked_notify callback.
|
|
228
|
+
"""
|
|
229
|
+
async with self._lock:
|
|
230
|
+
req = self._requests.get(token)
|
|
231
|
+
if req is None:
|
|
232
|
+
return None
|
|
233
|
+
if req.locked:
|
|
234
|
+
# Already locked by another authenticating device
|
|
235
|
+
return None
|
|
236
|
+
req.locked = True
|
|
237
|
+
action = req.action
|
|
238
|
+
if req.action_locked_notify:
|
|
239
|
+
try:
|
|
240
|
+
req.action_locked_notify(action)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
return action
|
|
244
|
+
|
|
245
|
+
async def complete_request(
|
|
246
|
+
self,
|
|
247
|
+
token: str,
|
|
248
|
+
session_token: str | None,
|
|
249
|
+
user_uuid: UUID,
|
|
250
|
+
credential_uuid: UUID,
|
|
251
|
+
reset_token: str | None = None,
|
|
252
|
+
) -> bool:
|
|
253
|
+
"""Mark a request as completed with the authentication result.
|
|
254
|
+
|
|
255
|
+
The request is removed after notifying the waiting client.
|
|
256
|
+
Returns True if the request existed and was completed.
|
|
257
|
+
"""
|
|
258
|
+
async with self._lock:
|
|
259
|
+
req = self._requests.pop(token, None)
|
|
260
|
+
if req is None:
|
|
261
|
+
return False
|
|
262
|
+
if req.notify:
|
|
263
|
+
try:
|
|
264
|
+
req.notify(session_token, user_uuid, credential_uuid, reset_token)
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
async def cancel_request(
|
|
270
|
+
self, token: str, *, denied: bool = False
|
|
271
|
+
) -> RemoteAuthRequest | None:
|
|
272
|
+
"""Cancel and remove a request.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
token: The request token
|
|
276
|
+
denied: If True, marks this as an explicit denial (not just timeout/disconnect)
|
|
277
|
+
|
|
278
|
+
Returns the removed request if it existed, None otherwise.
|
|
279
|
+
"""
|
|
280
|
+
async with self._lock:
|
|
281
|
+
req = self._requests.pop(token, None)
|
|
282
|
+
if req is None:
|
|
283
|
+
return None
|
|
284
|
+
if denied:
|
|
285
|
+
req.denied = True
|
|
286
|
+
if req.notify and not req.completed:
|
|
287
|
+
try:
|
|
288
|
+
# Pass denied status through a special UUID value (all zeros means denied)
|
|
289
|
+
if denied:
|
|
290
|
+
req.notify(None, UUID(int=0), None, None)
|
|
291
|
+
else:
|
|
292
|
+
req.notify(None, None, None, None)
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
return req
|
|
296
|
+
|
|
297
|
+
def get_connection_count(self) -> int:
|
|
298
|
+
"""Get the current count of open WebSocket connections.
|
|
299
|
+
|
|
300
|
+
This is used to determine PoW difficulty based on load.
|
|
301
|
+
"""
|
|
302
|
+
# Count is maintained externally by the WebSocket endpoints
|
|
303
|
+
return getattr(self, "_ws_count", 0)
|
|
304
|
+
|
|
305
|
+
def increment_connections(self) -> None:
|
|
306
|
+
"""Increment the WebSocket connection counter."""
|
|
307
|
+
self._ws_count = getattr(self, "_ws_count", 0) + 1
|
|
308
|
+
|
|
309
|
+
def decrement_connections(self) -> None:
|
|
310
|
+
"""Decrement the WebSocket connection counter."""
|
|
311
|
+
self._ws_count = max(0, getattr(self, "_ws_count", 0) - 1)
|
|
312
|
+
|
|
313
|
+
def get_pow_difficulty(self) -> int:
|
|
314
|
+
"""Get PoW difficulty based on current WebSocket connection count.
|
|
315
|
+
|
|
316
|
+
Uses NORMAL difficulty with low load (< 10 connections),
|
|
317
|
+
HARD difficulty with high load (>= 10 connections).
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
PoW work units (pow.NORMAL or pow.HARD)
|
|
321
|
+
"""
|
|
322
|
+
from paskia.util import pow
|
|
323
|
+
|
|
324
|
+
count = self.get_connection_count()
|
|
325
|
+
return pow.HARD if count >= 10 else pow.NORMAL
|
|
326
|
+
|
|
327
|
+
async def consume_request(self, token: str) -> RemoteAuthRequest | None:
|
|
328
|
+
"""Get and remove a request (for use by the authenticating device)."""
|
|
329
|
+
if not passphrase.is_well_formed(token, n=passphrase.N_WORDS_SHORT):
|
|
330
|
+
return None
|
|
331
|
+
async with self._lock:
|
|
332
|
+
req = self._requests.get(token)
|
|
333
|
+
if req is None:
|
|
334
|
+
return None
|
|
335
|
+
now = datetime.now(timezone.utc)
|
|
336
|
+
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
|
337
|
+
del self._requests[token]
|
|
338
|
+
return None
|
|
339
|
+
# Don't remove yet - wait until completion
|
|
340
|
+
return req
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# Global instance
|
|
344
|
+
instance: RemoteAuthManager | None = None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def init():
|
|
348
|
+
"""Initialize the global remote auth manager."""
|
|
349
|
+
global instance
|
|
350
|
+
instance = RemoteAuthManager()
|
|
351
|
+
await instance.start()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def shutdown():
|
|
355
|
+
"""Shutdown the global remote auth manager."""
|
|
356
|
+
global instance
|
|
357
|
+
if instance:
|
|
358
|
+
await instance.stop()
|
|
359
|
+
instance = None
|