More robust server startup, startup logo and info screen, renewed devmode script.
This commit is contained in:
+44
-15
@@ -111,7 +111,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||
)
|
||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
|
||||
p.add_argument(
|
||||
"--origin",
|
||||
action="append",
|
||||
dest="origins",
|
||||
metavar="URL",
|
||||
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--auth-host",
|
||||
help=(
|
||||
@@ -166,39 +172,62 @@ def main():
|
||||
else:
|
||||
host = port = uds = all_ifaces = None # type: ignore
|
||||
|
||||
# Export configuration via environment for lifespan initialization in each process
|
||||
os.environ.setdefault("PASKIA_RP_ID", args.rp_id)
|
||||
if args.rp_name:
|
||||
os.environ["PASKIA_RP_NAME"] = args.rp_name
|
||||
if args.origin:
|
||||
os.environ["PASKIA_ORIGIN"] = args.origin
|
||||
if getattr(args, "auth_host", None):
|
||||
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
|
||||
else:
|
||||
# Collect origins and handle auth_host
|
||||
origins = getattr(args, "origins", None) or []
|
||||
if not getattr(args, "auth_host", None):
|
||||
# Preserve pre-set env variable if CLI option omitted
|
||||
args.auth_host = os.environ.get("PASKIA_AUTH_HOST")
|
||||
|
||||
if args.auth_host:
|
||||
# Normalize auth_host with scheme
|
||||
if "://" not in args.auth_host:
|
||||
args.auth_host = f"https://{args.auth_host}"
|
||||
|
||||
validate_auth_host(args.auth_host, args.rp_id)
|
||||
from paskia.util import hostutil as _hostutil # local import
|
||||
|
||||
_hostutil.reload_config()
|
||||
|
||||
# If origins are configured, ensure auth_host is included at top
|
||||
if origins:
|
||||
# Insert auth_host at the beginning (Passkey.__init__ will normalize/dedupe)
|
||||
origins.insert(0, args.auth_host)
|
||||
|
||||
# Export configuration via single JSON env variable for worker processes
|
||||
# (PASKIA_DEVMODE is kept separate as it's externally defined)
|
||||
# All keys are always present; None is used where no value is configured
|
||||
import json
|
||||
|
||||
config = {
|
||||
"rp_id": args.rp_id,
|
||||
"rp_name": args.rp_name or None,
|
||||
"origins": origins or None,
|
||||
"auth_host": args.auth_host or None,
|
||||
"default_admin": os.getenv("PASKIA_DEFAULT_ADMIN") or None,
|
||||
"default_org": os.getenv("PASKIA_DEFAULT_ORG") or None,
|
||||
}
|
||||
os.environ["PASKIA_CONFIG"] = json.dumps(config)
|
||||
|
||||
# One-time initialization + bootstrap before starting any server processes.
|
||||
# Lifespan in worker processes will call globals.init with bootstrap disabled.
|
||||
from paskia import globals as _globals # local import
|
||||
|
||||
asyncio.run(
|
||||
_globals.init(
|
||||
rp_id=args.rp_id,
|
||||
rp_name=args.rp_name,
|
||||
origin=args.origin,
|
||||
default_admin=os.getenv("PASKIA_DEFAULT_ADMIN") or None,
|
||||
default_org=os.getenv("PASKIA_DEFAULT_ORG") or None,
|
||||
rp_id=config["rp_id"],
|
||||
rp_name=config["rp_name"],
|
||||
origins=config["origins"],
|
||||
default_admin=config["default_admin"],
|
||||
default_org=config["default_org"],
|
||||
bootstrap=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Print startup configuration
|
||||
from paskia.util import startupbox
|
||||
|
||||
startupbox.print_startup_config(_globals.passkey.instance, args, host, port, uds)
|
||||
|
||||
# Handle recover-admin command (no server start)
|
||||
if args.command == "reset":
|
||||
from paskia.fastapi import reset as reset_cmd # local import
|
||||
|
||||
+11
-13
@@ -15,26 +15,24 @@ from paskia.util import frontend, hostutil, passphrase
|
||||
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
"""Application lifespan to ensure globals (DB, passkey) are initialized in each process.
|
||||
|
||||
We populate configuration from environment variables (set by the CLI entrypoint)
|
||||
Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint)
|
||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
||||
"""
|
||||
import json
|
||||
|
||||
from paskia import globals
|
||||
|
||||
rp_id = os.getenv("PASKIA_RP_ID", "localhost")
|
||||
rp_name = os.getenv("PASKIA_RP_NAME") or None
|
||||
origin = os.getenv("PASKIA_ORIGIN") or None
|
||||
default_admin = (
|
||||
os.getenv("PASKIA_DEFAULT_ADMIN") or None
|
||||
) # still passed for context
|
||||
default_org = os.getenv("PASKIA_DEFAULT_ORG") or None
|
||||
config = json.loads(os.environ["PASKIA_CONFIG"])
|
||||
|
||||
try:
|
||||
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
||||
await globals.init(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
origin=origin,
|
||||
default_admin=default_admin,
|
||||
default_org=default_org,
|
||||
rp_id=config["rp_id"],
|
||||
rp_name=config["rp_name"],
|
||||
origins=config["origins"],
|
||||
default_admin=config["default_admin"],
|
||||
default_org=config["default_org"],
|
||||
bootstrap=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
|
||||
+17
-6
@@ -42,19 +42,30 @@ def websocket_error_handler(func):
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def _validate_origin(ws: WebSocket) -> str:
|
||||
"""Extract and validate origin from WebSocket request headers.
|
||||
|
||||
Raises:
|
||||
ValueError: If origin header is missing or not in allowed list
|
||||
"""
|
||||
origin = ws.headers.get("origin")
|
||||
if not origin:
|
||||
raise ValueError("Origin header is required for WebSocket connections")
|
||||
return passkey.instance.validate_origin(origin)
|
||||
|
||||
|
||||
async def register_chat(
|
||||
ws: WebSocket,
|
||||
user_uuid: UUID,
|
||||
user_name: str,
|
||||
origin: str,
|
||||
credential_ids: list[bytes] | None = None,
|
||||
origin: str | None = None,
|
||||
):
|
||||
"""Generate registration options and send them to the client."""
|
||||
options, challenge = passkey.instance.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
origin=origin,
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
response = await ws.receive_json()
|
||||
@@ -75,7 +86,7 @@ async def websocket_register_add(
|
||||
- Normal session via auth cookie (requires recent authentication)
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = ws.headers["origin"]
|
||||
origin = _validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
@@ -100,7 +111,7 @@ async def websocket_register_add(
|
||||
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
|
||||
credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
|
||||
|
||||
# Create a new session and store everything in database
|
||||
token = create_token()
|
||||
@@ -131,7 +142,7 @@ async def websocket_register_add(
|
||||
@app.websocket("/authenticate")
|
||||
@websocket_error_handler
|
||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
origin = ws.headers["origin"]
|
||||
origin = _validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
@@ -166,7 +177,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
raise ValueError("This passkey belongs to a different account")
|
||||
|
||||
# Verify the credential matches the stored data
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
|
||||
# Update both credential and user's last_seen timestamp
|
||||
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user