paskia 0.8.0__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paskia/_version.py +2 -2
- paskia/authsession.py +14 -27
- paskia/bootstrap.py +31 -103
- paskia/config.py +0 -1
- paskia/db/__init__.py +26 -51
- paskia/db/background.py +17 -37
- paskia/db/jsonl.py +168 -6
- paskia/db/migrations.py +34 -0
- paskia/db/operations.py +400 -723
- paskia/db/structs.py +214 -90
- paskia/fastapi/__main__.py +89 -189
- paskia/fastapi/admin.py +103 -162
- paskia/fastapi/api.py +49 -85
- paskia/fastapi/mainapp.py +30 -19
- paskia/fastapi/remote.py +16 -39
- paskia/fastapi/reset.py +27 -17
- paskia/fastapi/session.py +2 -2
- paskia/fastapi/user.py +21 -27
- paskia/fastapi/ws.py +27 -62
- paskia/fastapi/wschat.py +62 -0
- paskia/frontend-build/auth/admin/index.html +5 -5
- paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
- paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
- paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
- paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
- paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
- paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
- paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
- paskia/frontend-build/auth/index.html +5 -5
- paskia/frontend-build/auth/restricted/index.html +4 -4
- paskia/frontend-build/int/forward/index.html +4 -4
- paskia/frontend-build/int/reset/index.html +3 -3
- paskia/globals.py +2 -2
- paskia/migrate/__init__.py +62 -55
- paskia/migrate/sql.py +72 -22
- paskia/remoteauth.py +1 -2
- paskia/sansio.py +6 -12
- {paskia-0.8.0.dist-info → paskia-0.9.0.dist-info}/METADATA +3 -2
- paskia-0.9.0.dist-info/RECORD +57 -0
- paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
- paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
- paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
- paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
- paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
- paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
- paskia/util/frontend.py +0 -75
- paskia/util/hostutil.py +0 -76
- paskia/util/htmlutil.py +0 -47
- paskia/util/passphrase.py +0 -20
- paskia/util/permutil.py +0 -43
- paskia/util/pow.py +0 -45
- paskia/util/querysafe.py +0 -11
- paskia/util/sessionutil.py +0 -38
- paskia/util/startupbox.py +0 -75
- paskia/util/timeutil.py +0 -47
- paskia/util/useragent.py +0 -10
- paskia/util/userinfo.py +0 -145
- paskia/util/wordlist.py +0 -54
- paskia-0.8.0.dist-info/RECORD +0 -68
- {paskia-0.8.0.dist-info → paskia-0.9.0.dist-info}/WHEEL +0 -0
- {paskia-0.8.0.dist-info → paskia-0.9.0.dist-info}/entry_points.txt +0 -0
paskia/fastapi/__main__.py
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import asyncio
|
|
3
|
-
import
|
|
3
|
+
import json
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
6
|
from urllib.parse import urlparse
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
from fastapi_vue.hostutil import parse_endpoint
|
|
9
|
+
from uvicorn import Config, Server
|
|
9
10
|
|
|
11
|
+
from paskia import globals as _globals
|
|
12
|
+
from paskia.bootstrap import bootstrap_if_needed
|
|
13
|
+
from paskia.config import PaskiaConfig
|
|
14
|
+
from paskia.db.background import flush
|
|
15
|
+
from paskia.fastapi import app as fastapi_app
|
|
16
|
+
from paskia.fastapi import reset as reset_cmd
|
|
17
|
+
from paskia.util import startupbox
|
|
10
18
|
from paskia.util.hostutil import normalize_origin
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
DEFAULT_PORT = 4401
|
|
21
|
+
|
|
22
|
+
EPILOG = """\
|
|
23
|
+
Examples:
|
|
24
|
+
paskia # localhost:4401
|
|
25
|
+
paskia :8080 # All interfaces, port 8080
|
|
26
|
+
paskia unix:/tmp/paskia.sock
|
|
27
|
+
paskia reset [user] # Generate passkey reset link
|
|
28
|
+
"""
|
|
14
29
|
|
|
15
30
|
|
|
16
31
|
def is_subdomain(sub: str, domain: str) -> bool:
|
|
@@ -34,80 +49,6 @@ def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
|
|
34
49
|
)
|
|
35
50
|
|
|
36
51
|
|
|
37
|
-
def parse_endpoint(
|
|
38
|
-
value: str | None, default_port: int
|
|
39
|
-
) -> tuple[str | None, int | None, str | None, bool]:
|
|
40
|
-
"""Parse an endpoint using stdlib (urllib.parse, ipaddress).
|
|
41
|
-
|
|
42
|
-
Returns (host, port, uds_path). If uds_path is not None, host/port are None.
|
|
43
|
-
|
|
44
|
-
Supported forms:
|
|
45
|
-
- host[:port]
|
|
46
|
-
- :port (uses default host)
|
|
47
|
-
- [ipv6][:port] (bracketed for port usage)
|
|
48
|
-
- ipv6 (unbracketed, no port allowed -> default port)
|
|
49
|
-
- unix:/path/to/socket.sock
|
|
50
|
-
- None -> defaults (localhost:4401)
|
|
51
|
-
|
|
52
|
-
Notes:
|
|
53
|
-
- For IPv6 with an explicit port you MUST use brackets (e.g. [::1]:8080)
|
|
54
|
-
- Unbracketed IPv6 like ::1 implies the default port.
|
|
55
|
-
"""
|
|
56
|
-
if not value:
|
|
57
|
-
return DEFAULT_HOST, default_port, None, False
|
|
58
|
-
|
|
59
|
-
# Port only (numeric) -> localhost:port
|
|
60
|
-
if value.isdigit():
|
|
61
|
-
try:
|
|
62
|
-
port_only = int(value)
|
|
63
|
-
except ValueError: # pragma: no cover (isdigit guards)
|
|
64
|
-
raise SystemExit(f"Invalid port '{value}'")
|
|
65
|
-
return DEFAULT_HOST, port_only, None, False
|
|
66
|
-
|
|
67
|
-
# Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
|
|
68
|
-
if value.startswith(":") and value != ":":
|
|
69
|
-
port_part = value[1:]
|
|
70
|
-
if not port_part.isdigit():
|
|
71
|
-
raise SystemExit(f"Invalid port in '{value}'")
|
|
72
|
-
return None, int(port_part), None, True
|
|
73
|
-
|
|
74
|
-
# UNIX domain socket
|
|
75
|
-
if value.startswith("unix:"):
|
|
76
|
-
uds_path = value[5:] or None
|
|
77
|
-
if uds_path is None:
|
|
78
|
-
raise SystemExit("unix: path must not be empty")
|
|
79
|
-
return None, None, uds_path, False
|
|
80
|
-
|
|
81
|
-
# Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons
|
|
82
|
-
if value.count(":") > 1 and not value.startswith("["):
|
|
83
|
-
try:
|
|
84
|
-
ipaddress.IPv6Address(value)
|
|
85
|
-
except ValueError as e: # pragma: no cover
|
|
86
|
-
raise SystemExit(f"Invalid IPv6 address '{value}': {e}")
|
|
87
|
-
return value, default_port, None, False
|
|
88
|
-
|
|
89
|
-
# Use urllib.parse for everything else (host[:port], :port, [ipv6][:port])
|
|
90
|
-
parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
|
|
91
|
-
host = parsed.hostname
|
|
92
|
-
port = parsed.port
|
|
93
|
-
|
|
94
|
-
# Host may be None if empty (e.g. ':5500')
|
|
95
|
-
if not host:
|
|
96
|
-
host = DEFAULT_HOST
|
|
97
|
-
if port is None:
|
|
98
|
-
port = default_port
|
|
99
|
-
|
|
100
|
-
# Validate IP literals (optional; hostname passes through)
|
|
101
|
-
try:
|
|
102
|
-
# Strip brackets if somehow present (urlparse removes them already)
|
|
103
|
-
ipaddress.ip_address(host)
|
|
104
|
-
except ValueError:
|
|
105
|
-
# Not an IP address -> treat as hostname; no action
|
|
106
|
-
pass
|
|
107
|
-
|
|
108
|
-
return host, port, None, False
|
|
109
|
-
|
|
110
|
-
|
|
111
52
|
def add_common_options(p: argparse.ArgumentParser) -> None:
|
|
112
53
|
p.add_argument(
|
|
113
54
|
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
|
@@ -134,45 +75,44 @@ def main():
|
|
|
134
75
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
|
135
76
|
|
|
136
77
|
parser = argparse.ArgumentParser(
|
|
137
|
-
prog="paskia",
|
|
78
|
+
prog="paskia",
|
|
79
|
+
description="Paskia authentication server",
|
|
80
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
81
|
+
epilog=EPILOG,
|
|
138
82
|
)
|
|
139
|
-
sub = parser.add_subparsers(dest="command", required=True)
|
|
140
83
|
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
"serve", help="Run the server (production style, no auto-reload)"
|
|
144
|
-
)
|
|
145
|
-
serve.add_argument(
|
|
84
|
+
# Primary argument: either host:port or "reset" subcommand
|
|
85
|
+
parser.add_argument(
|
|
146
86
|
"hostport",
|
|
147
87
|
nargs="?",
|
|
148
88
|
help=(
|
|
149
89
|
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
|
|
150
|
-
"[ipv6][:port] | ipv6 | unix:/path.sock"
|
|
151
|
-
),
|
|
152
|
-
)
|
|
153
|
-
add_common_options(serve)
|
|
154
|
-
|
|
155
|
-
# reset subcommand
|
|
156
|
-
reset = sub.add_parser(
|
|
157
|
-
"reset",
|
|
158
|
-
help=(
|
|
159
|
-
"Create a credential reset link for a user. Provide part of the display name or UUID. "
|
|
160
|
-
"If omitted, targets the master admin (first Administration role user in an auth:admin org)."
|
|
90
|
+
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
|
|
161
91
|
),
|
|
162
92
|
)
|
|
163
|
-
|
|
164
|
-
"
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"reset_query",
|
|
165
95
|
nargs="?",
|
|
166
|
-
help="
|
|
96
|
+
help="For 'reset' command: user UUID or substring of display name",
|
|
167
97
|
)
|
|
168
|
-
add_common_options(
|
|
98
|
+
add_common_options(parser)
|
|
169
99
|
|
|
170
100
|
args = parser.parse_args()
|
|
171
101
|
|
|
172
|
-
|
|
173
|
-
|
|
102
|
+
# Detect "reset" subcommand (first positional is "reset")
|
|
103
|
+
is_reset = args.hostport == "reset"
|
|
104
|
+
|
|
105
|
+
if is_reset:
|
|
106
|
+
endpoints = []
|
|
174
107
|
else:
|
|
175
|
-
|
|
108
|
+
# Parse endpoint using fastapi_vue.hostutil
|
|
109
|
+
endpoints = parse_endpoint(args.hostport, DEFAULT_PORT)
|
|
110
|
+
|
|
111
|
+
# Extract host/port/uds from first endpoint for config display and site_url
|
|
112
|
+
ep = endpoints[0] if endpoints else {}
|
|
113
|
+
host = ep.get("host")
|
|
114
|
+
port = ep.get("port")
|
|
115
|
+
uds = ep.get("uds")
|
|
176
116
|
|
|
177
117
|
# Collect and normalize origins, handle auth_host
|
|
178
118
|
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
|
@@ -193,8 +133,13 @@ def main():
|
|
|
193
133
|
origins = [x for x in origins if not (x in seen or seen.add(x))]
|
|
194
134
|
|
|
195
135
|
# Compute site_url and site_path for reset links
|
|
196
|
-
# Priority: auth_host > first origin with localhost > http://localhost:port
|
|
197
|
-
|
|
136
|
+
# Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
|
|
137
|
+
explicit_site_url = os.environ.get("PASKIA_SITE_URL")
|
|
138
|
+
if explicit_site_url:
|
|
139
|
+
# Explicit site URL from devserver or deployment config
|
|
140
|
+
site_url = explicit_site_url.rstrip("/")
|
|
141
|
+
site_path = "/" if args.auth_host else "/auth/"
|
|
142
|
+
elif args.auth_host:
|
|
198
143
|
site_url = args.auth_host.rstrip("/")
|
|
199
144
|
site_path = "/"
|
|
200
145
|
elif origins:
|
|
@@ -215,8 +160,6 @@ def main():
|
|
|
215
160
|
site_path = "/auth/"
|
|
216
161
|
|
|
217
162
|
# Build runtime configuration
|
|
218
|
-
from paskia.config import PaskiaConfig
|
|
219
|
-
|
|
220
163
|
config = PaskiaConfig(
|
|
221
164
|
rp_id=args.rp_id,
|
|
222
165
|
rp_name=args.rp_name or None,
|
|
@@ -230,8 +173,6 @@ def main():
|
|
|
230
173
|
)
|
|
231
174
|
|
|
232
175
|
# Export configuration via single JSON env variable for worker processes
|
|
233
|
-
import json
|
|
234
|
-
|
|
235
176
|
config_json = {
|
|
236
177
|
"rp_id": config.rp_id,
|
|
237
178
|
"rp_name": config.rp_name,
|
|
@@ -242,93 +183,52 @@ def main():
|
|
|
242
183
|
}
|
|
243
184
|
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
|
244
185
|
|
|
245
|
-
|
|
246
|
-
|
|
186
|
+
startupbox.print_startup_config(config)
|
|
187
|
+
|
|
188
|
+
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
|
|
247
189
|
|
|
248
|
-
|
|
249
|
-
|
|
190
|
+
run_kwargs: dict = {
|
|
191
|
+
"log_level": "info",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if devmode:
|
|
195
|
+
# Security: dev mode must run on localhost:4402 to prevent
|
|
196
|
+
# accidental public exposure of the Vite dev server
|
|
197
|
+
if host != "localhost" or port != 4402:
|
|
198
|
+
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
|
199
|
+
run_kwargs["reload"] = True
|
|
200
|
+
run_kwargs["reload_dirs"] = ["paskia"]
|
|
201
|
+
# Suppress uvicorn startup messages in dev mode
|
|
202
|
+
run_kwargs["log_level"] = "warning"
|
|
203
|
+
|
|
204
|
+
async def async_main():
|
|
205
|
+
await _globals.init(
|
|
250
206
|
rp_id=config.rp_id,
|
|
251
207
|
rp_name=config.rp_name,
|
|
252
208
|
origins=config.origins,
|
|
253
209
|
bootstrap=False,
|
|
254
210
|
)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if args.command == "reset":
|
|
269
|
-
from paskia.fastapi import reset as reset_cmd # local import
|
|
270
|
-
|
|
271
|
-
exit_code = reset_cmd.run(getattr(args, "query", None))
|
|
272
|
-
raise SystemExit(exit_code)
|
|
273
|
-
|
|
274
|
-
if args.command == "serve":
|
|
275
|
-
run_kwargs: dict = {
|
|
276
|
-
"log_level": "info",
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
# Dev mode: enable reload when PASKIA_DEVMODE is set
|
|
280
|
-
devmode = bool(os.environ.get("PASKIA_DEVMODE"))
|
|
281
|
-
if devmode:
|
|
282
|
-
# Security: dev mode must run on localhost:4402 to prevent
|
|
283
|
-
# accidental public exposure of the Vite dev server
|
|
284
|
-
if host != "localhost" or port != 4402:
|
|
285
|
-
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
|
286
|
-
run_kwargs["reload"] = True
|
|
287
|
-
run_kwargs["reload_dirs"] = ["paskia"]
|
|
288
|
-
# Suppress uvicorn startup messages in dev mode
|
|
289
|
-
run_kwargs["log_level"] = "warning"
|
|
290
|
-
|
|
291
|
-
if uds:
|
|
292
|
-
run_kwargs["uds"] = uds
|
|
293
|
-
else:
|
|
294
|
-
if not all_ifaces:
|
|
295
|
-
run_kwargs["host"] = host
|
|
296
|
-
run_kwargs["port"] = port
|
|
297
|
-
|
|
298
|
-
if all_ifaces and not uds:
|
|
299
|
-
# Dev mode with all interfaces: use simple single-server approach
|
|
300
|
-
if devmode:
|
|
301
|
-
run_kwargs["host"] = "::"
|
|
302
|
-
run_kwargs["port"] = port
|
|
303
|
-
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
|
304
|
-
else:
|
|
305
|
-
# Production: run separate servers for IPv4 and IPv6
|
|
306
|
-
from uvicorn import Config, Server # noqa: E402 local import
|
|
307
|
-
|
|
308
|
-
from paskia.fastapi import (
|
|
309
|
-
app as fastapi_app, # noqa: E402 local import
|
|
310
|
-
)
|
|
311
|
-
|
|
312
|
-
async def serve_both():
|
|
313
|
-
servers = []
|
|
314
|
-
assert port is not None
|
|
315
|
-
for h in ("0.0.0.0", "::"):
|
|
316
|
-
try:
|
|
317
|
-
cfg = Config(
|
|
318
|
-
app=fastapi_app,
|
|
319
|
-
host=h,
|
|
320
|
-
port=port,
|
|
321
|
-
log_level="info",
|
|
322
|
-
)
|
|
323
|
-
servers.append(Server(cfg))
|
|
324
|
-
except Exception as e: # pragma: no cover
|
|
325
|
-
logging.warning(f"Failed to configure server for {h}: {e}")
|
|
326
|
-
tasks = [asyncio.create_task(s.serve()) for s in servers]
|
|
327
|
-
await asyncio.gather(*tasks)
|
|
328
|
-
|
|
329
|
-
asyncio.run(serve_both())
|
|
211
|
+
await bootstrap_if_needed()
|
|
212
|
+
await flush()
|
|
213
|
+
|
|
214
|
+
if is_reset:
|
|
215
|
+
exit_code = reset_cmd.run(args.reset_query)
|
|
216
|
+
raise SystemExit(exit_code)
|
|
217
|
+
|
|
218
|
+
if len(endpoints) > 1:
|
|
219
|
+
async with asyncio.TaskGroup() as tg:
|
|
220
|
+
for ep in endpoints:
|
|
221
|
+
tg.create_task(
|
|
222
|
+
Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
|
|
223
|
+
)
|
|
330
224
|
else:
|
|
331
|
-
|
|
225
|
+
server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
|
|
226
|
+
await server.serve()
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
asyncio.run(async_main())
|
|
230
|
+
except KeyboardInterrupt:
|
|
231
|
+
pass
|
|
332
232
|
|
|
333
233
|
|
|
334
234
|
if __name__ == "__main__":
|