paskia 0.8.1__py3-none-any.whl → 0.9.0__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/authsession.py +14 -27
- paskia/bootstrap.py +31 -103
- paskia/db/__init__.py +25 -51
- paskia/db/background.py +17 -37
- paskia/db/jsonl.py +168 -6
- paskia/db/migrations.py +34 -0
- paskia/db/operations.py +400 -723
- paskia/db/structs.py +214 -90
- paskia/fastapi/__main__.py +24 -28
- paskia/fastapi/admin.py +101 -160
- paskia/fastapi/api.py +47 -83
- paskia/fastapi/mainapp.py +13 -6
- paskia/fastapi/remote.py +16 -39
- paskia/fastapi/reset.py +27 -17
- paskia/fastapi/session.py +2 -2
- paskia/fastapi/user.py +21 -27
- paskia/fastapi/ws.py +27 -62
- paskia/fastapi/wschat.py +62 -0
- 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 +62 -55
- paskia/migrate/sql.py +72 -22
- paskia/remoteauth.py +1 -2
- paskia/sansio.py +6 -12
- {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/METADATA +1 -1
- paskia-0.9.0.dist-info/RECORD +57 -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.0.dist-info}/WHEEL +0 -0
- {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/entry_points.txt +0 -0
paskia/fastapi/api.py
CHANGED
|
@@ -17,7 +17,6 @@ from paskia import db
|
|
|
17
17
|
from paskia.authsession import (
|
|
18
18
|
EXPIRES,
|
|
19
19
|
get_reset,
|
|
20
|
-
get_session,
|
|
21
20
|
refresh_session_token,
|
|
22
21
|
)
|
|
23
22
|
from paskia.fastapi import authz, session, user
|
|
@@ -27,7 +26,7 @@ from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
|
|
27
26
|
|
|
28
27
|
bearer_auth = HTTPBearer(auto_error=True)
|
|
29
28
|
|
|
30
|
-
app = FastAPI()
|
|
29
|
+
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
31
30
|
|
|
32
31
|
app.mount("/user", user.app)
|
|
33
32
|
|
|
@@ -78,12 +77,7 @@ async def validate_token(
|
|
|
78
77
|
max_age: str | None = Query(None),
|
|
79
78
|
auth=AUTH_COOKIE,
|
|
80
79
|
):
|
|
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
|
-
"""
|
|
80
|
+
"""Validate session and return context. Refreshes session expiry."""
|
|
87
81
|
try:
|
|
88
82
|
ctx = await authz.verify(
|
|
89
83
|
auth,
|
|
@@ -99,7 +93,7 @@ async def validate_token(
|
|
|
99
93
|
consumed = EXPIRES - (ctx.session.expiry - datetime.now(timezone.utc))
|
|
100
94
|
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
|
101
95
|
try:
|
|
102
|
-
|
|
96
|
+
refresh_session_token(
|
|
103
97
|
auth,
|
|
104
98
|
ip=request.client.host if request.client else "",
|
|
105
99
|
user_agent=request.headers.get("user-agent") or "",
|
|
@@ -113,8 +107,26 @@ async def validate_token(
|
|
|
113
107
|
)
|
|
114
108
|
return {
|
|
115
109
|
"valid": True,
|
|
116
|
-
"user_uuid": str(ctx.session.user_uuid),
|
|
117
110
|
"renewed": renewed,
|
|
111
|
+
"ctx": userinfo.format_session_context(ctx),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.get("/token-info")
|
|
116
|
+
async def token_info(credentials=Depends(bearer_auth)):
|
|
117
|
+
"""Get reset/device-add token info. Pass token via Bearer header."""
|
|
118
|
+
token = credentials.credentials
|
|
119
|
+
if not passphrase.is_well_formed(token):
|
|
120
|
+
raise HTTPException(400, "Invalid token format")
|
|
121
|
+
try:
|
|
122
|
+
reset_token = get_reset(token)
|
|
123
|
+
except ValueError as e:
|
|
124
|
+
raise HTTPException(401, str(e))
|
|
125
|
+
|
|
126
|
+
u = db.data().users.get(reset_token.user)
|
|
127
|
+
return {
|
|
128
|
+
"token_type": reset_token.token_type,
|
|
129
|
+
"display_name": u.display_name,
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
|
|
@@ -144,9 +156,10 @@ async def forward_authentication(
|
|
|
144
156
|
ctx = await authz.verify(
|
|
145
157
|
auth, perm, host=request.headers.get("host"), max_age=max_age
|
|
146
158
|
)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
159
|
+
# Build permission scopes for Remote-Groups header
|
|
160
|
+
role_permissions = (
|
|
161
|
+
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
|
162
|
+
)
|
|
150
163
|
|
|
151
164
|
remote_headers: dict[str, str] = {
|
|
152
165
|
"Remote-User": str(ctx.user.uuid),
|
|
@@ -165,7 +178,7 @@ async def forward_authentication(
|
|
|
165
178
|
.isoformat()
|
|
166
179
|
.replace("+00:00", "Z")
|
|
167
180
|
),
|
|
168
|
-
"Remote-Credential": str(ctx.session.
|
|
181
|
+
"Remote-Credential": str(ctx.session.credential),
|
|
169
182
|
}
|
|
170
183
|
return Response(status_code=204, headers=remote_headers)
|
|
171
184
|
except authz.AuthException as e:
|
|
@@ -207,78 +220,27 @@ async def get_settings():
|
|
|
207
220
|
}
|
|
208
221
|
|
|
209
222
|
|
|
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
223
|
@app.post("/user-info")
|
|
236
224
|
async def api_user_info(
|
|
237
225
|
request: Request,
|
|
238
226
|
response: Response,
|
|
239
|
-
reset: str | None = None,
|
|
240
227
|
auth=AUTH_COOKIE,
|
|
241
228
|
):
|
|
242
|
-
"""Get user
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if reset:
|
|
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
|
|
267
|
-
except ValueError as e:
|
|
268
|
-
raise HTTPException(401, str(e))
|
|
269
|
-
|
|
270
|
-
# Return minimal response for reset tokens
|
|
271
|
-
if not authenticated and reset_token:
|
|
272
|
-
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
|
|
273
|
-
|
|
274
|
-
# Return full user info for authenticated users
|
|
275
|
-
assert auth is not None
|
|
276
|
-
assert session_record is not None
|
|
229
|
+
"""Get full user profile including credentials and sessions."""
|
|
230
|
+
if auth is None:
|
|
231
|
+
raise authz.AuthException(
|
|
232
|
+
status_code=401,
|
|
233
|
+
detail="Authentication required",
|
|
234
|
+
mode="login",
|
|
235
|
+
)
|
|
236
|
+
ctx = db.get_session_context(auth, request.headers.get("host"))
|
|
237
|
+
if not ctx:
|
|
238
|
+
raise HTTPException(401, "Session expired")
|
|
277
239
|
|
|
278
240
|
return await userinfo.format_user_info(
|
|
279
|
-
user_uuid=
|
|
241
|
+
user_uuid=ctx.user.uuid,
|
|
280
242
|
auth=auth,
|
|
281
|
-
session_record=
|
|
243
|
+
session_record=ctx.session,
|
|
282
244
|
request_host=request.headers.get("host"),
|
|
283
245
|
)
|
|
284
246
|
|
|
@@ -287,12 +249,12 @@ async def api_user_info(
|
|
|
287
249
|
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
288
250
|
if not auth:
|
|
289
251
|
return {"message": "Already logged out"}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
252
|
+
host = request.headers.get("host")
|
|
253
|
+
ctx = db.get_session_context(auth, host)
|
|
254
|
+
if not ctx:
|
|
293
255
|
return {"message": "Already logged out"}
|
|
294
256
|
with suppress(Exception):
|
|
295
|
-
db.delete_session(auth)
|
|
257
|
+
db.delete_session(auth, ctx=ctx)
|
|
296
258
|
session.clear_session_cookie(response)
|
|
297
259
|
return {"message": "Logged out successfully"}
|
|
298
260
|
|
|
@@ -301,9 +263,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
|
301
263
|
async def api_set_session(
|
|
302
264
|
request: Request, response: Response, auth=Depends(bearer_auth)
|
|
303
265
|
):
|
|
304
|
-
|
|
266
|
+
ctx = db.get_session_context(auth.credentials, request.headers.get("host"))
|
|
267
|
+
if not ctx:
|
|
268
|
+
raise HTTPException(401, "Session expired")
|
|
305
269
|
session.set_session_cookie(response, auth.credentials)
|
|
306
270
|
return {
|
|
307
271
|
"message": "Session cookie set successfully",
|
|
308
|
-
"
|
|
272
|
+
"user": str(ctx.user.uuid),
|
|
309
273
|
}
|
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,15 @@ 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
|
|
10
13
|
from paskia.fastapi import admin, api, auth_host, ws
|
|
11
14
|
from paskia.fastapi.session import AUTH_COOKIE
|
|
12
15
|
from paskia.util import hostutil, passphrase, vitedev
|
|
13
16
|
|
|
14
17
|
# Vue Frontend static files
|
|
15
18
|
frontend = Frontend(
|
|
16
|
-
Path(__file__).
|
|
19
|
+
Path(__file__).parent.parent / "frontend-build",
|
|
17
20
|
cached=["/auth/assets/"],
|
|
18
21
|
)
|
|
19
22
|
|
|
@@ -30,10 +33,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|
|
30
33
|
so that uvicorn reload / multiprocess workers inherit the settings.
|
|
31
34
|
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
|
32
35
|
"""
|
|
33
|
-
import json
|
|
34
|
-
|
|
35
|
-
from paskia import globals
|
|
36
|
-
|
|
37
36
|
config = json.loads(os.environ["PASKIA_CONFIG"])
|
|
38
37
|
|
|
39
38
|
try:
|
|
@@ -55,10 +54,18 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|
|
55
54
|
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
|
56
55
|
|
|
57
56
|
await frontend.load()
|
|
57
|
+
await start_background()
|
|
58
58
|
yield
|
|
59
|
+
await stop_background()
|
|
59
60
|
|
|
60
61
|
|
|
61
|
-
app = FastAPI(
|
|
62
|
+
app = FastAPI(
|
|
63
|
+
lifespan=lifespan,
|
|
64
|
+
redirect_slashes=False,
|
|
65
|
+
docs_url=None,
|
|
66
|
+
redoc_url=None,
|
|
67
|
+
openapi_url=None,
|
|
68
|
+
)
|
|
62
69
|
|
|
63
70
|
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
|
64
71
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
363
|
+
credential_uuid=cred.uuid,
|
|
387
364
|
reset_token=reset_token,
|
|
388
365
|
)
|
|
389
366
|
|
paskia/fastapi/reset.py
CHANGED
|
@@ -16,7 +16,7 @@ import asyncio
|
|
|
16
16
|
from uuid import UUID
|
|
17
17
|
|
|
18
18
|
from paskia import authsession as _authsession
|
|
19
|
-
from paskia import db
|
|
19
|
+
from paskia import db
|
|
20
20
|
from paskia.util import hostutil, passphrase
|
|
21
21
|
|
|
22
22
|
|
|
@@ -26,23 +26,30 @@ async def _resolve_targets(query: str | None):
|
|
|
26
26
|
targets: list[tuple] = []
|
|
27
27
|
try:
|
|
28
28
|
q_uuid = UUID(query)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
p = next(
|
|
30
|
+
(p for p in db.data().permissions.values() if p.scope == "auth:admin"),
|
|
31
|
+
None,
|
|
32
|
+
)
|
|
33
|
+
if p:
|
|
34
|
+
for org_uuid in p.orgs:
|
|
35
|
+
users = db.get_organization_users(org_uuid)
|
|
36
|
+
for u, role_name in users:
|
|
37
|
+
if u.uuid == q_uuid:
|
|
38
|
+
return [(u, role_name)]
|
|
35
39
|
# UUID not found among admin orgs -> fall back to substring search (rare case)
|
|
36
40
|
except ValueError:
|
|
37
41
|
pass
|
|
38
42
|
# Substring search
|
|
39
43
|
needle = query.lower()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
p = next(
|
|
45
|
+
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
|
46
|
+
)
|
|
47
|
+
if p:
|
|
48
|
+
for org_uuid in p.orgs:
|
|
49
|
+
users = db.get_organization_users(org_uuid)
|
|
50
|
+
for u, role_name in users:
|
|
51
|
+
if needle in (u.display_name or "").lower():
|
|
52
|
+
targets.append((u, role_name))
|
|
46
53
|
# De-duplicate
|
|
47
54
|
seen = set()
|
|
48
55
|
deduped = []
|
|
@@ -52,10 +59,13 @@ async def _resolve_targets(query: str | None):
|
|
|
52
59
|
deduped.append((u, role_name))
|
|
53
60
|
return deduped
|
|
54
61
|
# No query -> master admin
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
p = next(
|
|
63
|
+
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
|
64
|
+
)
|
|
65
|
+
if not p or not p.orgs:
|
|
57
66
|
return []
|
|
58
|
-
|
|
67
|
+
first_org_uuid = next(iter(p.orgs))
|
|
68
|
+
users = db.get_organization_users(first_org_uuid)
|
|
59
69
|
admin_users = [pair for pair in users if pair[1] == "Administration"]
|
|
60
70
|
return admin_users[:1]
|
|
61
71
|
|
|
@@ -63,7 +73,7 @@ async def _resolve_targets(query: str | None):
|
|
|
63
73
|
async def _create_reset(user, role_name: str):
|
|
64
74
|
token = passphrase.generate()
|
|
65
75
|
expiry = _authsession.reset_expires()
|
|
66
|
-
|
|
76
|
+
db.create_reset_token(
|
|
67
77
|
passphrase=token,
|
|
68
78
|
user_uuid=user.uuid,
|
|
69
79
|
expiry=expiry,
|
paskia/fastapi/session.py
CHANGED
|
@@ -19,8 +19,8 @@ AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
|
|
19
19
|
def infodict(request: Request | WebSocket, type: str) -> dict:
|
|
20
20
|
"""Extract client information from request."""
|
|
21
21
|
return {
|
|
22
|
-
"ip": request.client.host if request.client else
|
|
23
|
-
"user_agent": request.headers.get("user-agent", "")[:500]
|
|
22
|
+
"ip": request.client.host if request.client else "",
|
|
23
|
+
"user_agent": request.headers.get("user-agent", "")[:500],
|
|
24
24
|
"session_type": type,
|
|
25
25
|
}
|
|
26
26
|
|
paskia/fastapi/user.py
CHANGED
|
@@ -14,13 +14,12 @@ from paskia import db
|
|
|
14
14
|
from paskia.authsession import (
|
|
15
15
|
delete_credential,
|
|
16
16
|
expires,
|
|
17
|
-
get_session,
|
|
18
17
|
)
|
|
19
18
|
from paskia.fastapi import authz, session
|
|
20
19
|
from paskia.fastapi.session import AUTH_COOKIE
|
|
21
20
|
from paskia.util import hostutil, passphrase
|
|
22
21
|
|
|
23
|
-
app = FastAPI()
|
|
22
|
+
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
24
23
|
|
|
25
24
|
|
|
26
25
|
@app.exception_handler(authz.AuthException)
|
|
@@ -43,18 +42,18 @@ async def user_update_display_name(
|
|
|
43
42
|
raise authz.AuthException(
|
|
44
43
|
status_code=401, detail="Authentication Required", mode="login"
|
|
45
44
|
)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
host = request.headers.get("host")
|
|
46
|
+
ctx = db.get_session_context(auth, host)
|
|
47
|
+
if not ctx:
|
|
49
48
|
raise authz.AuthException(
|
|
50
49
|
status_code=401, detail="Session expired", mode="login"
|
|
51
|
-
)
|
|
50
|
+
)
|
|
52
51
|
new_name = (payload.get("display_name") or "").strip()
|
|
53
52
|
if not new_name:
|
|
54
53
|
raise HTTPException(status_code=400, detail="display_name required")
|
|
55
54
|
if len(new_name) > 64:
|
|
56
55
|
raise HTTPException(status_code=400, detail="display_name too long")
|
|
57
|
-
db.update_user_display_name(
|
|
56
|
+
db.update_user_display_name(ctx.user.uuid, new_name, ctx=ctx)
|
|
58
57
|
return {"status": "ok"}
|
|
59
58
|
|
|
60
59
|
|
|
@@ -62,13 +61,13 @@ async def user_update_display_name(
|
|
|
62
61
|
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
63
62
|
if not auth:
|
|
64
63
|
return {"message": "Already logged out"}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
host = request.headers.get("host")
|
|
65
|
+
ctx = db.get_session_context(auth, host)
|
|
66
|
+
if not ctx:
|
|
68
67
|
raise authz.AuthException(
|
|
69
68
|
status_code=401, detail="Session expired", mode="login"
|
|
70
69
|
)
|
|
71
|
-
db.delete_sessions_for_user(
|
|
70
|
+
db.delete_sessions_for_user(ctx.user.uuid, ctx=ctx)
|
|
72
71
|
session.clear_session_cookie(response)
|
|
73
72
|
return {"message": "Logged out from all hosts"}
|
|
74
73
|
|
|
@@ -84,18 +83,18 @@ async def api_delete_session(
|
|
|
84
83
|
raise authz.AuthException(
|
|
85
84
|
status_code=401, detail="Authentication Required", mode="login"
|
|
86
85
|
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
host = request.headers.get("host")
|
|
87
|
+
ctx = db.get_session_context(auth, host)
|
|
88
|
+
if not ctx:
|
|
90
89
|
raise authz.AuthException(
|
|
91
90
|
status_code=401, detail="Session expired", mode="login"
|
|
92
|
-
)
|
|
91
|
+
)
|
|
93
92
|
|
|
94
|
-
target_session = db.
|
|
95
|
-
if not target_session or target_session.
|
|
93
|
+
target_session = db.data().sessions.get(session_id)
|
|
94
|
+
if not target_session or target_session.user != ctx.user.uuid:
|
|
96
95
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
97
96
|
|
|
98
|
-
db.delete_session(session_id)
|
|
97
|
+
db.delete_session(session_id, ctx=ctx)
|
|
99
98
|
current_terminated = session_id == auth
|
|
100
99
|
if current_terminated:
|
|
101
100
|
session.clear_session_cookie(response) # explicit because 200
|
|
@@ -112,7 +111,7 @@ async def api_delete_credential(
|
|
|
112
111
|
# Require recent authentication for sensitive operation
|
|
113
112
|
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
|
114
113
|
try:
|
|
115
|
-
|
|
114
|
+
delete_credential(uuid, auth, host=request.headers.get("host"))
|
|
116
115
|
except ValueError as e:
|
|
117
116
|
raise authz.AuthException(
|
|
118
117
|
status_code=401, detail="Session expired", mode="login"
|
|
@@ -127,20 +126,15 @@ async def api_create_link(
|
|
|
127
126
|
auth=AUTH_COOKIE,
|
|
128
127
|
):
|
|
129
128
|
# Require recent authentication for sensitive operation
|
|
130
|
-
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
|
131
|
-
try:
|
|
132
|
-
s = await get_session(auth, host=request.headers.get("host"))
|
|
133
|
-
except ValueError as e:
|
|
134
|
-
raise authz.AuthException(
|
|
135
|
-
status_code=401, detail="Session expired", mode="login"
|
|
136
|
-
) from e
|
|
129
|
+
ctx = await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
|
137
130
|
token = passphrase.generate()
|
|
138
131
|
expiry = expires()
|
|
139
132
|
db.create_reset_token(
|
|
140
|
-
user_uuid=
|
|
133
|
+
user_uuid=ctx.user.uuid,
|
|
141
134
|
passphrase=token,
|
|
142
135
|
expiry=expiry,
|
|
143
136
|
token_type="device addition",
|
|
137
|
+
ctx=ctx,
|
|
144
138
|
)
|
|
145
139
|
url = hostutil.reset_link_url(token)
|
|
146
140
|
return {
|