paskia 0.8.1__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 +15 -43
- paskia/bootstrap.py +31 -103
- paskia/db/__init__.py +27 -55
- paskia/db/background.py +20 -40
- paskia/db/jsonl.py +196 -46
- paskia/db/logging.py +233 -0
- paskia/db/migrations.py +33 -0
- paskia/db/operations.py +409 -825
- paskia/db/structs.py +408 -94
- paskia/fastapi/__main__.py +25 -28
- paskia/fastapi/admin.py +147 -329
- paskia/fastapi/api.py +68 -110
- paskia/fastapi/logging.py +218 -0
- paskia/fastapi/mainapp.py +25 -8
- paskia/fastapi/remote.py +16 -39
- paskia/fastapi/reset.py +27 -19
- paskia/fastapi/response.py +22 -0
- paskia/fastapi/session.py +2 -2
- paskia/fastapi/user.py +24 -30
- paskia/fastapi/ws.py +25 -60
- paskia/fastapi/wschat.py +62 -0
- paskia/fastapi/wsutil.py +15 -2
- paskia/frontend-build/auth/admin/index.html +5 -5
- paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
- paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
- paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
- paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
- paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
- paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
- paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
- paskia/frontend-build/auth/index.html +5 -5
- paskia/frontend-build/auth/restricted/index.html +4 -4
- paskia/frontend-build/int/forward/index.html +4 -4
- paskia/frontend-build/int/reset/index.html +3 -3
- paskia/globals.py +2 -2
- paskia/migrate/__init__.py +67 -60
- paskia/migrate/sql.py +94 -37
- paskia/remoteauth.py +7 -8
- paskia/sansio.py +6 -12
- {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/METADATA +1 -1
- paskia-0.9.1.dist-info/RECORD +60 -0
- paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
- paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
- paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
- paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
- paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
- paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
- paskia-0.8.1.dist-info/RECORD +0 -55
- {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/WHEEL +0 -0
- {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/entry_points.txt +0 -0
paskia/fastapi/api.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from contextlib import suppress
|
|
3
|
-
from datetime import datetime, timedelta
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
4
|
|
|
5
5
|
from fastapi import (
|
|
6
6
|
Depends,
|
|
@@ -14,20 +14,16 @@ from fastapi.responses import JSONResponse
|
|
|
14
14
|
from fastapi.security import HTTPBearer
|
|
15
15
|
|
|
16
16
|
from paskia import db
|
|
17
|
-
from paskia.authsession import
|
|
18
|
-
EXPIRES,
|
|
19
|
-
get_reset,
|
|
20
|
-
get_session,
|
|
21
|
-
refresh_session_token,
|
|
22
|
-
)
|
|
17
|
+
from paskia.authsession import EXPIRES, expires, get_reset
|
|
23
18
|
from paskia.fastapi import authz, session, user
|
|
19
|
+
from paskia.fastapi.response import MsgspecResponse
|
|
24
20
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
|
25
21
|
from paskia.globals import passkey as global_passkey
|
|
26
22
|
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
|
27
23
|
|
|
28
24
|
bearer_auth = HTTPBearer(auto_error=True)
|
|
29
25
|
|
|
30
|
-
app = FastAPI()
|
|
26
|
+
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
31
27
|
|
|
32
28
|
app.mount("/user", user.app)
|
|
33
29
|
|
|
@@ -78,12 +74,7 @@ async def validate_token(
|
|
|
78
74
|
max_age: str | None = Query(None),
|
|
79
75
|
auth=AUTH_COOKIE,
|
|
80
76
|
):
|
|
81
|
-
"""Validate
|
|
82
|
-
|
|
83
|
-
Always refreshes the session (sliding expiration) and re-sets the cookie with a
|
|
84
|
-
renewed max-age. This keeps active users logged in without needing a separate
|
|
85
|
-
refresh endpoint.
|
|
86
|
-
"""
|
|
77
|
+
"""Validate session and return context. Refreshes session expiry."""
|
|
87
78
|
try:
|
|
88
79
|
ctx = await authz.verify(
|
|
89
80
|
auth,
|
|
@@ -96,26 +87,23 @@ async def validate_token(
|
|
|
96
87
|
raise
|
|
97
88
|
renewed = False
|
|
98
89
|
if auth:
|
|
99
|
-
consumed = EXPIRES - (ctx.session.expiry - datetime.now(
|
|
90
|
+
consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
|
|
100
91
|
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
"user_uuid": str(ctx.session.user_uuid),
|
|
117
|
-
"renewed": renewed,
|
|
118
|
-
}
|
|
92
|
+
db.update_session(
|
|
93
|
+
auth,
|
|
94
|
+
ip=request.client.host if request.client else "",
|
|
95
|
+
user_agent=request.headers.get("user-agent") or "",
|
|
96
|
+
expiry=expires(),
|
|
97
|
+
)
|
|
98
|
+
session.set_session_cookie(response, auth)
|
|
99
|
+
renewed = True
|
|
100
|
+
return MsgspecResponse(
|
|
101
|
+
{
|
|
102
|
+
"valid": True,
|
|
103
|
+
"renewed": renewed,
|
|
104
|
+
"ctx": userinfo.build_session_context(ctx),
|
|
105
|
+
}
|
|
106
|
+
)
|
|
119
107
|
|
|
120
108
|
|
|
121
109
|
@app.get("/forward")
|
|
@@ -144,9 +132,10 @@ async def forward_authentication(
|
|
|
144
132
|
ctx = await authz.verify(
|
|
145
133
|
auth, perm, host=request.headers.get("host"), max_age=max_age
|
|
146
134
|
)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
135
|
+
# Build permission scopes for Remote-Groups header
|
|
136
|
+
role_permissions = (
|
|
137
|
+
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
|
138
|
+
)
|
|
150
139
|
|
|
151
140
|
remote_headers: dict[str, str] = {
|
|
152
141
|
"Remote-User": str(ctx.user.uuid),
|
|
@@ -157,15 +146,13 @@ async def forward_authentication(
|
|
|
157
146
|
"Remote-Role": str(ctx.role.uuid),
|
|
158
147
|
"Remote-Role-Name": ctx.role.display_name,
|
|
159
148
|
"Remote-Session-Expires": (
|
|
160
|
-
ctx.session.expiry.astimezone(
|
|
161
|
-
.isoformat()
|
|
162
|
-
.replace("+00:00", "Z")
|
|
149
|
+
ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
163
150
|
if ctx.session.expiry.tzinfo
|
|
164
|
-
else ctx.session.expiry.replace(tzinfo=
|
|
151
|
+
else ctx.session.expiry.replace(tzinfo=UTC)
|
|
165
152
|
.isoformat()
|
|
166
153
|
.replace("+00:00", "Z")
|
|
167
154
|
),
|
|
168
|
-
"Remote-Credential": str(ctx.session.
|
|
155
|
+
"Remote-Credential": str(ctx.session.credential),
|
|
169
156
|
}
|
|
170
157
|
return Response(status_code=204, headers=remote_headers)
|
|
171
158
|
except authz.AuthException as e:
|
|
@@ -207,92 +194,61 @@ async def get_settings():
|
|
|
207
194
|
}
|
|
208
195
|
|
|
209
196
|
|
|
210
|
-
@app.get("/token-info")
|
|
211
|
-
async def api_token_info(token: str):
|
|
212
|
-
"""Get information about a reset token.
|
|
213
|
-
|
|
214
|
-
Returns:
|
|
215
|
-
- type: "reset"
|
|
216
|
-
- user_name: display name of the user
|
|
217
|
-
- token_type: type of reset token
|
|
218
|
-
"""
|
|
219
|
-
if not passphrase.is_well_formed(token):
|
|
220
|
-
raise HTTPException(status_code=404, detail="Invalid token")
|
|
221
|
-
|
|
222
|
-
# Check if this is a reset token
|
|
223
|
-
try:
|
|
224
|
-
reset_token = await get_reset(token)
|
|
225
|
-
user = db.get_user_by_uuid(reset_token.user_uuid)
|
|
226
|
-
return {
|
|
227
|
-
"type": "reset",
|
|
228
|
-
"user_name": user.display_name,
|
|
229
|
-
"token_type": reset_token.token_type,
|
|
230
|
-
}
|
|
231
|
-
except (ValueError, Exception):
|
|
232
|
-
raise HTTPException(status_code=404, detail="Token not found or expired")
|
|
233
|
-
|
|
234
|
-
|
|
235
197
|
@app.post("/user-info")
|
|
236
198
|
async def api_user_info(
|
|
237
199
|
request: Request,
|
|
238
200
|
response: Response,
|
|
239
|
-
reset: str | None = None,
|
|
240
201
|
auth=AUTH_COOKIE,
|
|
241
202
|
):
|
|
242
|
-
"""Get user
|
|
203
|
+
"""Get full user profile including credentials and sessions."""
|
|
204
|
+
if auth is None:
|
|
205
|
+
raise authz.AuthException(
|
|
206
|
+
status_code=401,
|
|
207
|
+
detail="Authentication required",
|
|
208
|
+
mode="login",
|
|
209
|
+
)
|
|
210
|
+
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
|
211
|
+
if not ctx:
|
|
212
|
+
raise HTTPException(401, "Session expired")
|
|
213
|
+
|
|
214
|
+
return MsgspecResponse(
|
|
215
|
+
await userinfo.build_user_info(
|
|
216
|
+
user_uuid=ctx.user.uuid,
|
|
217
|
+
auth=auth,
|
|
218
|
+
session_record=ctx.session,
|
|
219
|
+
request_host=request.headers.get("host"),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
243
222
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"""
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
223
|
+
|
|
224
|
+
@app.get("/token-info")
|
|
225
|
+
async def token_info(credentials=Depends(bearer_auth)):
|
|
226
|
+
"""Get reset/device-add token info. Pass token via Bearer header."""
|
|
227
|
+
token = credentials.credentials
|
|
228
|
+
if not passphrase.is_well_formed(token):
|
|
229
|
+
raise HTTPException(400, "Invalid token format")
|
|
251
230
|
try:
|
|
252
|
-
|
|
253
|
-
if not passphrase.is_well_formed(reset):
|
|
254
|
-
raise ValueError("Invalid reset token")
|
|
255
|
-
reset_token = await get_reset(reset)
|
|
256
|
-
target_user_uuid = reset_token.user_uuid
|
|
257
|
-
else:
|
|
258
|
-
if auth is None:
|
|
259
|
-
raise authz.AuthException(
|
|
260
|
-
status_code=401,
|
|
261
|
-
detail="Authentication required",
|
|
262
|
-
mode="login",
|
|
263
|
-
)
|
|
264
|
-
session_record = await get_session(auth, host=request.headers.get("host"))
|
|
265
|
-
authenticated = True
|
|
266
|
-
target_user_uuid = session_record.user_uuid
|
|
231
|
+
reset_token = get_reset(token)
|
|
267
232
|
except ValueError as e:
|
|
268
233
|
raise HTTPException(401, str(e))
|
|
269
234
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
assert auth is not None
|
|
276
|
-
assert session_record is not None
|
|
277
|
-
|
|
278
|
-
return await userinfo.format_user_info(
|
|
279
|
-
user_uuid=target_user_uuid,
|
|
280
|
-
auth=auth,
|
|
281
|
-
session_record=session_record,
|
|
282
|
-
request_host=request.headers.get("host"),
|
|
283
|
-
)
|
|
235
|
+
u = reset_token.user
|
|
236
|
+
return {
|
|
237
|
+
"token_type": reset_token.token_type,
|
|
238
|
+
"display_name": u.display_name,
|
|
239
|
+
}
|
|
284
240
|
|
|
285
241
|
|
|
286
242
|
@app.post("/logout")
|
|
287
243
|
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
288
244
|
if not auth:
|
|
289
245
|
return {"message": "Already logged out"}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
246
|
+
host = request.headers.get("host")
|
|
247
|
+
ctx = db.data().session_ctx(auth, host)
|
|
248
|
+
if not ctx:
|
|
293
249
|
return {"message": "Already logged out"}
|
|
294
250
|
with suppress(Exception):
|
|
295
|
-
db.delete_session(auth)
|
|
251
|
+
db.delete_session(auth, ctx=ctx)
|
|
296
252
|
session.clear_session_cookie(response)
|
|
297
253
|
return {"message": "Logged out successfully"}
|
|
298
254
|
|
|
@@ -301,9 +257,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
|
301
257
|
async def api_set_session(
|
|
302
258
|
request: Request, response: Response, auth=Depends(bearer_auth)
|
|
303
259
|
):
|
|
304
|
-
|
|
260
|
+
ctx = db.data().session_ctx(auth.credentials, request.headers.get("host"))
|
|
261
|
+
if not ctx:
|
|
262
|
+
raise HTTPException(401, "Session expired")
|
|
305
263
|
session.set_session_cookie(response, auth.credentials)
|
|
306
264
|
return {
|
|
307
265
|
"message": "Session cookie set successfully",
|
|
308
|
-
"
|
|
266
|
+
"user": str(ctx.user.uuid),
|
|
309
267
|
}
|
|
@@ -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
2
3
|
import os
|
|
3
4
|
from contextlib import asynccontextmanager
|
|
@@ -7,13 +8,21 @@ from fastapi import FastAPI, HTTPException, Request, Response
|
|
|
7
8
|
from fastapi.responses import FileResponse, RedirectResponse
|
|
8
9
|
from fastapi_vue import Frontend
|
|
9
10
|
|
|
11
|
+
from paskia import globals
|
|
12
|
+
from paskia.db import start_background, stop_background
|
|
13
|
+
from paskia.db.logging import configure_db_logging
|
|
10
14
|
from paskia.fastapi import admin, api, auth_host, ws
|
|
15
|
+
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
|
11
16
|
from paskia.fastapi.session import AUTH_COOKIE
|
|
12
17
|
from paskia.util import hostutil, passphrase, vitedev
|
|
13
18
|
|
|
19
|
+
# Configure custom logging
|
|
20
|
+
configure_access_logging()
|
|
21
|
+
configure_db_logging()
|
|
22
|
+
|
|
14
23
|
# Vue Frontend static files
|
|
15
24
|
frontend = Frontend(
|
|
16
|
-
Path(__file__).
|
|
25
|
+
Path(__file__).parent.parent / "frontend-build",
|
|
17
26
|
cached=["/auth/assets/"],
|
|
18
27
|
)
|
|
19
28
|
|
|
@@ -30,10 +39,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|
|
30
39
|
so that uvicorn reload / multiprocess workers inherit the settings.
|
|
31
40
|
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
|
32
41
|
"""
|
|
33
|
-
import json
|
|
34
|
-
|
|
35
|
-
from paskia import globals
|
|
36
|
-
|
|
37
42
|
config = json.loads(os.environ["PASKIA_CONFIG"])
|
|
38
43
|
|
|
39
44
|
try:
|
|
@@ -49,16 +54,28 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|
|
49
54
|
# Re-raise to fail fast
|
|
50
55
|
raise
|
|
51
56
|
|
|
52
|
-
# 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
|
|
53
59
|
if frontend.devmode:
|
|
54
60
|
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
|
55
|
-
|
|
61
|
+
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
|
56
62
|
|
|
57
63
|
await frontend.load()
|
|
64
|
+
await start_background()
|
|
58
65
|
yield
|
|
66
|
+
await stop_background()
|
|
59
67
|
|
|
60
68
|
|
|
61
|
-
app = FastAPI(
|
|
69
|
+
app = FastAPI(
|
|
70
|
+
lifespan=lifespan,
|
|
71
|
+
redirect_slashes=False,
|
|
72
|
+
docs_url=None,
|
|
73
|
+
redoc_url=None,
|
|
74
|
+
openapi_url=None,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Custom access logging (uvicorn's access_log is disabled)
|
|
78
|
+
app.add_middleware(AccessLogMiddleware)
|
|
62
79
|
|
|
63
80
|
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
|
64
81
|
app.middleware("http")(auth_host.redirect_middleware)
|
paskia/fastapi/remote.py
CHANGED
|
@@ -16,13 +16,14 @@ import base64url
|
|
|
16
16
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
17
17
|
|
|
18
18
|
from paskia import db, remoteauth
|
|
19
|
+
from paskia.authsession import expires
|
|
19
20
|
from paskia.fastapi.session import infodict
|
|
21
|
+
from paskia.fastapi.wschat import authenticate_chat
|
|
20
22
|
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
|
21
|
-
from paskia.
|
|
22
|
-
from paskia.util import passphrase, pow
|
|
23
|
+
from paskia.util import hostutil, passphrase, pow, useragent
|
|
23
24
|
|
|
24
25
|
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
|
25
|
-
app = FastAPI()
|
|
26
|
+
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
@app.websocket("/request")
|
|
@@ -179,7 +180,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
|
|
179
180
|
):
|
|
180
181
|
response = {
|
|
181
182
|
"status": "authenticated",
|
|
182
|
-
"
|
|
183
|
+
"user": str(result_data["user_uuid"]),
|
|
183
184
|
}
|
|
184
185
|
if result_data.get("session_token"):
|
|
185
186
|
response["session_token"] = result_data["session_token"]
|
|
@@ -268,7 +269,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
268
269
|
6. Client sends WebAuthn response
|
|
269
270
|
7. Server sends {status: "success", message: "..."}
|
|
270
271
|
"""
|
|
271
|
-
from paskia.util import useragent
|
|
272
272
|
|
|
273
273
|
origin = validate_origin(ws)
|
|
274
274
|
|
|
@@ -289,7 +289,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
289
289
|
)
|
|
290
290
|
|
|
291
291
|
request = None
|
|
292
|
-
webauthn_challenge = None
|
|
293
292
|
explicitly_denied = False
|
|
294
293
|
|
|
295
294
|
try:
|
|
@@ -311,43 +310,21 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
311
310
|
|
|
312
311
|
# Handle authenticate request (no PoW needed - already validated during lookup)
|
|
313
312
|
if msg.get("authenticate") and request is not None:
|
|
314
|
-
|
|
315
|
-
options, webauthn_challenge = passkey.instance.auth_generate_options(
|
|
316
|
-
credential_ids=None
|
|
317
|
-
)
|
|
318
|
-
await ws.send_json({"optionsJSON": options})
|
|
319
|
-
|
|
320
|
-
# Wait for WebAuthn response
|
|
321
|
-
credential = passkey.instance.auth_parse(await ws.receive_json())
|
|
322
|
-
|
|
323
|
-
# Fetch and verify credential
|
|
324
|
-
try:
|
|
325
|
-
stored_cred = db.get_credential_by_id(credential.raw_id)
|
|
326
|
-
except ValueError:
|
|
327
|
-
raise ValueError(
|
|
328
|
-
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
|
329
|
-
)
|
|
330
|
-
|
|
331
|
-
# Verify the credential
|
|
332
|
-
passkey.instance.auth_verify(
|
|
333
|
-
credential, webauthn_challenge, stored_cred, origin
|
|
334
|
-
)
|
|
313
|
+
cred, new_sign_count = await authenticate_chat(ws, origin)
|
|
335
314
|
|
|
336
315
|
# Create a session for the REQUESTING device
|
|
337
|
-
assert
|
|
316
|
+
assert cred.uuid is not None
|
|
338
317
|
|
|
339
318
|
session_token = None
|
|
340
319
|
reset_token = None
|
|
341
320
|
|
|
342
321
|
if request.action == "register":
|
|
343
322
|
# For registration, create a reset token for device addition
|
|
344
|
-
from paskia.authsession import expires
|
|
345
|
-
from paskia.util import hostutil
|
|
346
323
|
|
|
347
324
|
token_str = passphrase.generate()
|
|
348
325
|
expiry = expires()
|
|
349
326
|
db.create_reset_token(
|
|
350
|
-
user_uuid=
|
|
327
|
+
user_uuid=cred.user_uuid,
|
|
351
328
|
passphrase=token_str,
|
|
352
329
|
expiry=expiry,
|
|
353
330
|
token_type="device addition",
|
|
@@ -356,8 +333,9 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
356
333
|
# Also create a session so the device is logged in
|
|
357
334
|
normalized_host = hostutil.normalize_host(request.host)
|
|
358
335
|
session_token = db.login(
|
|
359
|
-
user_uuid=
|
|
360
|
-
|
|
336
|
+
user_uuid=cred.user_uuid,
|
|
337
|
+
credential_uuid=cred.uuid,
|
|
338
|
+
sign_count=new_sign_count,
|
|
361
339
|
host=normalized_host,
|
|
362
340
|
ip=request.ip,
|
|
363
341
|
user_agent=request.user_agent,
|
|
@@ -365,13 +343,12 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
365
343
|
)
|
|
366
344
|
else:
|
|
367
345
|
# Default login action
|
|
368
|
-
from paskia.authsession import expires
|
|
369
|
-
from paskia.util import hostutil
|
|
370
346
|
|
|
371
347
|
normalized_host = hostutil.normalize_host(request.host)
|
|
372
348
|
session_token = db.login(
|
|
373
|
-
user_uuid=
|
|
374
|
-
|
|
349
|
+
user_uuid=cred.user_uuid,
|
|
350
|
+
credential_uuid=cred.uuid,
|
|
351
|
+
sign_count=new_sign_count,
|
|
375
352
|
host=normalized_host,
|
|
376
353
|
ip=request.ip,
|
|
377
354
|
user_agent=request.user_agent,
|
|
@@ -382,8 +359,8 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|
|
382
359
|
completed = await remoteauth.instance.complete_request(
|
|
383
360
|
token=request.key,
|
|
384
361
|
session_token=session_token,
|
|
385
|
-
user_uuid=
|
|
386
|
-
credential_uuid=
|
|
362
|
+
user_uuid=cred.user_uuid,
|
|
363
|
+
credential_uuid=cred.uuid,
|
|
387
364
|
reset_token=reset_token,
|
|
388
365
|
)
|
|
389
366
|
|