paskia 0.9.0__py3-none-any.whl → 0.9.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/_version.py +2 -2
- paskia/aaguid/__init__.py +5 -4
- paskia/authsession.py +4 -19
- paskia/db/__init__.py +2 -4
- paskia/db/background.py +3 -3
- paskia/db/jsonl.py +100 -112
- paskia/db/logging.py +233 -0
- paskia/db/migrations.py +19 -20
- paskia/db/operations.py +99 -192
- paskia/db/structs.py +236 -46
- paskia/fastapi/__main__.py +1 -0
- paskia/fastapi/admin.py +70 -193
- paskia/fastapi/api.py +49 -55
- paskia/fastapi/logging.py +218 -0
- paskia/fastapi/mainapp.py +12 -2
- paskia/fastapi/remote.py +4 -4
- paskia/fastapi/reset.py +0 -2
- paskia/fastapi/response.py +22 -0
- paskia/fastapi/user.py +7 -7
- paskia/fastapi/ws.py +6 -6
- paskia/fastapi/wsutil.py +15 -2
- paskia/migrate/__init__.py +9 -9
- paskia/migrate/sql.py +26 -19
- paskia/remoteauth.py +6 -6
- {paskia-0.9.0.dist-info → paskia-0.9.1.dist-info}/METADATA +1 -1
- {paskia-0.9.0.dist-info → paskia-0.9.1.dist-info}/RECORD +28 -25
- {paskia-0.9.0.dist-info → paskia-0.9.1.dist-info}/WHEEL +0 -0
- {paskia-0.9.0.dist-info → paskia-0.9.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Custom access logging middleware for FastAPI/Uvicorn."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from ipaddress import IPv6Address
|
|
7
|
+
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("paskia.access")
|
|
13
|
+
|
|
14
|
+
_RESET = "\033[0m"
|
|
15
|
+
_STATUS_INFO = "\033[32m" # 1xx (green)
|
|
16
|
+
_STATUS_OK = "\033[92m" # 2xx (bright green)
|
|
17
|
+
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
|
18
|
+
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
|
19
|
+
_STATUS_SERVER_ERR = "\033[1;31m" # 5xx (bright red)
|
|
20
|
+
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
|
21
|
+
_METHOD_WRITE = "\033[1;34m" # POST, PUT, DELETE, PATCH (bright blue)
|
|
22
|
+
_HOST = "\033[1;30m" # hostname (dark grey)
|
|
23
|
+
_PATH = "\033[0m" # path (default)
|
|
24
|
+
_TIMING = "\033[2m" # timing (dim)
|
|
25
|
+
_WS_OPEN = "\033[1;33m" # WebSocket connect (bright yellow)
|
|
26
|
+
_WS_CLOSE = "\033[0;33m" # WebSocket disconnect (yellow)
|
|
27
|
+
_WS_STATUS = "\033[1;30m" # WebSocket close status (dark grey)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_ipv6_network(ip: str) -> str:
|
|
31
|
+
"""Format IPv6 address to show only network part (first 64 bits)."""
|
|
32
|
+
try:
|
|
33
|
+
addr = IPv6Address(ip)
|
|
34
|
+
# Get the integer representation and mask to first 64 bits
|
|
35
|
+
network_int = int(addr) >> 64
|
|
36
|
+
# Format as IPv6 with trailing ::
|
|
37
|
+
# Split into 4 groups of 16 bits
|
|
38
|
+
groups = []
|
|
39
|
+
for _ in range(4):
|
|
40
|
+
groups.insert(0, format(network_int & 0xFFFF, "x"))
|
|
41
|
+
network_int >>= 16
|
|
42
|
+
# Compress consecutive zero groups
|
|
43
|
+
result = ":".join(groups) + "::"
|
|
44
|
+
# Simplify leading zeros in groups and compress
|
|
45
|
+
return str(IPv6Address(result + "0"))
|
|
46
|
+
except Exception:
|
|
47
|
+
return ip
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_client_ip(ip: str) -> str:
|
|
51
|
+
"""Format client IP, compressing IPv6 to network part only."""
|
|
52
|
+
if not ip or ip == "-":
|
|
53
|
+
return "-"
|
|
54
|
+
if ":" in ip:
|
|
55
|
+
return format_ipv6_network(ip)
|
|
56
|
+
return ip
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def status_color(status: int) -> str:
|
|
60
|
+
"""Return color code based on HTTP status."""
|
|
61
|
+
if status < 200:
|
|
62
|
+
return _STATUS_INFO
|
|
63
|
+
if status < 300:
|
|
64
|
+
return _STATUS_OK
|
|
65
|
+
if status < 400:
|
|
66
|
+
return _STATUS_REDIRECT
|
|
67
|
+
if status < 500:
|
|
68
|
+
return _STATUS_CLIENT_ERR
|
|
69
|
+
return _STATUS_SERVER_ERR
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def method_color(method: str) -> str:
|
|
73
|
+
"""Return color code based on HTTP method."""
|
|
74
|
+
if method in ("GET", "HEAD", "OPTIONS"):
|
|
75
|
+
return _METHOD_READ
|
|
76
|
+
return _METHOD_WRITE
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_access_log(
|
|
80
|
+
client: str, status: int, method: str, host: str, path: str, duration_ms: float
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Format access log line with colors and aligned fields."""
|
|
83
|
+
use_color = sys.stderr.isatty()
|
|
84
|
+
|
|
85
|
+
# Format components with fixed widths for alignment
|
|
86
|
+
ip = format_client_ip(client).ljust(15) # IPv4 max 15 chars
|
|
87
|
+
timing = f"{duration_ms:.0f}ms"
|
|
88
|
+
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
|
89
|
+
|
|
90
|
+
if use_color:
|
|
91
|
+
status_str = f"{status_color(status)}{status}{_RESET}"
|
|
92
|
+
timing_str = f"{_TIMING}{timing}{_RESET}"
|
|
93
|
+
method_str = f"{method_color(method)}{method_padded}{_RESET}"
|
|
94
|
+
host_str = f"{_HOST}{host}{_RESET}"
|
|
95
|
+
path_str = f"{_PATH}{path}{_RESET}"
|
|
96
|
+
else:
|
|
97
|
+
status_str = str(status)
|
|
98
|
+
timing_str = timing
|
|
99
|
+
method_str = method_padded
|
|
100
|
+
host_str = host
|
|
101
|
+
path_str = path
|
|
102
|
+
|
|
103
|
+
# Format: "IP STATUS METHOD host path TIMING"
|
|
104
|
+
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# WebSocket connection counter (mod 100)
|
|
108
|
+
_ws_counter = 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _next_ws_id() -> int:
|
|
112
|
+
"""Get next WebSocket connection ID (0-99)."""
|
|
113
|
+
global _ws_counter
|
|
114
|
+
ws_id = _ws_counter
|
|
115
|
+
_ws_counter = (_ws_counter + 1) % 100
|
|
116
|
+
return ws_id
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def log_ws_open(client: str, host: str, path: str) -> int:
|
|
120
|
+
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
|
121
|
+
use_color = sys.stderr.isatty()
|
|
122
|
+
ws_id = _next_ws_id()
|
|
123
|
+
|
|
124
|
+
ip = format_client_ip(client).ljust(15)
|
|
125
|
+
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
|
126
|
+
|
|
127
|
+
if use_color:
|
|
128
|
+
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
|
129
|
+
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
|
130
|
+
host_str = f"{_HOST}{host}{_RESET}"
|
|
131
|
+
path_str = f"{_PATH}{path}{_RESET}"
|
|
132
|
+
else:
|
|
133
|
+
prefix = f"WS+ {id_str}"
|
|
134
|
+
host_str = host
|
|
135
|
+
path_str = path
|
|
136
|
+
|
|
137
|
+
logger.info(f"{ip} {prefix} {host_str}{path_str}")
|
|
138
|
+
return ws_id
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# WebSocket close codes to human-readable status
|
|
142
|
+
WS_CLOSE_CODES = {
|
|
143
|
+
1000: "ok",
|
|
144
|
+
1001: "going away",
|
|
145
|
+
1002: "protocol error",
|
|
146
|
+
1003: "unsupported",
|
|
147
|
+
1005: "no status",
|
|
148
|
+
1006: "abnormal",
|
|
149
|
+
1007: "invalid data",
|
|
150
|
+
1008: "policy violation",
|
|
151
|
+
1009: "too large",
|
|
152
|
+
1010: "extension required",
|
|
153
|
+
1011: "server error",
|
|
154
|
+
1012: "restarting",
|
|
155
|
+
1013: "try again",
|
|
156
|
+
1014: "bad gateway",
|
|
157
|
+
1015: "tls error",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def log_ws_close(
|
|
162
|
+
client: str, ws_id: int, close_code: int | None, duration_ms: float
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Log WebSocket connection close with duration and status."""
|
|
165
|
+
use_color = sys.stderr.isatty()
|
|
166
|
+
|
|
167
|
+
ip = format_client_ip(client).ljust(15)
|
|
168
|
+
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
|
169
|
+
timing = f"{duration_ms:.0f}ms"
|
|
170
|
+
|
|
171
|
+
# Convert close code to status text
|
|
172
|
+
if close_code is None:
|
|
173
|
+
status = "closed"
|
|
174
|
+
else:
|
|
175
|
+
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
|
176
|
+
|
|
177
|
+
if use_color:
|
|
178
|
+
# 🔌 aligned with status, ID aligned with method
|
|
179
|
+
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
|
180
|
+
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
|
181
|
+
timing_str = f"{_TIMING}{timing}{_RESET}"
|
|
182
|
+
else:
|
|
183
|
+
prefix = f"WS- {id_str}"
|
|
184
|
+
status_str = status
|
|
185
|
+
timing_str = timing
|
|
186
|
+
|
|
187
|
+
logger.info(f"{ip} {prefix} {status_str} {timing_str}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class AccessLogMiddleware(BaseHTTPMiddleware):
|
|
191
|
+
"""Middleware that logs HTTP requests with custom format."""
|
|
192
|
+
|
|
193
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
194
|
+
start = time.perf_counter()
|
|
195
|
+
response = await call_next(request)
|
|
196
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
197
|
+
|
|
198
|
+
client = request.client.host if request.client else "-"
|
|
199
|
+
host = request.headers.get("host", "-")
|
|
200
|
+
method = request.method
|
|
201
|
+
path = request.url.path
|
|
202
|
+
if request.url.query:
|
|
203
|
+
path = f"{path}?{request.url.query}"
|
|
204
|
+
status = response.status_code
|
|
205
|
+
|
|
206
|
+
line = format_access_log(client, status, method, host, path, duration_ms)
|
|
207
|
+
logger.info(line)
|
|
208
|
+
|
|
209
|
+
return response
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def configure_access_logging():
|
|
213
|
+
"""Configure the access logger to output to stderr."""
|
|
214
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
215
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
216
|
+
logger.addHandler(handler)
|
|
217
|
+
logger.setLevel(logging.INFO)
|
|
218
|
+
logger.propagate = False
|
paskia/fastapi/mainapp.py
CHANGED
|
@@ -10,10 +10,16 @@ from fastapi_vue import Frontend
|
|
|
10
10
|
|
|
11
11
|
from paskia import globals
|
|
12
12
|
from paskia.db import start_background, stop_background
|
|
13
|
+
from paskia.db.logging import configure_db_logging
|
|
13
14
|
from paskia.fastapi import admin, api, auth_host, ws
|
|
15
|
+
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
|
14
16
|
from paskia.fastapi.session import AUTH_COOKIE
|
|
15
17
|
from paskia.util import hostutil, passphrase, vitedev
|
|
16
18
|
|
|
19
|
+
# Configure custom logging
|
|
20
|
+
configure_access_logging()
|
|
21
|
+
configure_db_logging()
|
|
22
|
+
|
|
17
23
|
# Vue Frontend static files
|
|
18
24
|
frontend = Frontend(
|
|
19
25
|
Path(__file__).parent.parent / "frontend-build",
|
|
@@ -48,10 +54,11 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|
|
48
54
|
# Re-raise to fail fast
|
|
49
55
|
raise
|
|
50
56
|
|
|
51
|
-
# Restore info
|
|
57
|
+
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
|
58
|
+
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
|
52
59
|
if frontend.devmode:
|
|
53
60
|
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
|
54
|
-
|
|
61
|
+
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
|
55
62
|
|
|
56
63
|
await frontend.load()
|
|
57
64
|
await start_background()
|
|
@@ -67,6 +74,9 @@ app = FastAPI(
|
|
|
67
74
|
openapi_url=None,
|
|
68
75
|
)
|
|
69
76
|
|
|
77
|
+
# Custom access logging (uvicorn's access_log is disabled)
|
|
78
|
+
app.add_middleware(AccessLogMiddleware)
|
|
79
|
+
|
|
70
80
|
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
|
71
81
|
app.middleware("http")(auth_host.redirect_middleware)
|
|
72
82
|
|
paskia/fastapi/remote.py
CHANGED
|
@@ -324,7 +324,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
324
324
|
token_str = passphrase.generate()
|
|
325
325
|
expiry = expires()
|
|
326
326
|
db.create_reset_token(
|
|
327
|
-
user_uuid=cred.
|
|
327
|
+
user_uuid=cred.user_uuid,
|
|
328
328
|
passphrase=token_str,
|
|
329
329
|
expiry=expiry,
|
|
330
330
|
token_type="device addition",
|
|
@@ -333,7 +333,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
333
333
|
# Also create a session so the device is logged in
|
|
334
334
|
normalized_host = hostutil.normalize_host(request.host)
|
|
335
335
|
session_token = db.login(
|
|
336
|
-
user_uuid=cred.
|
|
336
|
+
user_uuid=cred.user_uuid,
|
|
337
337
|
credential_uuid=cred.uuid,
|
|
338
338
|
sign_count=new_sign_count,
|
|
339
339
|
host=normalized_host,
|
|
@@ -346,7 +346,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
346
346
|
|
|
347
347
|
normalized_host = hostutil.normalize_host(request.host)
|
|
348
348
|
session_token = db.login(
|
|
349
|
-
user_uuid=cred.
|
|
349
|
+
user_uuid=cred.user_uuid,
|
|
350
350
|
credential_uuid=cred.uuid,
|
|
351
351
|
sign_count=new_sign_count,
|
|
352
352
|
host=normalized_host,
|
|
@@ -359,7 +359,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
359
359
|
completed = await remoteauth.instance.complete_request(
|
|
360
360
|
token=request.key,
|
|
361
361
|
session_token=session_token,
|
|
362
|
-
user_uuid=cred.
|
|
362
|
+
user_uuid=cred.user_uuid,
|
|
363
363
|
credential_uuid=cred.uuid,
|
|
364
364
|
reset_token=reset_token,
|
|
365
365
|
)
|
paskia/fastapi/reset.py
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""FastAPI response utilities for msgspec.Struct serialization."""
|
|
2
|
+
|
|
3
|
+
import msgspec
|
|
4
|
+
from fastapi import Response
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MsgspecResponse(Response):
|
|
8
|
+
"""Response that uses msgspec for JSON encoding.
|
|
9
|
+
|
|
10
|
+
Use this for returning msgspec.Struct, dict, or list with proper serialization.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
media_type = "application/json"
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
content: msgspec.Struct | dict | list,
|
|
18
|
+
status_code: int = 200,
|
|
19
|
+
headers: dict | None = None,
|
|
20
|
+
):
|
|
21
|
+
body = msgspec.json.encode(content)
|
|
22
|
+
super().__init__(content=body, status_code=status_code, headers=headers)
|
paskia/fastapi/user.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from datetime import
|
|
1
|
+
from datetime import UTC
|
|
2
2
|
from uuid import UUID
|
|
3
3
|
|
|
4
4
|
from fastapi import (
|
|
@@ -43,7 +43,7 @@ async def user_update_display_name(
|
|
|
43
43
|
status_code=401, detail="Authentication Required", mode="login"
|
|
44
44
|
)
|
|
45
45
|
host = request.headers.get("host")
|
|
46
|
-
ctx = db.
|
|
46
|
+
ctx = db.data().session_ctx(auth, host)
|
|
47
47
|
if not ctx:
|
|
48
48
|
raise authz.AuthException(
|
|
49
49
|
status_code=401, detail="Session expired", mode="login"
|
|
@@ -62,7 +62,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
|
|
|
62
62
|
if not auth:
|
|
63
63
|
return {"message": "Already logged out"}
|
|
64
64
|
host = request.headers.get("host")
|
|
65
|
-
ctx = db.
|
|
65
|
+
ctx = db.data().session_ctx(auth, host)
|
|
66
66
|
if not ctx:
|
|
67
67
|
raise authz.AuthException(
|
|
68
68
|
status_code=401, detail="Session expired", mode="login"
|
|
@@ -84,14 +84,14 @@ async def api_delete_session(
|
|
|
84
84
|
status_code=401, detail="Authentication Required", mode="login"
|
|
85
85
|
)
|
|
86
86
|
host = request.headers.get("host")
|
|
87
|
-
ctx = db.
|
|
87
|
+
ctx = db.data().session_ctx(auth, host)
|
|
88
88
|
if not ctx:
|
|
89
89
|
raise authz.AuthException(
|
|
90
90
|
status_code=401, detail="Session expired", mode="login"
|
|
91
91
|
)
|
|
92
92
|
|
|
93
93
|
target_session = db.data().sessions.get(session_id)
|
|
94
|
-
if not target_session or target_session.
|
|
94
|
+
if not target_session or target_session.user_uuid != ctx.user.uuid:
|
|
95
95
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
96
96
|
|
|
97
97
|
db.delete_session(session_id, ctx=ctx)
|
|
@@ -141,8 +141,8 @@ async def api_create_link(
|
|
|
141
141
|
"message": "Registration link generated successfully",
|
|
142
142
|
"url": url,
|
|
143
143
|
"expires": (
|
|
144
|
-
expiry.astimezone(
|
|
144
|
+
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
145
145
|
if expiry.tzinfo
|
|
146
|
-
else expiry.replace(tzinfo=
|
|
146
|
+
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
|
147
147
|
),
|
|
148
148
|
}
|
paskia/fastapi/ws.py
CHANGED
|
@@ -38,11 +38,11 @@ async def websocket_register_add(
|
|
|
38
38
|
f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
|
|
39
39
|
)
|
|
40
40
|
s = get_reset(reset)
|
|
41
|
-
user_uuid = s.
|
|
41
|
+
user_uuid = s.user_uuid
|
|
42
42
|
else:
|
|
43
43
|
# Require recent authentication for adding a new passkey
|
|
44
44
|
ctx = await authz.verify(auth, perm=[], host=host, max_age="5m")
|
|
45
|
-
user_uuid = ctx.session.
|
|
45
|
+
user_uuid = ctx.session.user_uuid
|
|
46
46
|
s = ctx.session
|
|
47
47
|
|
|
48
48
|
# Get user information and determine effective user_name for this registration
|
|
@@ -91,7 +91,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
|
|
91
91
|
session_user_uuid = None
|
|
92
92
|
credential_ids = None
|
|
93
93
|
if auth:
|
|
94
|
-
ctx = db.
|
|
94
|
+
ctx = db.data().session_ctx(auth, host)
|
|
95
95
|
if ctx:
|
|
96
96
|
session_user_uuid = ctx.user.uuid
|
|
97
97
|
credential_ids = db.get_user_credential_ids(session_user_uuid) or None
|
|
@@ -99,7 +99,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
|
|
99
99
|
cred, new_sign_count = await authenticate_chat(ws, origin, credential_ids)
|
|
100
100
|
|
|
101
101
|
# If reauth mode, verify the credential belongs to the session's user
|
|
102
|
-
if session_user_uuid and cred.
|
|
102
|
+
if session_user_uuid and cred.user_uuid != session_user_uuid:
|
|
103
103
|
raise ValueError("This passkey belongs to a different account")
|
|
104
104
|
|
|
105
105
|
# Create session and update user/credential in a single transaction
|
|
@@ -114,7 +114,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
|
|
114
114
|
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
|
115
115
|
|
|
116
116
|
token = db.login(
|
|
117
|
-
user_uuid=cred.
|
|
117
|
+
user_uuid=cred.user_uuid,
|
|
118
118
|
credential_uuid=cred.uuid,
|
|
119
119
|
sign_count=new_sign_count,
|
|
120
120
|
host=normalized_host,
|
|
@@ -125,7 +125,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
|
|
125
125
|
|
|
126
126
|
await ws.send_json(
|
|
127
127
|
{
|
|
128
|
-
"user": str(cred.
|
|
128
|
+
"user": str(cred.user_uuid),
|
|
129
129
|
"session_token": token,
|
|
130
130
|
}
|
|
131
131
|
)
|
paskia/fastapi/wsutil.py
CHANGED
|
@@ -3,6 +3,7 @@ Shared WebSocket utilities for FastAPI endpoints.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import time
|
|
6
7
|
from functools import wraps
|
|
7
8
|
|
|
8
9
|
import base64url
|
|
@@ -10,6 +11,7 @@ from fastapi import WebSocket, WebSocketDisconnect
|
|
|
10
11
|
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
|
11
12
|
|
|
12
13
|
from paskia.fastapi import authz
|
|
14
|
+
from paskia.fastapi.logging import log_ws_close, log_ws_open
|
|
13
15
|
from paskia.globals import passkey
|
|
14
16
|
from paskia.util import pow
|
|
15
17
|
|
|
@@ -19,11 +21,19 @@ def websocket_error_handler(func):
|
|
|
19
21
|
|
|
20
22
|
@wraps(func)
|
|
21
23
|
async def wrapper(ws: WebSocket, *args, **kwargs):
|
|
24
|
+
client = ws.client.host if ws.client else "-"
|
|
25
|
+
host = ws.headers.get("host", "-")
|
|
26
|
+
path = ws.url.path
|
|
27
|
+
|
|
28
|
+
start = time.perf_counter()
|
|
29
|
+
ws_id = log_ws_open(client, host, path)
|
|
30
|
+
close_code = None
|
|
31
|
+
|
|
22
32
|
try:
|
|
23
33
|
await ws.accept()
|
|
24
34
|
return await func(ws, *args, **kwargs)
|
|
25
|
-
except WebSocketDisconnect:
|
|
26
|
-
|
|
35
|
+
except WebSocketDisconnect as e:
|
|
36
|
+
close_code = e.code
|
|
27
37
|
except authz.AuthException as e:
|
|
28
38
|
await ws.send_json(
|
|
29
39
|
{
|
|
@@ -36,6 +46,9 @@ def websocket_error_handler(func):
|
|
|
36
46
|
except Exception:
|
|
37
47
|
logging.exception("Internal Server Error")
|
|
38
48
|
await ws.send_json({"status": 500, "detail": "Internal Server Error"})
|
|
49
|
+
finally:
|
|
50
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
51
|
+
log_ws_close(client, ws_id, close_code, duration_ms)
|
|
39
52
|
|
|
40
53
|
return wrapper
|
|
41
54
|
|
paskia/migrate/__init__.py
CHANGED
|
@@ -14,7 +14,7 @@ Or via the CLI entry point (if installed):
|
|
|
14
14
|
import argparse
|
|
15
15
|
import asyncio
|
|
16
16
|
import re
|
|
17
|
-
from datetime import
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
18
|
from uuid import UUID
|
|
19
19
|
|
|
20
20
|
import base64url
|
|
@@ -154,7 +154,7 @@ async def migrate_from_sql(
|
|
|
154
154
|
if perm_uuid:
|
|
155
155
|
new_permissions[perm_uuid] = True
|
|
156
156
|
new_role = Role(
|
|
157
|
-
|
|
157
|
+
org_uuid=role.org_uuid,
|
|
158
158
|
display_name=role.display_name,
|
|
159
159
|
permissions=new_permissions,
|
|
160
160
|
)
|
|
@@ -172,8 +172,8 @@ async def migrate_from_sql(
|
|
|
172
172
|
user_key: UUID = legacy_user.uuid
|
|
173
173
|
new_user = User(
|
|
174
174
|
display_name=legacy_user.display_name,
|
|
175
|
-
|
|
176
|
-
created_at=legacy_user.created_at or datetime.now(
|
|
175
|
+
role_uuid=legacy_user.role_uuid,
|
|
176
|
+
created_at=legacy_user.created_at or datetime.now(UTC),
|
|
177
177
|
last_seen=legacy_user.last_seen,
|
|
178
178
|
visits=legacy_user.visits,
|
|
179
179
|
)
|
|
@@ -190,7 +190,7 @@ async def migrate_from_sql(
|
|
|
190
190
|
cred_key: UUID = legacy_cred.uuid
|
|
191
191
|
new_cred = Credential(
|
|
192
192
|
credential_id=legacy_cred.credential_id,
|
|
193
|
-
|
|
193
|
+
user_uuid=legacy_cred.user_uuid,
|
|
194
194
|
aaguid=legacy_cred.aaguid,
|
|
195
195
|
public_key=legacy_cred.public_key,
|
|
196
196
|
sign_count=legacy_cred.sign_count,
|
|
@@ -217,8 +217,8 @@ async def migrate_from_sql(
|
|
|
217
217
|
# Already in new format or unknown - try to use as-is
|
|
218
218
|
session_key = base64url.enc(old_key[:12])
|
|
219
219
|
db.sessions[session_key] = Session(
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
user_uuid=sess.user_uuid,
|
|
221
|
+
credential_uuid=sess.credential_uuid,
|
|
222
222
|
host=sess.host,
|
|
223
223
|
ip=sess.ip,
|
|
224
224
|
user_agent=sess.user_agent,
|
|
@@ -241,14 +241,14 @@ async def migrate_from_sql(
|
|
|
241
241
|
# Already in new format or unknown - truncate to 9 bytes
|
|
242
242
|
token_key = old_key[:9]
|
|
243
243
|
db.reset_tokens[token_key] = ResetToken(
|
|
244
|
-
|
|
244
|
+
user_uuid=token.user_uuid,
|
|
245
245
|
expiry=token.expiry,
|
|
246
246
|
token_type=token.token_type,
|
|
247
247
|
)
|
|
248
248
|
print(f" Migrated {len(token_models)} reset tokens")
|
|
249
249
|
|
|
250
250
|
# Queue and flush all changes using the transaction mechanism
|
|
251
|
-
with db.transaction("migrate"):
|
|
251
|
+
with db.transaction("migrate:sql"):
|
|
252
252
|
pass # All data already added to _data, transaction commits on exit
|
|
253
253
|
|
|
254
254
|
await store.flush()
|
paskia/migrate/sql.py
CHANGED
|
@@ -9,7 +9,7 @@ DO NOT use this module for new code. Use paskia.db instead.
|
|
|
9
9
|
|
|
10
10
|
from contextlib import asynccontextmanager
|
|
11
11
|
from dataclasses import dataclass
|
|
12
|
-
from datetime import
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
13
|
from uuid import UUID
|
|
14
14
|
|
|
15
15
|
from sqlalchemy import (
|
|
@@ -25,11 +25,6 @@ from sqlalchemy.dialects.sqlite import BLOB
|
|
|
25
25
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
26
26
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
27
27
|
|
|
28
|
-
from paskia.db import (
|
|
29
|
-
Org,
|
|
30
|
-
Role,
|
|
31
|
-
)
|
|
32
|
-
|
|
33
28
|
|
|
34
29
|
# Legacy User class for SQL schema (uses 'role_uuid' not 'role')
|
|
35
30
|
@dataclass
|
|
@@ -71,6 +66,17 @@ class _LegacyRole:
|
|
|
71
66
|
permissions: list[str] | None = None
|
|
72
67
|
|
|
73
68
|
|
|
69
|
+
# Legacy Org class for SQL schema (has mutable permissions/roles lists)
|
|
70
|
+
@dataclass
|
|
71
|
+
class _LegacyOrg:
|
|
72
|
+
"""Org as stored in the old SQL schema with mutable permissions/roles."""
|
|
73
|
+
|
|
74
|
+
uuid: UUID
|
|
75
|
+
display_name: str
|
|
76
|
+
permissions: list[str] | None = None
|
|
77
|
+
roles: list[_LegacyRole] | None = None
|
|
78
|
+
|
|
79
|
+
|
|
74
80
|
# Legacy Session class for SQL schema (uses 'key' as field, 'user_uuid', 'credential_uuid')
|
|
75
81
|
@dataclass
|
|
76
82
|
class _LegacySession:
|
|
@@ -112,8 +118,8 @@ def _normalize_dt(value: datetime | None) -> datetime | None:
|
|
|
112
118
|
if value is None:
|
|
113
119
|
return None
|
|
114
120
|
if value.tzinfo is None:
|
|
115
|
-
return value.replace(tzinfo=
|
|
116
|
-
return value.astimezone(
|
|
121
|
+
return value.replace(tzinfo=UTC)
|
|
122
|
+
return value.astimezone(UTC)
|
|
117
123
|
|
|
118
124
|
|
|
119
125
|
class Base(DeclarativeBase):
|
|
@@ -128,12 +134,13 @@ class OrgModel(Base):
|
|
|
128
134
|
|
|
129
135
|
def as_dataclass(self):
|
|
130
136
|
# Base Org without permissions/roles (filled by data accessors)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
return _LegacyOrg(
|
|
138
|
+
uuid=UUID(bytes=self.uuid),
|
|
139
|
+
display_name=self.display_name,
|
|
140
|
+
)
|
|
134
141
|
|
|
135
142
|
@staticmethod
|
|
136
|
-
def from_dataclass(org:
|
|
143
|
+
def from_dataclass(org: _LegacyOrg):
|
|
137
144
|
return OrgModel(uuid=org.uuid.bytes, display_name=org.display_name)
|
|
138
145
|
|
|
139
146
|
|
|
@@ -172,7 +179,7 @@ class UserModel(Base):
|
|
|
172
179
|
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
|
|
173
180
|
)
|
|
174
181
|
created_at: Mapped[datetime] = mapped_column(
|
|
175
|
-
DateTime(timezone=True), default=lambda: datetime.now(
|
|
182
|
+
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
|
176
183
|
)
|
|
177
184
|
last_seen: Mapped[datetime | None] = mapped_column(
|
|
178
185
|
DateTime(timezone=True), nullable=True
|
|
@@ -195,7 +202,7 @@ class UserModel(Base):
|
|
|
195
202
|
uuid=user.uuid.bytes,
|
|
196
203
|
display_name=user.display_name,
|
|
197
204
|
role_uuid=user.role_uuid.bytes,
|
|
198
|
-
created_at=user.created_at or datetime.now(
|
|
205
|
+
created_at=user.created_at or datetime.now(UTC),
|
|
199
206
|
last_seen=user.last_seen,
|
|
200
207
|
visits=user.visits,
|
|
201
208
|
)
|
|
@@ -215,7 +222,7 @@ class CredentialModel(Base):
|
|
|
215
222
|
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
|
|
216
223
|
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
217
224
|
created_at: Mapped[datetime] = mapped_column(
|
|
218
|
-
DateTime(timezone=True), default=lambda: datetime.now(
|
|
225
|
+
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
|
219
226
|
)
|
|
220
227
|
last_used: Mapped[datetime | None] = mapped_column(
|
|
221
228
|
DateTime(timezone=True), nullable=True
|
|
@@ -255,7 +262,7 @@ class SessionModel(Base):
|
|
|
255
262
|
user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
256
263
|
renewed: Mapped[datetime] = mapped_column(
|
|
257
264
|
DateTime(timezone=True),
|
|
258
|
-
default=lambda: datetime.now(
|
|
265
|
+
default=lambda: datetime.now(UTC),
|
|
259
266
|
nullable=False,
|
|
260
267
|
)
|
|
261
268
|
|
|
@@ -388,7 +395,7 @@ class DB:
|
|
|
388
395
|
result = await session.execute(select(PermissionModel))
|
|
389
396
|
return [p.as_dataclass() for p in result.scalars().all()]
|
|
390
397
|
|
|
391
|
-
async def list_organizations(self) -> list[
|
|
398
|
+
async def list_organizations(self) -> list[_LegacyOrg]:
|
|
392
399
|
async with self.session() as session:
|
|
393
400
|
# Load all orgs
|
|
394
401
|
orgs_result = await session.execute(select(OrgModel))
|
|
@@ -415,13 +422,13 @@ class DB:
|
|
|
415
422
|
perms_by_role.setdefault(rp.role_uuid, []).append(rp.permission_id)
|
|
416
423
|
|
|
417
424
|
# Build org dataclasses with roles and permission IDs
|
|
418
|
-
roles_by_org: dict[bytes, list[
|
|
425
|
+
roles_by_org: dict[bytes, list[_LegacyRole]] = {}
|
|
419
426
|
for rm in role_models:
|
|
420
427
|
r_dc = rm.as_dataclass()
|
|
421
428
|
r_dc.permissions = perms_by_role.get(rm.uuid, [])
|
|
422
429
|
roles_by_org.setdefault(rm.org_uuid, []).append(r_dc)
|
|
423
430
|
|
|
424
|
-
orgs: list[
|
|
431
|
+
orgs: list[_LegacyOrg] = []
|
|
425
432
|
for om in org_models:
|
|
426
433
|
o_dc = om.as_dataclass()
|
|
427
434
|
o_dc.permissions = perms_by_org.get(om.uuid, [])
|