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,335 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import ipaddress
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from paskia.util.hostutil import normalize_origin
|
|
11
|
+
|
|
12
|
+
DEFAULT_HOST = "localhost"
|
|
13
|
+
DEFAULT_SERVE_PORT = 4401
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_subdomain(sub: str, domain: str) -> bool:
|
|
17
|
+
"""Check if sub is a subdomain of domain (or equal)."""
|
|
18
|
+
sub_parts = sub.lower().split(".")
|
|
19
|
+
domain_parts = domain.lower().split(".")
|
|
20
|
+
if len(sub_parts) < len(domain_parts):
|
|
21
|
+
return False
|
|
22
|
+
return sub_parts[-len(domain_parts) :] == domain_parts
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
|
26
|
+
"""Validate that auth_host is a subdomain of rp_id."""
|
|
27
|
+
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
|
28
|
+
host = parsed.hostname or parsed.path
|
|
29
|
+
if not host:
|
|
30
|
+
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
|
|
31
|
+
if not is_subdomain(host, rp_id):
|
|
32
|
+
raise SystemExit(
|
|
33
|
+
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
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
|
+
def add_common_options(p: argparse.ArgumentParser) -> None:
|
|
112
|
+
p.add_argument(
|
|
113
|
+
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
|
114
|
+
)
|
|
115
|
+
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
|
116
|
+
p.add_argument(
|
|
117
|
+
"--origin",
|
|
118
|
+
action="append",
|
|
119
|
+
dest="origins",
|
|
120
|
+
metavar="URL",
|
|
121
|
+
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
|
122
|
+
)
|
|
123
|
+
p.add_argument(
|
|
124
|
+
"--auth-host",
|
|
125
|
+
help=(
|
|
126
|
+
"Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
|
|
127
|
+
" e.g. auth.example.com or https://auth.example.com"
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main():
|
|
133
|
+
# Configure logging to remove the "ERROR:root:" prefix
|
|
134
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
|
135
|
+
|
|
136
|
+
parser = argparse.ArgumentParser(
|
|
137
|
+
prog="paskia", description="Paskia authentication server"
|
|
138
|
+
)
|
|
139
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
140
|
+
|
|
141
|
+
# serve subcommand
|
|
142
|
+
serve = sub.add_parser(
|
|
143
|
+
"serve", help="Run the server (production style, no auto-reload)"
|
|
144
|
+
)
|
|
145
|
+
serve.add_argument(
|
|
146
|
+
"hostport",
|
|
147
|
+
nargs="?",
|
|
148
|
+
help=(
|
|
149
|
+
"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)."
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
reset.add_argument(
|
|
164
|
+
"query",
|
|
165
|
+
nargs="?",
|
|
166
|
+
help="User UUID (full) or case-insensitive substring of display name. If omitted, master admin is used.",
|
|
167
|
+
)
|
|
168
|
+
add_common_options(reset)
|
|
169
|
+
|
|
170
|
+
args = parser.parse_args()
|
|
171
|
+
|
|
172
|
+
if args.command == "serve":
|
|
173
|
+
host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
|
|
174
|
+
else:
|
|
175
|
+
host = port = uds = all_ifaces = None # type: ignore
|
|
176
|
+
|
|
177
|
+
# Collect and normalize origins, handle auth_host
|
|
178
|
+
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
|
179
|
+
if args.auth_host:
|
|
180
|
+
# Normalize auth_host with scheme
|
|
181
|
+
if "://" not in args.auth_host:
|
|
182
|
+
args.auth_host = f"https://{args.auth_host}"
|
|
183
|
+
|
|
184
|
+
validate_auth_host(args.auth_host, args.rp_id)
|
|
185
|
+
|
|
186
|
+
# If origins are configured, ensure auth_host is included at top
|
|
187
|
+
if origins:
|
|
188
|
+
# Insert auth_host at the beginning
|
|
189
|
+
origins.insert(0, args.auth_host)
|
|
190
|
+
|
|
191
|
+
# Remove duplicates while preserving order
|
|
192
|
+
seen = set()
|
|
193
|
+
origins = [x for x in origins if not (x in seen or seen.add(x))]
|
|
194
|
+
|
|
195
|
+
# Compute site_url and site_path for reset links
|
|
196
|
+
# Priority: auth_host > first origin with localhost > http://localhost:port
|
|
197
|
+
if args.auth_host:
|
|
198
|
+
site_url = args.auth_host.rstrip("/")
|
|
199
|
+
site_path = "/"
|
|
200
|
+
elif origins:
|
|
201
|
+
# Find localhost origin if rp_id is localhost, else use first origin
|
|
202
|
+
localhost_origin = (
|
|
203
|
+
next((o for o in origins if "://localhost" in o), None)
|
|
204
|
+
if args.rp_id == "localhost"
|
|
205
|
+
else None
|
|
206
|
+
)
|
|
207
|
+
site_url = (localhost_origin or origins[0]).rstrip("/")
|
|
208
|
+
site_path = "/auth/"
|
|
209
|
+
elif args.rp_id == "localhost" and port:
|
|
210
|
+
# Dev mode: use http with port
|
|
211
|
+
site_url = f"http://localhost:{port}"
|
|
212
|
+
site_path = "/auth/"
|
|
213
|
+
else:
|
|
214
|
+
site_url = f"https://{args.rp_id}"
|
|
215
|
+
site_path = "/auth/"
|
|
216
|
+
|
|
217
|
+
# Build runtime configuration
|
|
218
|
+
from paskia.config import PaskiaConfig
|
|
219
|
+
|
|
220
|
+
config = PaskiaConfig(
|
|
221
|
+
rp_id=args.rp_id,
|
|
222
|
+
rp_name=args.rp_name or None,
|
|
223
|
+
origins=origins or None,
|
|
224
|
+
auth_host=args.auth_host or None,
|
|
225
|
+
site_url=site_url,
|
|
226
|
+
site_path=site_path,
|
|
227
|
+
host=host,
|
|
228
|
+
port=port,
|
|
229
|
+
uds=uds,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Export configuration via single JSON env variable for worker processes
|
|
233
|
+
import json
|
|
234
|
+
|
|
235
|
+
config_json = {
|
|
236
|
+
"rp_id": config.rp_id,
|
|
237
|
+
"rp_name": config.rp_name,
|
|
238
|
+
"origins": config.origins,
|
|
239
|
+
"auth_host": config.auth_host,
|
|
240
|
+
"site_url": config.site_url,
|
|
241
|
+
"site_path": config.site_path,
|
|
242
|
+
}
|
|
243
|
+
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
|
244
|
+
|
|
245
|
+
# Initialize globals (without bootstrap yet)
|
|
246
|
+
from paskia import globals as _globals # local import
|
|
247
|
+
|
|
248
|
+
asyncio.run(
|
|
249
|
+
_globals.init(
|
|
250
|
+
rp_id=config.rp_id,
|
|
251
|
+
rp_name=config.rp_name,
|
|
252
|
+
origins=config.origins,
|
|
253
|
+
bootstrap=False,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Print startup configuration
|
|
258
|
+
from paskia.util import startupbox
|
|
259
|
+
|
|
260
|
+
startupbox.print_startup_config(config)
|
|
261
|
+
|
|
262
|
+
# Bootstrap after startup box is printed
|
|
263
|
+
from paskia.bootstrap import bootstrap_if_needed
|
|
264
|
+
|
|
265
|
+
asyncio.run(bootstrap_if_needed())
|
|
266
|
+
|
|
267
|
+
# Handle recover-admin command (no server start)
|
|
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())
|
|
330
|
+
else:
|
|
331
|
+
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
main()
|