Project renamed to Paskia.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from paskia.fastapi.mainapp import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -0,0 +1,268 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import uvicorn
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_SERVE_PORT = 4401
|
||||
|
||||
|
||||
def is_subdomain(sub: str, domain: str) -> bool:
|
||||
"""Check if sub is a subdomain of domain (or equal)."""
|
||||
sub_parts = sub.lower().split(".")
|
||||
domain_parts = domain.lower().split(".")
|
||||
if len(sub_parts) < len(domain_parts):
|
||||
return False
|
||||
return sub_parts[-len(domain_parts) :] == domain_parts
|
||||
|
||||
|
||||
def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
||||
"""Validate that auth_host is a subdomain of rp_id."""
|
||||
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||
host = parsed.hostname or parsed.path
|
||||
if not host:
|
||||
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
|
||||
if not is_subdomain(host, rp_id):
|
||||
raise SystemExit(
|
||||
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
|
||||
)
|
||||
|
||||
|
||||
def parse_endpoint(
|
||||
value: str | None, default_port: int
|
||||
) -> tuple[str | None, int | None, str | None, bool]:
|
||||
"""Parse an endpoint using stdlib (urllib.parse, ipaddress).
|
||||
|
||||
Returns (host, port, uds_path). If uds_path is not None, host/port are None.
|
||||
|
||||
Supported forms:
|
||||
- host[:port]
|
||||
- :port (uses default host)
|
||||
- [ipv6][:port] (bracketed for port usage)
|
||||
- ipv6 (unbracketed, no port allowed -> default port)
|
||||
- unix:/path/to/socket.sock
|
||||
- None -> defaults (localhost:4401)
|
||||
|
||||
Notes:
|
||||
- For IPv6 with an explicit port you MUST use brackets (e.g. [::1]:8080)
|
||||
- Unbracketed IPv6 like ::1 implies the default port.
|
||||
"""
|
||||
if not value:
|
||||
return DEFAULT_HOST, default_port, None, False
|
||||
|
||||
# Port only (numeric) -> localhost:port
|
||||
if value.isdigit():
|
||||
try:
|
||||
port_only = int(value)
|
||||
except ValueError: # pragma: no cover (isdigit guards)
|
||||
raise SystemExit(f"Invalid port '{value}'")
|
||||
return DEFAULT_HOST, port_only, None, False
|
||||
|
||||
# Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
|
||||
if value.startswith(":") and value != ":":
|
||||
port_part = value[1:]
|
||||
if not port_part.isdigit():
|
||||
raise SystemExit(f"Invalid port in '{value}'")
|
||||
return None, int(port_part), None, True
|
||||
|
||||
# UNIX domain socket
|
||||
if value.startswith("unix:"):
|
||||
uds_path = value[5:] or None
|
||||
if uds_path is None:
|
||||
raise SystemExit("unix: path must not be empty")
|
||||
return None, None, uds_path, False
|
||||
|
||||
# Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons
|
||||
if value.count(":") > 1 and not value.startswith("["):
|
||||
try:
|
||||
ipaddress.IPv6Address(value)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise SystemExit(f"Invalid IPv6 address '{value}': {e}")
|
||||
return value, default_port, None, False
|
||||
|
||||
# Use urllib.parse for everything else (host[:port], :port, [ipv6][:port])
|
||||
parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
|
||||
host = parsed.hostname
|
||||
port = parsed.port
|
||||
|
||||
# Host may be None if empty (e.g. ':5500')
|
||||
if not host:
|
||||
host = DEFAULT_HOST
|
||||
if port is None:
|
||||
port = default_port
|
||||
|
||||
# Validate IP literals (optional; hostname passes through)
|
||||
try:
|
||||
# Strip brackets if somehow present (urlparse removes them already)
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
# Not an IP address -> treat as hostname; no action
|
||||
pass
|
||||
|
||||
return host, port, None, False
|
||||
|
||||
|
||||
def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--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(
|
||||
"--auth-host",
|
||||
help=(
|
||||
"Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
|
||||
" e.g. auth.example.com or https://auth.example.com"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Configure logging to remove the "ERROR:root:" prefix
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="paskia", description="Paskia authentication server"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# serve subcommand
|
||||
serve = sub.add_parser(
|
||||
"serve", help="Run the server (production style, no auto-reload)"
|
||||
)
|
||||
serve.add_argument(
|
||||
"hostport",
|
||||
nargs="?",
|
||||
help=(
|
||||
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
|
||||
"[ipv6][:port] | ipv6 | unix:/path.sock"
|
||||
),
|
||||
)
|
||||
add_common_options(serve)
|
||||
|
||||
# reset subcommand
|
||||
reset = sub.add_parser(
|
||||
"reset",
|
||||
help=(
|
||||
"Create a credential reset link for a user. Provide part of the display name or UUID. "
|
||||
"If omitted, targets the master admin (first Administration role user in an auth:admin org)."
|
||||
),
|
||||
)
|
||||
reset.add_argument(
|
||||
"query",
|
||||
nargs="?",
|
||||
help="User UUID (full) or case-insensitive substring of display name. If omitted, master admin is used.",
|
||||
)
|
||||
add_common_options(reset)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "serve":
|
||||
host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
|
||||
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:
|
||||
# Preserve pre-set env variable if CLI option omitted
|
||||
args.auth_host = os.environ.get("PASKIA_AUTH_HOST")
|
||||
|
||||
if args.auth_host:
|
||||
validate_auth_host(args.auth_host, args.rp_id)
|
||||
from paskia.util import hostutil as _hostutil # local import
|
||||
|
||||
_hostutil.reload_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,
|
||||
bootstrap=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Handle recover-admin command (no server start)
|
||||
if args.command == "reset":
|
||||
from paskia.fastapi import reset as reset_cmd # local import
|
||||
|
||||
exit_code = reset_cmd.run(getattr(args, "query", None))
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
if args.command == "serve":
|
||||
run_kwargs: dict = {
|
||||
"log_level": "info",
|
||||
}
|
||||
|
||||
# Dev mode: enable reload when PASKIA_DEVMODE is set
|
||||
devmode = os.environ.get("PASKIA_DEVMODE") == "1"
|
||||
if devmode:
|
||||
# Security: dev mode must run on localhost:4402 to prevent
|
||||
# accidental public exposure of the Vite dev server
|
||||
if host != "localhost" or port != 4402:
|
||||
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
||||
run_kwargs["reload"] = True
|
||||
run_kwargs["reload_dirs"] = ["paskia"]
|
||||
|
||||
if uds:
|
||||
run_kwargs["uds"] = uds
|
||||
else:
|
||||
if not all_ifaces:
|
||||
run_kwargs["host"] = host
|
||||
run_kwargs["port"] = port
|
||||
|
||||
if all_ifaces and not uds:
|
||||
# Dev mode with all interfaces: use simple single-server approach
|
||||
if devmode:
|
||||
run_kwargs["host"] = "::"
|
||||
run_kwargs["port"] = port
|
||||
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||
else:
|
||||
# Production: run separate servers for IPv4 and IPv6
|
||||
from uvicorn import Config, Server # noqa: E402 local import
|
||||
|
||||
from paskia.fastapi import (
|
||||
app as fastapi_app, # noqa: E402 local import
|
||||
)
|
||||
|
||||
async def serve_both():
|
||||
servers = []
|
||||
assert port is not None
|
||||
for h in ("0.0.0.0", "::"):
|
||||
try:
|
||||
cfg = Config(
|
||||
app=fastapi_app,
|
||||
host=h,
|
||||
port=port,
|
||||
log_level="info",
|
||||
)
|
||||
servers.append(Server(cfg))
|
||||
except Exception as e: # pragma: no cover
|
||||
logging.warning(f"Failed to configure server for {h}: {e}")
|
||||
tasks = [asyncio.create_task(s.serve()) for s in servers]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
asyncio.run(serve_both())
|
||||
else:
|
||||
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,849 @@
|
||||
import logging
|
||||
from datetime import timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import db
|
||||
from paskia.util import (
|
||||
frontend,
|
||||
hostutil,
|
||||
passphrase,
|
||||
permutil,
|
||||
querysafe,
|
||||
tokens,
|
||||
useragent,
|
||||
)
|
||||
from paskia.util.tokens import encode_session_key, session_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(_request, exc: ValueError): # pragma: no cover - simple
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=await authz.auth_error_content(exc),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(_request, exc: Exception):
|
||||
logging.exception("Unhandled exception in admin app")
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
||||
return Response(*await frontend.read("/auth/admin/index.html"))
|
||||
|
||||
|
||||
# -------------------- Organizations --------------------
|
||||
|
||||
|
||||
@app.get("/orgs")
|
||||
async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:*"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
orgs = await db.instance.list_organizations()
|
||||
if "auth:admin" not in ctx.role.permissions:
|
||||
orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
|
||||
|
||||
def role_to_dict(r):
|
||||
return {
|
||||
"uuid": str(r.uuid),
|
||||
"org_uuid": str(r.org_uuid),
|
||||
"display_name": r.display_name,
|
||||
"permissions": r.permissions,
|
||||
}
|
||||
|
||||
async def org_to_dict(o):
|
||||
users = await db.instance.get_organization_users(str(o.uuid))
|
||||
return {
|
||||
"uuid": str(o.uuid),
|
||||
"display_name": o.display_name,
|
||||
"permissions": o.permissions,
|
||||
"roles": [role_to_dict(r) for r in o.roles],
|
||||
"users": [
|
||||
{
|
||||
"uuid": str(u.uuid),
|
||||
"display_name": u.display_name,
|
||||
"role": role_name,
|
||||
"visits": u.visits,
|
||||
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
|
||||
}
|
||||
for (u, role_name) in users
|
||||
],
|
||||
}
|
||||
|
||||
return [await org_to_dict(o) for o in orgs]
|
||||
|
||||
|
||||
@app.post("/orgs")
|
||||
async def admin_create_org(
|
||||
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
from ..db import Role as RoleDC # local import to avoid cycles
|
||||
|
||||
org_uuid = uuid4()
|
||||
display_name = payload.get("display_name") or "New Organization"
|
||||
permissions = payload.get("permissions") or []
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
await db.instance.create_organization(org)
|
||||
|
||||
# Automatically create Administration role with org admin permission
|
||||
role_uuid = uuid4()
|
||||
admin_role = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
display_name="Administration",
|
||||
permissions=[f"auth:org:{org_uuid}"],
|
||||
)
|
||||
await db.instance.create_role(admin_role)
|
||||
|
||||
return {"uuid": str(org_uuid)}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}")
|
||||
async def admin_update_org(
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
|
||||
current = await db.instance.get_organization(str(org_uuid))
|
||||
display_name = payload.get("display_name") or current.display_name
|
||||
permissions = payload.get("permissions") or current.permissions or []
|
||||
|
||||
# Sanity check: prevent removing permissions that would break current user's admin access
|
||||
org_admin_perm = f"auth:org:{org_uuid}"
|
||||
|
||||
# If current user is org admin (not global admin), ensure org admin perm remains
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" in ctx.role.permissions
|
||||
):
|
||||
if org_admin_perm not in permissions:
|
||||
raise ValueError(
|
||||
"Cannot remove organization admin permission from your own organization"
|
||||
)
|
||||
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
await db.instance.update_organization(org)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}")
|
||||
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if ctx.org.uuid == org_uuid:
|
||||
raise ValueError("Cannot delete the organization you belong to")
|
||||
|
||||
# Delete organization-specific permissions
|
||||
org_perm_pattern = f"org:{str(org_uuid).lower()}"
|
||||
all_permissions = await db.instance.list_permissions()
|
||||
for perm in all_permissions:
|
||||
perm_id_lower = perm.id.lower()
|
||||
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
|
||||
if (
|
||||
f":{org_perm_pattern}:" in perm_id_lower
|
||||
or perm_id_lower.startswith(f"{org_perm_pattern}:")
|
||||
or perm_id_lower.endswith(f":{org_perm_pattern}")
|
||||
or perm_id_lower == org_perm_pattern
|
||||
):
|
||||
await db.instance.delete_permission(perm.id)
|
||||
|
||||
await db.instance.delete_organization(org_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/permission")
|
||||
async def admin_add_org_permission(
|
||||
org_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/permission")
|
||||
async def admin_remove_org_permission(
|
||||
org_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# -------------------- Roles --------------------
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/roles")
|
||||
async def admin_create_role(
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
from ..db import Role as RoleDC
|
||||
|
||||
role_uuid = uuid4()
|
||||
display_name = payload.get("display_name") or "New Role"
|
||||
perms = payload.get("permissions") or []
|
||||
org = await db.instance.get_organization(str(org_uuid))
|
||||
grantable = set(org.permissions or [])
|
||||
for pid in perms:
|
||||
await db.instance.get_permission(pid)
|
||||
if pid not in grantable:
|
||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||
role = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
display_name=display_name,
|
||||
permissions=perms,
|
||||
)
|
||||
await db.instance.create_role(role)
|
||||
return {"uuid": str(role_uuid)}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
async def admin_update_role(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
# Verify caller is global admin or admin of provided org
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
role = await db.instance.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
from ..db import Role as RoleDC
|
||||
|
||||
display_name = payload.get("display_name") or role.display_name
|
||||
permissions = payload.get("permissions")
|
||||
if permissions is None:
|
||||
permissions = role.permissions
|
||||
org = await db.instance.get_organization(str(org_uuid))
|
||||
grantable = set(org.permissions or [])
|
||||
existing_permissions = set(role.permissions)
|
||||
for pid in permissions:
|
||||
await db.instance.get_permission(pid)
|
||||
if pid not in existing_permissions and pid not in grantable:
|
||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access via role update
|
||||
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
|
||||
has_admin_access = (
|
||||
"auth:admin" in permissions or f"auth:org:{org_uuid}" in permissions
|
||||
)
|
||||
if not has_admin_access:
|
||||
raise ValueError("Cannot update your own role to remove admin permissions")
|
||||
|
||||
updated = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
display_name=display_name,
|
||||
permissions=permissions,
|
||||
)
|
||||
await db.instance.update_role(updated)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
async def admin_delete_role(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
role = await db.instance.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from deleting their own role
|
||||
if ctx.role.uuid == role_uuid:
|
||||
raise ValueError("Cannot delete your own role")
|
||||
|
||||
await db.instance.delete_role(role_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# -------------------- Users --------------------
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/users")
|
||||
async def admin_create_user(
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
display_name = payload.get("display_name")
|
||||
role_name = payload.get("role")
|
||||
if not display_name or not role_name:
|
||||
raise ValueError("display_name and role are required")
|
||||
from ..db import User as UserDC
|
||||
|
||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
||||
if not role_obj:
|
||||
raise ValueError("Role not found in organization")
|
||||
user_uuid = uuid4()
|
||||
user = UserDC(
|
||||
uuid=user_uuid,
|
||||
display_name=display_name,
|
||||
role_uuid=role_obj.uuid,
|
||||
visits=0,
|
||||
created_at=None,
|
||||
)
|
||||
await db.instance.create_user(user)
|
||||
return {"uuid": str(user_uuid)}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||
async def admin_update_user_role(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
new_role = payload.get("role")
|
||||
if not new_role:
|
||||
raise ValueError("role is required")
|
||||
try:
|
||||
user_org, _current_role = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise ValueError("User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise ValueError("User does not belong to this organization")
|
||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||
if not any(r.display_name == new_role for r in roles):
|
||||
raise ValueError("Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
if ctx.user.uuid == user_uuid:
|
||||
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
||||
if new_role_obj:
|
||||
has_admin_access = (
|
||||
"auth:admin" in new_role_obj.permissions
|
||||
or f"auth:org:{org_uuid}" in new_role_obj.permissions
|
||||
)
|
||||
if not has_admin_access:
|
||||
raise ValueError(
|
||||
"Cannot change your own role to one without admin permissions"
|
||||
)
|
||||
|
||||
await db.instance.update_user_role_in_organization(user_uuid, new_role)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
|
||||
async def admin_create_user_registration_link(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
# Check if user has existing credentials
|
||||
credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
token_type = "user registration" if not credentials else "account recovery"
|
||||
|
||||
token = passphrase.generate()
|
||||
expiry = reset_expires()
|
||||
await db.instance.create_reset_token(
|
||||
user_uuid=user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expiry=expiry,
|
||||
token_type=token_type,
|
||||
)
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
)
|
||||
return {
|
||||
"url": url,
|
||||
"expires": (
|
||||
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
|
||||
async def admin_get_user_detail(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
creds: list[dict] = []
|
||||
aaguids: set[str] = set()
|
||||
for cid in cred_ids:
|
||||
try:
|
||||
c = await db.instance.get_credential_by_id(cid)
|
||||
except ValueError:
|
||||
continue
|
||||
aaguid_str = str(c.aaguid)
|
||||
aaguids.add(aaguid_str)
|
||||
creds.append(
|
||||
{
|
||||
"credential_uuid": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": (
|
||||
c.created_at.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.created_at.tzinfo
|
||||
else c.created_at.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"last_used": (
|
||||
c.last_used.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used and c.last_used.tzinfo
|
||||
else (
|
||||
c.last_used.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_verified": (
|
||||
c.last_verified.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified and c.last_verified.tzinfo
|
||||
else (
|
||||
c.last_verified.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified
|
||||
else None
|
||||
)
|
||||
)
|
||||
if c.last_verified
|
||||
else None,
|
||||
"sign_count": c.sign_count,
|
||||
}
|
||||
)
|
||||
from .. import aaguid as aaguid_mod
|
||||
|
||||
aaguid_info = aaguid_mod.filter(aaguids)
|
||||
|
||||
# Get sessions for the user
|
||||
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
session_records = await db.instance.list_sessions_for_user(user_uuid)
|
||||
current_session_key = session_key(auth)
|
||||
sessions_payload: list[dict] = []
|
||||
for entry in session_records:
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": encode_session_key(entry.key),
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": (
|
||||
entry.renewed.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if entry.renewed.tzinfo
|
||||
else entry.renewed.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"is_current": entry.key == current_session_key,
|
||||
"is_current_host": bool(
|
||||
normalized_request_host
|
||||
and entry.host
|
||||
and entry.host == normalized_request_host
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"display_name": user.display_name,
|
||||
"org": {"display_name": user_org.display_name},
|
||||
"role": role_name,
|
||||
"visits": user.visits,
|
||||
"created_at": (
|
||||
user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if user.created_at and user.created_at.tzinfo
|
||||
else (
|
||||
user.created_at.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if user.created_at
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_seen": (
|
||||
user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if user.last_seen and user.last_seen.tzinfo
|
||||
else (
|
||||
user.last_seen.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if user.last_seen
|
||||
else None
|
||||
)
|
||||
),
|
||||
"credentials": creds,
|
||||
"aaguid_info": aaguid_info,
|
||||
"sessions": sessions_payload,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
||||
async def admin_update_user_display_name(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
if len(new_name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
await db.instance.update_user_display_name(user_uuid, new_name)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
|
||||
async def admin_delete_user_credential(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
await db.instance.delete_credential(credential_uuid, user_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
|
||||
async def admin_delete_user_session(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
session_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
try:
|
||||
target_key = tokens.decode_session_key(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid session identifier"
|
||||
) from exc
|
||||
|
||||
target_session = await db.instance.get_session(target_key)
|
||||
if not target_session or target_session.user_uuid != user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
await db.instance.delete_session(target_key)
|
||||
|
||||
# Check if admin terminated their own session
|
||||
current_terminated = target_key == session_key(auth)
|
||||
return {"status": "ok", "current_session_terminated": current_terminated}
|
||||
|
||||
|
||||
# -------------------- Permissions (global) --------------------
|
||||
|
||||
|
||||
@app.get("/permissions")
|
||||
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:*"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
perms = await db.instance.list_permissions()
|
||||
|
||||
# Global admins see all permissions
|
||||
if "auth:admin" in ctx.role.permissions:
|
||||
return [{"id": p.id, "display_name": p.display_name} for p in perms]
|
||||
|
||||
# Org admins only see permissions their org can grant
|
||||
grantable = set(ctx.org.permissions or [])
|
||||
filtered_perms = [p for p in perms if p.id in grantable]
|
||||
return [{"id": p.id, "display_name": p.display_name} for p in filtered_perms]
|
||||
|
||||
|
||||
@app.post("/permissions")
|
||||
async def admin_create_permission(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
perm_id = payload.get("id")
|
||||
display_name = payload.get("display_name")
|
||||
if not perm_id or not display_name:
|
||||
raise ValueError("id and display_name are required")
|
||||
querysafe.assert_safe(perm_id, field="id")
|
||||
await db.instance.create_permission(PermDC(id=perm_id, display_name=display_name))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.put("/permission")
|
||||
async def admin_update_permission(
|
||||
permission_id: str,
|
||||
display_name: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
await db.instance.update_permission(
|
||||
PermDC(id=permission_id, display_name=display_name)
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/permission/rename")
|
||||
async def admin_rename_permission(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
old_id = payload.get("old_id")
|
||||
new_id = payload.get("new_id")
|
||||
display_name = payload.get("display_name")
|
||||
if not old_id or not new_id:
|
||||
raise ValueError("old_id and new_id required")
|
||||
|
||||
# Sanity check: prevent renaming critical permissions
|
||||
if old_id == "auth:admin":
|
||||
raise ValueError("Cannot rename the master admin permission")
|
||||
|
||||
querysafe.assert_safe(old_id, field="old_id")
|
||||
querysafe.assert_safe(new_id, field="new_id")
|
||||
if display_name is None:
|
||||
perm = await db.instance.get_permission(old_id)
|
||||
display_name = perm.display_name
|
||||
rename_fn = getattr(db.instance, "rename_permission", None)
|
||||
if not rename_fn:
|
||||
raise ValueError("Permission renaming not supported by this backend")
|
||||
await rename_fn(old_id, new_id, display_name)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/permission")
|
||||
async def admin_delete_permission(
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
|
||||
# Sanity check: prevent deleting critical permissions
|
||||
if permission_id == "auth:admin":
|
||||
raise ValueError("Cannot delete the master admin permission")
|
||||
|
||||
await db.instance.delete_permission(permission_id)
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,279 @@
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia.authsession import (
|
||||
EXPIRES,
|
||||
get_reset,
|
||||
get_session,
|
||||
refresh_session_token,
|
||||
session_expiry,
|
||||
)
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import db
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
|
||||
from paskia.util.tokens import session_key
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=True)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.mount("/user", user.app)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(_request: Request, exc: HTTPException):
|
||||
"""Ensure auth cookie is cleared on 401 responses (JSON responses only)."""
|
||||
if exc.status_code == 401:
|
||||
resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
session.clear_session_cookie(resp)
|
||||
return resp
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
|
||||
# Refresh only if at least this much of the session lifetime has been *consumed*.
|
||||
# Consumption is derived from (now + EXPIRES) - current_expires.
|
||||
# This guarantees a minimum spacing between DB writes even with frequent /validate calls.
|
||||
_REFRESH_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(_request: Request, exc: ValueError):
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request: Request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=await authz.auth_error_content(exc),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(_request: Request, exc: Exception):
|
||||
logging.exception("Unhandled exception in API app")
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
|
||||
@app.post("/validate")
|
||||
async def validate_token(
|
||||
request: Request,
|
||||
response: Response,
|
||||
perm: list[str] = Query([]),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Validate the current session and extend its expiry.
|
||||
|
||||
Always refreshes the session (sliding expiration) and re-sets the cookie with a
|
||||
renewed max-age. This keeps active users logged in without needing a separate
|
||||
refresh endpoint.
|
||||
"""
|
||||
try:
|
||||
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
|
||||
except HTTPException:
|
||||
# Global handler will clear cookie if 401
|
||||
raise
|
||||
renewed = False
|
||||
if auth:
|
||||
current_expiry = session_expiry(ctx.session)
|
||||
consumed = EXPIRES - (current_expiry - datetime.now(timezone.utc))
|
||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||
try:
|
||||
await refresh_session_token(
|
||||
auth,
|
||||
ip=request.client.host if request.client else "",
|
||||
user_agent=request.headers.get("user-agent") or "",
|
||||
)
|
||||
session.set_session_cookie(response, auth)
|
||||
renewed = True
|
||||
except ValueError:
|
||||
# Session disappeared, e.g. due to concurrent logout; global handler will clear
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
return {
|
||||
"valid": True,
|
||||
"user_uuid": str(ctx.session.user_uuid),
|
||||
"renewed": renewed,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/forward")
|
||||
async def forward_authentication(
|
||||
request: Request,
|
||||
response: Response,
|
||||
perm: list[str] = Query([]),
|
||||
max_age: str | None = Query(None),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Forward auth validation for Caddy/Nginx.
|
||||
|
||||
Query Params:
|
||||
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
||||
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
|
||||
is older than this, user must re-authenticate.
|
||||
|
||||
Success: 204 No Content with Remote-* headers describing the authenticated user.
|
||||
Failure (unauthenticated / unauthorized): 4xx response.
|
||||
- If Accept header contains "text/html": HTML page for authentication
|
||||
with data attributes for mode and other metadata.
|
||||
- Otherwise: JSON response with error details and an `iframe` field
|
||||
pointing to /auth/restricted/?mode=... for iframe-based authentication.
|
||||
"""
|
||||
try:
|
||||
ctx = await authz.verify(
|
||||
auth, perm, host=request.headers.get("host"), max_age=max_age
|
||||
)
|
||||
role_permissions = set(ctx.role.permissions or [])
|
||||
if ctx.permissions:
|
||||
role_permissions.update(permission.id for permission in ctx.permissions)
|
||||
|
||||
remote_headers: dict[str, str] = {
|
||||
"Remote-User": str(ctx.user.uuid),
|
||||
"Remote-Name": ctx.user.display_name,
|
||||
"Remote-Groups": ",".join(sorted(role_permissions)),
|
||||
"Remote-Org": str(ctx.org.uuid),
|
||||
"Remote-Org-Name": ctx.org.display_name,
|
||||
"Remote-Role": str(ctx.role.uuid),
|
||||
"Remote-Role-Name": ctx.role.display_name,
|
||||
"Remote-Session-Expires": (
|
||||
session_expiry(ctx.session)
|
||||
.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if session_expiry(ctx.session).tzinfo
|
||||
else session_expiry(ctx.session)
|
||||
.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"Remote-Credential": str(ctx.session.credential_uuid),
|
||||
}
|
||||
return Response(status_code=204, headers=remote_headers)
|
||||
except authz.AuthException as e:
|
||||
# Clear cookie only if session is invalid (not for reauth)
|
||||
if e.clear_session:
|
||||
session.clear_session_cookie(response)
|
||||
|
||||
# Check Accept header to decide response format
|
||||
accept = request.headers.get("accept", "")
|
||||
wants_html = "text/html" in accept
|
||||
|
||||
if wants_html:
|
||||
# Browser request - return full-page HTML with metadata
|
||||
data_attrs = {"mode": e.mode, **e.metadata}
|
||||
html = (await frontend.read("/int/forward/index.html"))[0]
|
||||
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
||||
return Response(
|
||||
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
||||
)
|
||||
else:
|
||||
# API request - return JSON with iframe srcdoc HTML
|
||||
return JSONResponse(
|
||||
status_code=e.status_code,
|
||||
content=await authz.auth_error_content(e),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/settings")
|
||||
async def get_settings():
|
||||
pk = global_passkey.instance
|
||||
base_path = hostutil.ui_base_path()
|
||||
return {
|
||||
"rp_id": pk.rp_id,
|
||||
"rp_name": pk.rp_name,
|
||||
"ui_base_path": base_path,
|
||||
"auth_host": hostutil.configured_auth_host(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/user-info")
|
||||
async def api_user_info(
|
||||
request: Request,
|
||||
response: Response,
|
||||
reset: str | None = None,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Get user information including credentials, sessions, and permissions.
|
||||
|
||||
Can be called with either:
|
||||
- A session cookie (auth) for authenticated users
|
||||
- A reset token for users in password reset flow
|
||||
"""
|
||||
authenticated = False
|
||||
session_record = None
|
||||
reset_token = None
|
||||
try:
|
||||
if reset:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError("Invalid reset token")
|
||||
reset_token = await get_reset(reset)
|
||||
target_user_uuid = reset_token.user_uuid
|
||||
else:
|
||||
if auth is None:
|
||||
raise authz.AuthException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
mode="login",
|
||||
)
|
||||
session_record = await get_session(auth, host=request.headers.get("host"))
|
||||
authenticated = True
|
||||
target_user_uuid = session_record.user_uuid
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, str(e))
|
||||
|
||||
# Return minimal response for reset tokens
|
||||
if not authenticated and reset_token:
|
||||
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
|
||||
|
||||
# Return full user info for authenticated users
|
||||
assert auth is not None
|
||||
assert session_record is not None
|
||||
|
||||
return await userinfo.format_user_info(
|
||||
user_uuid=target_user_uuid,
|
||||
auth=auth,
|
||||
session_record=session_record,
|
||||
request_host=request.headers.get("host"),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
try:
|
||||
await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
return {"message": "Already logged out"}
|
||||
with suppress(Exception):
|
||||
await db.instance.delete_session(session_key(auth))
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@app.post("/set-session")
|
||||
async def api_set_session(
|
||||
request: Request, response: Response, auth=Depends(bearer_auth)
|
||||
):
|
||||
user = await get_session(auth.credentials, host=request.headers.get("host"))
|
||||
session.set_session_cookie(response, auth.credentials)
|
||||
return {
|
||||
"message": "Session cookie set successfully",
|
||||
"user_uuid": str(user.user_uuid),
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Middleware for handling auth host redirects."""
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
|
||||
def is_ui_path(path: str) -> bool:
|
||||
"""Check if the path is a UI endpoint."""
|
||||
ui_paths = {
|
||||
"/",
|
||||
"/admin",
|
||||
"/admin/",
|
||||
"/auth",
|
||||
"/auth/",
|
||||
"/auth/admin",
|
||||
"/auth/admin/",
|
||||
}
|
||||
if path in ui_paths:
|
||||
return True
|
||||
# Treat reset token pages as UI (dynamic). Accept single-segment tokens.
|
||||
if path.startswith("/auth/"):
|
||||
token = path[6:]
|
||||
if token and "/" not in token and passphrase.is_well_formed(token):
|
||||
return True
|
||||
else:
|
||||
token = path[1:]
|
||||
if token and "/" not in token and passphrase.is_well_formed(token):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_restricted_path(path: str) -> bool:
|
||||
"""Check if the path is restricted (API/admin endpoints)."""
|
||||
return path.startswith(("/auth/api/admin/", "/auth/api/user/", "/auth/ws/"))
|
||||
|
||||
|
||||
def should_redirect_to_auth_host(path: str) -> bool:
|
||||
"""Determine if the request should be redirected to the auth host."""
|
||||
if path in {"/", "/auth", "/auth/"}:
|
||||
return False
|
||||
return is_ui_path(path) or is_restricted_path(path)
|
||||
|
||||
|
||||
def redirect_to_auth_host(request: Request, cfg: str, path: str) -> Response:
|
||||
"""Create a redirect response to the auth host."""
|
||||
if is_restricted_path(path):
|
||||
return Response(status_code=404)
|
||||
new_path = (
|
||||
path[5:] or "/" if is_ui_path(path) and path.startswith("/auth") else path
|
||||
)
|
||||
return RedirectResponse(f"{request.url.scheme}://{cfg}{new_path}", 307)
|
||||
|
||||
|
||||
def should_redirect_auth_path_to_root(path: str) -> bool:
|
||||
"""Check if /auth/ UI path should be redirected to root on auth host."""
|
||||
if not path.startswith("/auth/"):
|
||||
return False
|
||||
ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"}
|
||||
if path in ui_paths:
|
||||
return True
|
||||
# Check for reset token
|
||||
token = path[6:]
|
||||
return bool(token and "/" not in token and passphrase.is_well_formed(token))
|
||||
|
||||
|
||||
def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Response:
|
||||
"""Create a redirect response to root path on the same host."""
|
||||
new_path = path[5:] or "/"
|
||||
return RedirectResponse(f"{request.url.scheme}://{cur}{new_path}", 307)
|
||||
|
||||
|
||||
async def redirect_middleware(request: Request, call_next):
|
||||
"""Middleware to handle auth host redirects."""
|
||||
cfg = hostutil.configured_auth_host()
|
||||
if not cfg:
|
||||
return await call_next(request)
|
||||
|
||||
cur = hostutil.normalize_host(request.headers.get("host"))
|
||||
if not cur:
|
||||
return await call_next(request)
|
||||
|
||||
cfg_normalized = hostutil.normalize_host(cfg)
|
||||
on_auth_host = cur == cfg_normalized
|
||||
|
||||
path = request.url.path or "/"
|
||||
|
||||
if not on_auth_host:
|
||||
if not should_redirect_to_auth_host(path):
|
||||
return await call_next(request)
|
||||
return redirect_to_auth_host(request, cfg, path)
|
||||
else:
|
||||
# On auth host: force UI endpoints at root
|
||||
if should_redirect_auth_path_to_root(path):
|
||||
return redirect_to_root_on_auth_host(request, cur, path)
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,110 @@
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from paskia.util import permutil, sessionutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthException(HTTPException):
|
||||
"""Exception raised during authentication/authorization with metadata for the UI.
|
||||
|
||||
Attributes:
|
||||
status_code: HTTP status code (401 for auth, 403 for authz)
|
||||
detail: Error message
|
||||
mode: UI mode ('login' or 'reauth')
|
||||
clear_session: Whether to clear the session cookie (True for invalid sessions)
|
||||
metadata: Additional data to pass to the frontend
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
detail: str,
|
||||
mode: str,
|
||||
clear_session: bool = False,
|
||||
**metadata,
|
||||
):
|
||||
super().__init__(status_code=status_code, detail=detail)
|
||||
self.mode = mode
|
||||
self.clear_session = clear_session
|
||||
self.metadata = metadata
|
||||
|
||||
|
||||
async def auth_error_content(exc: AuthException) -> dict:
|
||||
"""Generate JSON response content for an AuthException.
|
||||
|
||||
Returns a dict with detail, mode, and iframe URL for src embedding.
|
||||
"""
|
||||
# Build hash fragment from mode and metadata
|
||||
params = {"mode": exc.mode, **exc.metadata}
|
||||
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||
iframe_url = f"/auth/restricted/#{fragment}"
|
||||
return {
|
||||
"detail": exc.detail,
|
||||
"auth": {
|
||||
"mode": exc.mode,
|
||||
"iframe": iframe_url,
|
||||
**exc.metadata,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def verify(
|
||||
auth: str | None,
|
||||
perm: list[str],
|
||||
match=permutil.has_all,
|
||||
host: str | None = None,
|
||||
max_age: str | None = None,
|
||||
):
|
||||
"""Validate session token and optional list of required permissions.
|
||||
|
||||
Returns the session context.
|
||||
|
||||
Raises AuthException on failure with metadata for UI rendering.
|
||||
"""
|
||||
if not auth:
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
mode="login",
|
||||
)
|
||||
|
||||
ctx = await permutil.session_context(auth, host)
|
||||
if not ctx:
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Your session has expired. Please sign in again.",
|
||||
mode="login",
|
||||
clear_session=True,
|
||||
)
|
||||
# Check max_age requirement if specified
|
||||
if max_age:
|
||||
try:
|
||||
if not sessionutil.check_session_age(ctx, max_age):
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Additional authentication required",
|
||||
mode="reauth",
|
||||
)
|
||||
except ValueError as e:
|
||||
# Invalid max_age format - log but don't fail the request
|
||||
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
||||
|
||||
if not match(ctx, perm):
|
||||
# Determine which permissions are missing for clearer diagnostics
|
||||
missing = sorted(set(perm) - set(ctx.role.permissions))
|
||||
logger.warning(
|
||||
"Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
|
||||
getattr(ctx.user, "uuid", "?"),
|
||||
getattr(ctx.role, "display_name", "?"),
|
||||
missing,
|
||||
perm,
|
||||
ctx.role.permissions,
|
||||
)
|
||||
raise AuthException(
|
||||
status_code=403, mode="forbidden", detail="Permission required"
|
||||
)
|
||||
|
||||
return ctx
|
||||
@@ -0,0 +1,106 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from paskia.fastapi import admin, api, auth_host, ws
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import frontend, hostutil, passphrase
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
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)
|
||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||
"""
|
||||
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
|
||||
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,
|
||||
bootstrap=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
logging.error(f"⚠️ {e}")
|
||||
# Re-raise to fail fast
|
||||
raise
|
||||
|
||||
yield
|
||||
# (Optional) add shutdown cleanup here later
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
||||
app.middleware("http")(auth_host.redirect_middleware)
|
||||
|
||||
app.mount("/auth/api/admin/", admin.app)
|
||||
app.mount("/auth/api/", api.app)
|
||||
app.mount("/auth/ws/", ws.app)
|
||||
|
||||
# In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
|
||||
if not frontend.is_dev_mode():
|
||||
app.mount(
|
||||
"/auth/assets/",
|
||||
StaticFiles(directory=frontend.file("auth", "assets")),
|
||||
name="assets",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth/restricted/")
|
||||
async def restricted_view():
|
||||
"""Serve the restricted/authentication UI for iframe embedding."""
|
||||
return Response(*await frontend.read("/auth/restricted/index.html"))
|
||||
|
||||
|
||||
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
||||
# / is used on a dedicated auth site, /auth/ on app domains with auth
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@app.get("/auth/")
|
||||
async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
"""Serve the user profile app.
|
||||
|
||||
The frontend handles mode detection (host mode vs full profile) based on settings.
|
||||
Access control is handled via APIs.
|
||||
"""
|
||||
return Response(*await frontend.read("/auth/index.html"))
|
||||
|
||||
|
||||
@app.get("/admin", include_in_schema=False)
|
||||
@app.get("/auth/admin", include_in_schema=False)
|
||||
async def admin_root_redirect():
|
||||
return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307)
|
||||
|
||||
|
||||
@app.get("/admin/", include_in_schema=False)
|
||||
async def admin_root(request: Request, auth=AUTH_COOKIE):
|
||||
return await admin.adminapp(request, auth) # Delegated to admin app
|
||||
|
||||
|
||||
# Note: this catch-all handler must be the last route defined
|
||||
@app.get("/{reset}")
|
||||
@app.get("/auth/{reset}")
|
||||
async def reset_link(reset: str):
|
||||
"""Serve the reset app directly with an injected reset token."""
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise HTTPException(status_code=404)
|
||||
return Response(*await frontend.read("/int/reset/index.html"))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""CLI support for creating user credential reset links.
|
||||
|
||||
Usage (via main CLI):
|
||||
paskia reset [query]
|
||||
|
||||
If query is omitted, the master admin (first Administration role user in
|
||||
an organization granting auth:admin) is targeted. Otherwise query is
|
||||
matched as either an exact UUID or a case-insensitive substring of the
|
||||
display name. If multiple users match, they are listed and the command
|
||||
aborts. A new one-time reset link is always created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
from paskia import authsession as _authsession
|
||||
from paskia import globals as _g
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util import tokens as _tokens
|
||||
|
||||
|
||||
async def _resolve_targets(query: str | None):
|
||||
if query:
|
||||
# Try UUID
|
||||
targets: list[tuple] = []
|
||||
try:
|
||||
q_uuid = UUID(query)
|
||||
perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin")
|
||||
for o in perm_orgs:
|
||||
users = await _g.db.instance.get_organization_users(str(o.uuid))
|
||||
for u, role_name in users:
|
||||
if u.uuid == q_uuid:
|
||||
return [(u, role_name)]
|
||||
# UUID not found among admin orgs -> fall back to substring search (rare case)
|
||||
except ValueError:
|
||||
pass
|
||||
# Substring search
|
||||
needle = query.lower()
|
||||
perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin")
|
||||
for o in perm_orgs:
|
||||
users = await _g.db.instance.get_organization_users(str(o.uuid))
|
||||
for u, role_name in users:
|
||||
if needle in (u.display_name or "").lower():
|
||||
targets.append((u, role_name))
|
||||
# De-duplicate
|
||||
seen = set()
|
||||
deduped = []
|
||||
for u, role_name in targets:
|
||||
if u.uuid not in seen:
|
||||
seen.add(u.uuid)
|
||||
deduped.append((u, role_name))
|
||||
return deduped
|
||||
# No query -> master admin
|
||||
perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin")
|
||||
if not perm_orgs:
|
||||
return []
|
||||
users = await _g.db.instance.get_organization_users(str(perm_orgs[0].uuid))
|
||||
admin_users = [pair for pair in users if pair[1] == "Administration"]
|
||||
return admin_users[:1]
|
||||
|
||||
|
||||
async def _create_reset(user, role_name: str):
|
||||
token = passphrase.generate()
|
||||
expiry = _authsession.reset_expires()
|
||||
await _g.db.instance.create_reset_token(
|
||||
user_uuid=user.uuid,
|
||||
key=_tokens.reset_key(token),
|
||||
expiry=expiry,
|
||||
token_type="manual reset",
|
||||
)
|
||||
return hostutil.reset_link_url(token), token
|
||||
|
||||
|
||||
async def _main(query: str | None) -> int:
|
||||
try:
|
||||
candidates = await _resolve_targets(query)
|
||||
if not candidates:
|
||||
print("No matching users found")
|
||||
return 1
|
||||
if len(candidates) > 1:
|
||||
print("Multiple matches. Refine your query:")
|
||||
for u, role_name in candidates:
|
||||
print(f" - {u.display_name} ({u.uuid}) role={role_name}")
|
||||
return 2
|
||||
user, role_name = candidates[0]
|
||||
link, token = await _create_reset(user, role_name)
|
||||
print(f"Reset link for {user.display_name} ({user.uuid}):\n{link}\n")
|
||||
return 0
|
||||
except Exception as e: # pragma: no cover
|
||||
print("Failed to create reset link:", e)
|
||||
return 1
|
||||
|
||||
|
||||
def run(query: str | None) -> int:
|
||||
"""Synchronous wrapper for CLI entrypoint."""
|
||||
return asyncio.run(_main(query))
|
||||
|
||||
|
||||
__all__ = ["run"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
FastAPI-specific session management for WebAuthn authentication.
|
||||
|
||||
This module provides FastAPI-specific session management functionality:
|
||||
- Extracting client information from FastAPI requests
|
||||
- Setting and clearing HTTP-only cookies via FastAPI Response objects
|
||||
|
||||
Generic session management functions have been moved to authsession.py
|
||||
"""
|
||||
|
||||
from fastapi import Cookie, Request, Response, WebSocket
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
|
||||
AUTH_COOKIE_NAME = "__Host-auth"
|
||||
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
||||
|
||||
|
||||
def infodict(request: Request | WebSocket, type: str) -> dict:
|
||||
"""Extract client information from request."""
|
||||
return {
|
||||
"ip": request.client.host if request.client else None,
|
||||
"user_agent": request.headers.get("user-agent", "")[:500] or None,
|
||||
"session_type": type,
|
||||
}
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
"""Set the session token as an HTTP-only cookie."""
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=int(EXPIRES.total_seconds()),
|
||||
httponly=True,
|
||||
secure=True,
|
||||
path="/",
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
# FastAPI's delete_cookie does not set the secure attribute
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value="",
|
||||
max_age=0,
|
||||
expires=0,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
path="/",
|
||||
samesite="lax",
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
from datetime import timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import (
|
||||
Body,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from paskia.authsession import (
|
||||
delete_credential,
|
||||
expires,
|
||||
get_session,
|
||||
)
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import db
|
||||
from paskia.util import hostutil, passphrase, tokens
|
||||
from paskia.util.tokens import decode_session_key, session_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=await authz.auth_error_content(exc),
|
||||
)
|
||||
|
||||
|
||||
@app.put("/display-name")
|
||||
async def user_update_display_name(
|
||||
request: Request,
|
||||
response: Response,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
if len(new_name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
await db.instance.update_user_display_name(s.user_uuid, new_name)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/logout-all")
|
||||
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
await db.instance.delete_sessions_for_user(s.user_uuid)
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out from all hosts"}
|
||||
|
||||
|
||||
@app.delete("/session/{session_id}")
|
||||
async def api_delete_session(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session_id: str,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
try:
|
||||
current_session = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as exc:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
target_key = decode_session_key(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid session identifier"
|
||||
) from exc
|
||||
|
||||
target_session = await db.instance.get_session(target_key)
|
||||
if not target_session or target_session.user_uuid != current_session.user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
await db.instance.delete_session(target_key)
|
||||
current_terminated = target_key == session_key(auth)
|
||||
if current_terminated:
|
||||
session.clear_session_cookie(response) # explicit because 200
|
||||
return {"status": "ok", "current_session_terminated": current_terminated}
|
||||
|
||||
|
||||
@app.delete("/credential/{uuid}")
|
||||
async def api_delete_credential(
|
||||
request: Request,
|
||||
response: Response,
|
||||
uuid: UUID,
|
||||
auth: str = AUTH_COOKIE,
|
||||
):
|
||||
# Require recent authentication for sensitive operation
|
||||
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
try:
|
||||
await delete_credential(uuid, auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
return {"message": "Credential deleted successfully"}
|
||||
|
||||
|
||||
@app.post("/create-link")
|
||||
async def api_create_link(
|
||||
request: Request,
|
||||
response: Response,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
# Require recent authentication for sensitive operation
|
||||
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
token = passphrase.generate()
|
||||
expiry = expires()
|
||||
await db.instance.create_reset_token(
|
||||
user_uuid=s.user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
)
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
)
|
||||
return {
|
||||
"message": "Registration link generated successfully",
|
||||
"url": url,
|
||||
"expires": (
|
||||
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import logging
|
||||
from functools import wraps
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from paskia.authsession import create_session, get_reset, get_session
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.globals import db, passkey
|
||||
from paskia.util import passphrase
|
||||
from paskia.util.tokens import create_token, session_key
|
||||
|
||||
|
||||
# WebSocket error handling decorator
|
||||
def websocket_error_handler(func):
|
||||
@wraps(func)
|
||||
async def wrapper(ws: WebSocket, *args, **kwargs):
|
||||
try:
|
||||
await ws.accept()
|
||||
return await func(ws, *args, **kwargs)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except authz.AuthException as e:
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": e.status_code,
|
||||
**(await authz.auth_error_content(e)),
|
||||
}
|
||||
)
|
||||
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||
await ws.send_json({"status": 401, "detail": str(e)})
|
||||
except Exception:
|
||||
logging.exception("Internal Server Error")
|
||||
await ws.send_json({"status": 500, "detail": "Internal Server Error"})
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# Create a FastAPI subapp for WebSocket endpoints
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
async def register_chat(
|
||||
ws: WebSocket,
|
||||
user_uuid: UUID,
|
||||
user_name: 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()
|
||||
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||
|
||||
|
||||
@app.websocket("/register")
|
||||
@websocket_error_handler
|
||||
async def websocket_register_add(
|
||||
ws: WebSocket,
|
||||
reset: str | None = None,
|
||||
name: str | None = None,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Register a new credential for an existing user.
|
||||
|
||||
Supports either:
|
||||
- Normal session via auth cookie (requires recent authentication)
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = ws.headers["origin"]
|
||||
host = origin.split("://", 1)[1]
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError(
|
||||
f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
|
||||
)
|
||||
s = await get_reset(reset)
|
||||
user_uuid = s.user_uuid
|
||||
else:
|
||||
# Require recent authentication for adding a new passkey
|
||||
ctx = await authz.verify(auth, perm=[], host=host, max_age="5m")
|
||||
user_uuid = ctx.session.user_uuid
|
||||
s = ctx.session
|
||||
|
||||
# Get user information and determine effective user_name for this registration
|
||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||
user_name = user.display_name
|
||||
if name is not None:
|
||||
stripped = name.strip()
|
||||
if stripped:
|
||||
user_name = stripped
|
||||
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)
|
||||
|
||||
# Create a new session and store everything in database
|
||||
token = create_token()
|
||||
metadata = infodict(ws, "authenticated")
|
||||
await db.instance.create_credential_session( # type: ignore[attr-defined]
|
||||
user_uuid=user_uuid,
|
||||
credential=credential,
|
||||
reset_key=(s.key if reset is not None else None),
|
||||
session_key=session_key(token),
|
||||
display_name=user_name,
|
||||
host=host,
|
||||
ip=metadata.get("ip"),
|
||||
user_agent=metadata.get("user_agent"),
|
||||
)
|
||||
auth = token
|
||||
|
||||
assert isinstance(auth, str) and len(auth) == 16
|
||||
await ws.send_json(
|
||||
{
|
||||
"user_uuid": str(user.uuid),
|
||||
"credential_uuid": str(credential.uuid),
|
||||
"session_token": auth,
|
||||
"message": "New credential added successfully",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.websocket("/authenticate")
|
||||
@websocket_error_handler
|
||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
origin = ws.headers["origin"]
|
||||
host = origin.split("://", 1)[1]
|
||||
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
session_user_uuid = None
|
||||
credential_ids = None
|
||||
if auth:
|
||||
try:
|
||||
session = await get_session(auth, host=host)
|
||||
session_user_uuid = session.user_uuid
|
||||
credential_ids = await db.instance.get_credentials_by_user_uuid(
|
||||
session_user_uuid
|
||||
)
|
||||
except ValueError:
|
||||
pass # Invalid/expired session - allow normal authentication
|
||||
|
||||
options, challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=credential_ids
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
# Wait for the client to use his authenticator to authenticate
|
||||
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||
# Fetch from the database by credential ID
|
||||
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}"
|
||||
)
|
||||
|
||||
# If reauth mode, verify the credential belongs to the session's user
|
||||
if session_user_uuid and stored_cred.user_uuid != session_user_uuid:
|
||||
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)
|
||||
# Update both credential and user's last_seen timestamp
|
||||
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||
|
||||
# Create a session token for the authenticated user
|
||||
assert stored_cred.uuid is not None
|
||||
metadata = infodict(ws, "auth")
|
||||
token = await create_session(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
host=host,
|
||||
ip=metadata.get("ip") or "",
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
)
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"user_uuid": str(stored_cred.user_uuid),
|
||||
"session_token": token,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user