paskia 0.7.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/__init__.py +3 -0
- paskia/_version.py +34 -0
- paskia/aaguid/__init__.py +32 -0
- paskia/aaguid/combined_aaguid.json +1 -0
- paskia/authsession.py +112 -0
- paskia/bootstrap.py +190 -0
- paskia/config.py +25 -0
- paskia/db/__init__.py +415 -0
- paskia/db/sql.py +1424 -0
- paskia/fastapi/__init__.py +3 -0
- paskia/fastapi/__main__.py +335 -0
- paskia/fastapi/admin.py +850 -0
- paskia/fastapi/api.py +308 -0
- paskia/fastapi/auth_host.py +97 -0
- paskia/fastapi/authz.py +110 -0
- paskia/fastapi/mainapp.py +130 -0
- paskia/fastapi/remote.py +504 -0
- paskia/fastapi/reset.py +101 -0
- paskia/fastapi/session.py +52 -0
- paskia/fastapi/user.py +162 -0
- paskia/fastapi/ws.py +163 -0
- paskia/fastapi/wsutil.py +91 -0
- paskia/frontend-build/auth/admin/index.html +18 -0
- paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css +1 -0
- paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +8 -0
- paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +1 -0
- paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css +1 -0
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +1 -0
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js +2 -0
- paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +1 -0
- paskia/frontend-build/auth/assets/admin-Df5_Damp.js +1 -0
- paskia/frontend-build/auth/assets/auth-BU_O38k2.css +1 -0
- paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +1 -0
- paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js +1 -0
- paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +1 -0
- paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +1 -0
- paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +1 -0
- paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +1 -0
- paskia/frontend-build/auth/assets/restricted-C0IQufuH.js +1 -0
- paskia/frontend-build/auth/index.html +19 -0
- paskia/frontend-build/auth/restricted/index.html +16 -0
- paskia/frontend-build/int/forward/index.html +18 -0
- paskia/frontend-build/int/reset/index.html +15 -0
- paskia/globals.py +71 -0
- paskia/remoteauth.py +359 -0
- paskia/sansio.py +263 -0
- paskia/util/frontend.py +75 -0
- paskia/util/hostutil.py +76 -0
- paskia/util/htmlutil.py +47 -0
- paskia/util/passphrase.py +20 -0
- paskia/util/permutil.py +32 -0
- paskia/util/pow.py +45 -0
- paskia/util/querysafe.py +11 -0
- paskia/util/sessionutil.py +37 -0
- paskia/util/startupbox.py +75 -0
- paskia/util/timeutil.py +47 -0
- paskia/util/tokens.py +44 -0
- paskia/util/useragent.py +10 -0
- paskia/util/userinfo.py +159 -0
- paskia/util/wordlist.py +54 -0
- paskia-0.7.1.dist-info/METADATA +22 -0
- paskia-0.7.1.dist-info/RECORD +64 -0
- paskia-0.7.1.dist-info/WHEEL +4 -0
- paskia-0.7.1.dist-info/entry_points.txt +2 -0
paskia/fastapi/api.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
from fastapi import (
|
|
6
|
+
Depends,
|
|
7
|
+
FastAPI,
|
|
8
|
+
HTTPException,
|
|
9
|
+
Query,
|
|
10
|
+
Request,
|
|
11
|
+
Response,
|
|
12
|
+
)
|
|
13
|
+
from fastapi.responses import JSONResponse
|
|
14
|
+
from fastapi.security import HTTPBearer
|
|
15
|
+
|
|
16
|
+
from paskia.authsession import (
|
|
17
|
+
EXPIRES,
|
|
18
|
+
get_reset,
|
|
19
|
+
get_session,
|
|
20
|
+
refresh_session_token,
|
|
21
|
+
session_expiry,
|
|
22
|
+
)
|
|
23
|
+
from paskia.fastapi import authz, session, user
|
|
24
|
+
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
|
25
|
+
from paskia.globals import db
|
|
26
|
+
from paskia.globals import passkey as global_passkey
|
|
27
|
+
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
|
|
28
|
+
from paskia.util.tokens import session_key
|
|
29
|
+
|
|
30
|
+
bearer_auth = HTTPBearer(auto_error=True)
|
|
31
|
+
|
|
32
|
+
app = FastAPI()
|
|
33
|
+
|
|
34
|
+
app.mount("/user", user.app)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.exception_handler(HTTPException)
|
|
38
|
+
async def http_exception_handler(_request: Request, exc: HTTPException):
|
|
39
|
+
"""Ensure auth cookie is cleared on 401 responses (JSON responses only)."""
|
|
40
|
+
if exc.status_code == 401:
|
|
41
|
+
resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
|
42
|
+
session.clear_session_cookie(resp)
|
|
43
|
+
return resp
|
|
44
|
+
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Refresh only if at least this much of the session lifetime has been *consumed*.
|
|
48
|
+
# Consumption is derived from (now + EXPIRES) - current_expires.
|
|
49
|
+
# This guarantees a minimum spacing between DB writes even with frequent /validate calls.
|
|
50
|
+
_REFRESH_INTERVAL = timedelta(minutes=5)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.exception_handler(ValueError)
|
|
54
|
+
async def value_error_handler(_request: Request, exc: ValueError):
|
|
55
|
+
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.exception_handler(authz.AuthException)
|
|
59
|
+
async def auth_exception_handler(_request: Request, exc: authz.AuthException):
|
|
60
|
+
"""Handle AuthException with auth info for UI."""
|
|
61
|
+
return JSONResponse(
|
|
62
|
+
status_code=exc.status_code,
|
|
63
|
+
content=await authz.auth_error_content(exc),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.exception_handler(Exception)
|
|
68
|
+
async def general_exception_handler(
|
|
69
|
+
_request: Request, exc: Exception
|
|
70
|
+
): # pragma: no cover
|
|
71
|
+
logging.exception("Unhandled exception in API app")
|
|
72
|
+
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.post("/validate")
|
|
76
|
+
async def validate_token(
|
|
77
|
+
request: Request,
|
|
78
|
+
response: Response,
|
|
79
|
+
perm: list[str] = Query([]),
|
|
80
|
+
auth=AUTH_COOKIE,
|
|
81
|
+
):
|
|
82
|
+
"""Validate the current session and extend its expiry.
|
|
83
|
+
|
|
84
|
+
Always refreshes the session (sliding expiration) and re-sets the cookie with a
|
|
85
|
+
renewed max-age. This keeps active users logged in without needing a separate
|
|
86
|
+
refresh endpoint.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
|
|
90
|
+
except HTTPException:
|
|
91
|
+
# Global handler will clear cookie if 401
|
|
92
|
+
raise
|
|
93
|
+
renewed = False
|
|
94
|
+
if auth:
|
|
95
|
+
current_expiry = session_expiry(ctx.session)
|
|
96
|
+
consumed = EXPIRES - (current_expiry - datetime.now(timezone.utc))
|
|
97
|
+
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
|
98
|
+
try:
|
|
99
|
+
await refresh_session_token(
|
|
100
|
+
auth,
|
|
101
|
+
ip=request.client.host if request.client else "",
|
|
102
|
+
user_agent=request.headers.get("user-agent") or "",
|
|
103
|
+
)
|
|
104
|
+
session.set_session_cookie(response, auth)
|
|
105
|
+
renewed = True
|
|
106
|
+
except ValueError:
|
|
107
|
+
# Session disappeared, e.g. due to concurrent logout; global handler will clear
|
|
108
|
+
raise authz.AuthException(
|
|
109
|
+
status_code=401, detail="Session expired", mode="login"
|
|
110
|
+
)
|
|
111
|
+
return {
|
|
112
|
+
"valid": True,
|
|
113
|
+
"user_uuid": str(ctx.session.user_uuid),
|
|
114
|
+
"renewed": renewed,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.get("/forward")
|
|
119
|
+
async def forward_authentication(
|
|
120
|
+
request: Request,
|
|
121
|
+
response: Response,
|
|
122
|
+
perm: list[str] = Query([]),
|
|
123
|
+
max_age: str | None = Query(None),
|
|
124
|
+
auth=AUTH_COOKIE,
|
|
125
|
+
):
|
|
126
|
+
"""Forward auth validation for Caddy/Nginx.
|
|
127
|
+
|
|
128
|
+
Query Params:
|
|
129
|
+
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
|
130
|
+
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
|
|
131
|
+
is older than this, user must re-authenticate.
|
|
132
|
+
|
|
133
|
+
Success: 204 No Content with Remote-* headers describing the authenticated user.
|
|
134
|
+
Failure (unauthenticated / unauthorized): 4xx response.
|
|
135
|
+
- If Accept header contains "text/html": HTML page for authentication
|
|
136
|
+
with data attributes for mode and other metadata.
|
|
137
|
+
- Otherwise: JSON response with error details and an `iframe` field
|
|
138
|
+
pointing to /auth/restricted/?mode=... for iframe-based authentication.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
ctx = await authz.verify(
|
|
142
|
+
auth, perm, host=request.headers.get("host"), max_age=max_age
|
|
143
|
+
)
|
|
144
|
+
role_permissions = set(ctx.role.permissions or [])
|
|
145
|
+
if ctx.permissions:
|
|
146
|
+
role_permissions.update(permission.id for permission in ctx.permissions)
|
|
147
|
+
|
|
148
|
+
remote_headers: dict[str, str] = {
|
|
149
|
+
"Remote-User": str(ctx.user.uuid),
|
|
150
|
+
"Remote-Name": ctx.user.display_name,
|
|
151
|
+
"Remote-Groups": ",".join(sorted(role_permissions)),
|
|
152
|
+
"Remote-Org": str(ctx.org.uuid),
|
|
153
|
+
"Remote-Org-Name": ctx.org.display_name,
|
|
154
|
+
"Remote-Role": str(ctx.role.uuid),
|
|
155
|
+
"Remote-Role-Name": ctx.role.display_name,
|
|
156
|
+
"Remote-Session-Expires": (
|
|
157
|
+
session_expiry(ctx.session)
|
|
158
|
+
.astimezone(timezone.utc)
|
|
159
|
+
.isoformat()
|
|
160
|
+
.replace("+00:00", "Z")
|
|
161
|
+
if session_expiry(ctx.session).tzinfo
|
|
162
|
+
else session_expiry(ctx.session)
|
|
163
|
+
.replace(tzinfo=timezone.utc)
|
|
164
|
+
.isoformat()
|
|
165
|
+
.replace("+00:00", "Z")
|
|
166
|
+
),
|
|
167
|
+
"Remote-Credential": str(ctx.session.credential_uuid),
|
|
168
|
+
}
|
|
169
|
+
return Response(status_code=204, headers=remote_headers)
|
|
170
|
+
except authz.AuthException as e:
|
|
171
|
+
# Clear cookie only if session is invalid (not for reauth)
|
|
172
|
+
if e.clear_session:
|
|
173
|
+
session.clear_session_cookie(response)
|
|
174
|
+
|
|
175
|
+
# Check Accept header to decide response format
|
|
176
|
+
accept = request.headers.get("accept", "")
|
|
177
|
+
wants_html = "text/html" in accept
|
|
178
|
+
|
|
179
|
+
if wants_html:
|
|
180
|
+
# Browser request - return full-page HTML with metadata
|
|
181
|
+
data_attrs = {"mode": e.mode, **e.metadata}
|
|
182
|
+
html = (await frontend.read("/int/forward/index.html"))[0]
|
|
183
|
+
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
|
184
|
+
return Response(
|
|
185
|
+
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
# API request - return JSON with iframe srcdoc HTML
|
|
189
|
+
return JSONResponse(
|
|
190
|
+
status_code=e.status_code,
|
|
191
|
+
content=await authz.auth_error_content(e),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.get("/settings")
|
|
196
|
+
async def get_settings():
|
|
197
|
+
pk = global_passkey.instance
|
|
198
|
+
base_path = hostutil.ui_base_path()
|
|
199
|
+
return {
|
|
200
|
+
"rp_id": pk.rp_id,
|
|
201
|
+
"rp_name": pk.rp_name,
|
|
202
|
+
"ui_base_path": base_path,
|
|
203
|
+
"auth_host": hostutil.dedicated_auth_host(),
|
|
204
|
+
"auth_site_url": hostutil.auth_site_url(),
|
|
205
|
+
"session_cookie": AUTH_COOKIE_NAME,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.get("/token-info")
|
|
210
|
+
async def api_token_info(token: str):
|
|
211
|
+
"""Get information about a reset token.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
- type: "reset"
|
|
215
|
+
- user_name: display name of the user
|
|
216
|
+
- token_type: type of reset token
|
|
217
|
+
"""
|
|
218
|
+
if not passphrase.is_well_formed(token):
|
|
219
|
+
raise HTTPException(status_code=404, detail="Invalid token")
|
|
220
|
+
|
|
221
|
+
# Check if this is a reset token
|
|
222
|
+
try:
|
|
223
|
+
reset_token = await get_reset(token)
|
|
224
|
+
user = await db.instance.get_user_by_uuid(reset_token.user_uuid)
|
|
225
|
+
return {
|
|
226
|
+
"type": "reset",
|
|
227
|
+
"user_name": user.display_name,
|
|
228
|
+
"token_type": reset_token.token_type,
|
|
229
|
+
}
|
|
230
|
+
except (ValueError, Exception):
|
|
231
|
+
raise HTTPException(status_code=404, detail="Token not found or expired")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.post("/user-info")
|
|
235
|
+
async def api_user_info(
|
|
236
|
+
request: Request,
|
|
237
|
+
response: Response,
|
|
238
|
+
reset: str | None = None,
|
|
239
|
+
auth=AUTH_COOKIE,
|
|
240
|
+
):
|
|
241
|
+
"""Get user information including credentials, sessions, and permissions.
|
|
242
|
+
|
|
243
|
+
Can be called with either:
|
|
244
|
+
- A session cookie (auth) for authenticated users
|
|
245
|
+
- A reset token for users in password reset flow
|
|
246
|
+
"""
|
|
247
|
+
authenticated = False
|
|
248
|
+
session_record = None
|
|
249
|
+
reset_token = None
|
|
250
|
+
try:
|
|
251
|
+
if reset:
|
|
252
|
+
if not passphrase.is_well_formed(reset):
|
|
253
|
+
raise ValueError("Invalid reset token")
|
|
254
|
+
reset_token = await get_reset(reset)
|
|
255
|
+
target_user_uuid = reset_token.user_uuid
|
|
256
|
+
else:
|
|
257
|
+
if auth is None:
|
|
258
|
+
raise authz.AuthException(
|
|
259
|
+
status_code=401,
|
|
260
|
+
detail="Authentication required",
|
|
261
|
+
mode="login",
|
|
262
|
+
)
|
|
263
|
+
session_record = await get_session(auth, host=request.headers.get("host"))
|
|
264
|
+
authenticated = True
|
|
265
|
+
target_user_uuid = session_record.user_uuid
|
|
266
|
+
except ValueError as e:
|
|
267
|
+
raise HTTPException(401, str(e))
|
|
268
|
+
|
|
269
|
+
# Return minimal response for reset tokens
|
|
270
|
+
if not authenticated and reset_token:
|
|
271
|
+
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
|
|
272
|
+
|
|
273
|
+
# Return full user info for authenticated users
|
|
274
|
+
assert auth is not None
|
|
275
|
+
assert session_record is not None
|
|
276
|
+
|
|
277
|
+
return await userinfo.format_user_info(
|
|
278
|
+
user_uuid=target_user_uuid,
|
|
279
|
+
auth=auth,
|
|
280
|
+
session_record=session_record,
|
|
281
|
+
request_host=request.headers.get("host"),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@app.post("/logout")
|
|
286
|
+
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
287
|
+
if not auth:
|
|
288
|
+
return {"message": "Already logged out"}
|
|
289
|
+
try:
|
|
290
|
+
await get_session(auth, host=request.headers.get("host"))
|
|
291
|
+
except ValueError:
|
|
292
|
+
return {"message": "Already logged out"}
|
|
293
|
+
with suppress(Exception):
|
|
294
|
+
await db.instance.delete_session(session_key(auth))
|
|
295
|
+
session.clear_session_cookie(response)
|
|
296
|
+
return {"message": "Logged out successfully"}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.post("/set-session")
|
|
300
|
+
async def api_set_session(
|
|
301
|
+
request: Request, response: Response, auth=Depends(bearer_auth)
|
|
302
|
+
):
|
|
303
|
+
user = await get_session(auth.credentials, host=request.headers.get("host"))
|
|
304
|
+
session.set_session_cookie(response, auth.credentials)
|
|
305
|
+
return {
|
|
306
|
+
"message": "Session cookie set successfully",
|
|
307
|
+
"user_uuid": str(user.user_uuid),
|
|
308
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Middleware for handling auth host redirects."""
|
|
2
|
+
|
|
3
|
+
from fastapi import Request, Response
|
|
4
|
+
from fastapi.responses import RedirectResponse
|
|
5
|
+
|
|
6
|
+
from paskia.util import hostutil, passphrase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_ui_path(path: str) -> bool:
|
|
10
|
+
"""Check if the path is a UI endpoint."""
|
|
11
|
+
ui_paths = {
|
|
12
|
+
"/",
|
|
13
|
+
"/admin",
|
|
14
|
+
"/admin/",
|
|
15
|
+
"/auth",
|
|
16
|
+
"/auth/",
|
|
17
|
+
"/auth/admin",
|
|
18
|
+
"/auth/admin/",
|
|
19
|
+
}
|
|
20
|
+
if path in ui_paths:
|
|
21
|
+
return True
|
|
22
|
+
# Treat reset token pages as UI (dynamic). Accept single-segment tokens.
|
|
23
|
+
if path.startswith("/auth/"):
|
|
24
|
+
token = path[6:]
|
|
25
|
+
if token and "/" not in token and passphrase.is_well_formed(token):
|
|
26
|
+
return True
|
|
27
|
+
else:
|
|
28
|
+
token = path[1:]
|
|
29
|
+
if token and "/" not in token and passphrase.is_well_formed(token):
|
|
30
|
+
return True
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_restricted_path(path: str) -> bool:
|
|
35
|
+
"""Check if the path is restricted (API/admin endpoints)."""
|
|
36
|
+
return path.startswith(("/auth/api/admin/", "/auth/api/user/", "/auth/ws/"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def should_redirect_to_auth_host(path: str) -> bool:
|
|
40
|
+
"""Determine if the request should be redirected to the auth host."""
|
|
41
|
+
if path in {"/", "/auth", "/auth/"}:
|
|
42
|
+
return False
|
|
43
|
+
return is_ui_path(path) or is_restricted_path(path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def redirect_to_auth_host(request: Request, cfg: str, path: str) -> Response:
|
|
47
|
+
"""Create a redirect response to the auth host."""
|
|
48
|
+
if is_restricted_path(path):
|
|
49
|
+
return Response(status_code=404)
|
|
50
|
+
new_path = (
|
|
51
|
+
path[5:] or "/" if is_ui_path(path) and path.startswith("/auth") else path
|
|
52
|
+
)
|
|
53
|
+
return RedirectResponse(f"{request.url.scheme}://{cfg}{new_path}", 307)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def should_redirect_auth_path_to_root(path: str) -> bool:
|
|
57
|
+
"""Check if /auth/ UI path should be redirected to root on auth host."""
|
|
58
|
+
if not path.startswith("/auth/"):
|
|
59
|
+
return False
|
|
60
|
+
ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"}
|
|
61
|
+
if path in ui_paths:
|
|
62
|
+
return True
|
|
63
|
+
# Check for reset token
|
|
64
|
+
token = path[6:]
|
|
65
|
+
return bool(token and "/" not in token and passphrase.is_well_formed(token))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Response:
|
|
69
|
+
"""Create a redirect response to root path on the same host."""
|
|
70
|
+
new_path = path[5:] or "/"
|
|
71
|
+
return RedirectResponse(f"{request.url.scheme}://{cur}{new_path}", 307)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def redirect_middleware(request: Request, call_next):
|
|
75
|
+
"""Middleware to handle auth host redirects."""
|
|
76
|
+
cfg = hostutil.dedicated_auth_host()
|
|
77
|
+
if not cfg:
|
|
78
|
+
return await call_next(request)
|
|
79
|
+
|
|
80
|
+
cur = hostutil.normalize_host(request.headers.get("host"))
|
|
81
|
+
if not cur:
|
|
82
|
+
return await call_next(request)
|
|
83
|
+
|
|
84
|
+
cfg_normalized = hostutil.normalize_host(cfg)
|
|
85
|
+
on_auth_host = cur == cfg_normalized
|
|
86
|
+
|
|
87
|
+
path = request.url.path or "/"
|
|
88
|
+
|
|
89
|
+
if not on_auth_host:
|
|
90
|
+
if not should_redirect_to_auth_host(path):
|
|
91
|
+
return await call_next(request)
|
|
92
|
+
return redirect_to_auth_host(request, cfg, path)
|
|
93
|
+
else:
|
|
94
|
+
# On auth host: force UI endpoints at root
|
|
95
|
+
if should_redirect_auth_path_to_root(path):
|
|
96
|
+
return redirect_to_root_on_auth_host(request, cur, path)
|
|
97
|
+
return await call_next(request)
|
paskia/fastapi/authz.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException
|
|
4
|
+
|
|
5
|
+
from paskia.util import permutil, sessionutil
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthException(HTTPException):
|
|
11
|
+
"""Exception raised during authentication/authorization with metadata for the UI.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
status_code: HTTP status code (401 for auth, 403 for authz)
|
|
15
|
+
detail: Error message
|
|
16
|
+
mode: UI mode ('login' or 'reauth')
|
|
17
|
+
clear_session: Whether to clear the session cookie (True for invalid sessions)
|
|
18
|
+
metadata: Additional data to pass to the frontend
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
status_code: int,
|
|
24
|
+
detail: str,
|
|
25
|
+
mode: str,
|
|
26
|
+
clear_session: bool = False,
|
|
27
|
+
**metadata,
|
|
28
|
+
):
|
|
29
|
+
super().__init__(status_code=status_code, detail=detail)
|
|
30
|
+
self.mode = mode
|
|
31
|
+
self.clear_session = clear_session
|
|
32
|
+
self.metadata = metadata
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def auth_error_content(exc: AuthException) -> dict:
|
|
36
|
+
"""Generate JSON response content for an AuthException.
|
|
37
|
+
|
|
38
|
+
Returns a dict with detail, mode, and iframe URL for src embedding.
|
|
39
|
+
"""
|
|
40
|
+
# Build hash fragment from mode and metadata
|
|
41
|
+
params = {"mode": exc.mode, **exc.metadata}
|
|
42
|
+
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
|
43
|
+
iframe_url = f"/auth/restricted/#{fragment}"
|
|
44
|
+
return {
|
|
45
|
+
"detail": exc.detail,
|
|
46
|
+
"auth": {
|
|
47
|
+
"mode": exc.mode,
|
|
48
|
+
"iframe": iframe_url,
|
|
49
|
+
**exc.metadata,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def verify(
|
|
55
|
+
auth: str | None,
|
|
56
|
+
perm: list[str],
|
|
57
|
+
match=permutil.has_all,
|
|
58
|
+
host: str | None = None,
|
|
59
|
+
max_age: str | None = None,
|
|
60
|
+
):
|
|
61
|
+
"""Validate session token and optional list of required permissions.
|
|
62
|
+
|
|
63
|
+
Returns the session context.
|
|
64
|
+
|
|
65
|
+
Raises AuthException on failure with metadata for UI rendering.
|
|
66
|
+
"""
|
|
67
|
+
if not auth:
|
|
68
|
+
raise AuthException(
|
|
69
|
+
status_code=401,
|
|
70
|
+
detail="Authentication required",
|
|
71
|
+
mode="login",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
ctx = await permutil.session_context(auth, host)
|
|
75
|
+
if not ctx:
|
|
76
|
+
raise AuthException(
|
|
77
|
+
status_code=401,
|
|
78
|
+
detail="Your session has expired. Please sign in again.",
|
|
79
|
+
mode="login",
|
|
80
|
+
clear_session=True,
|
|
81
|
+
)
|
|
82
|
+
# Check max_age requirement if specified
|
|
83
|
+
if max_age:
|
|
84
|
+
try:
|
|
85
|
+
if not sessionutil.check_session_age(ctx, max_age):
|
|
86
|
+
raise AuthException(
|
|
87
|
+
status_code=401,
|
|
88
|
+
detail="Additional authentication required",
|
|
89
|
+
mode="reauth",
|
|
90
|
+
)
|
|
91
|
+
except ValueError as e:
|
|
92
|
+
# Invalid max_age format - log but don't fail the request
|
|
93
|
+
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
|
94
|
+
|
|
95
|
+
if not match(ctx, perm):
|
|
96
|
+
# Determine which permissions are missing for clearer diagnostics
|
|
97
|
+
missing = sorted(set(perm) - set(ctx.role.permissions))
|
|
98
|
+
logger.warning(
|
|
99
|
+
"Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
|
|
100
|
+
getattr(ctx.user, "uuid", "?"),
|
|
101
|
+
getattr(ctx.role, "display_name", "?"),
|
|
102
|
+
missing,
|
|
103
|
+
perm,
|
|
104
|
+
ctx.role.permissions,
|
|
105
|
+
)
|
|
106
|
+
raise AuthException(
|
|
107
|
+
status_code=403, mode="forbidden", detail="Permission required"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return ctx
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
7
|
+
from fastapi.responses import FileResponse, RedirectResponse
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
|
|
10
|
+
from paskia.fastapi import admin, api, auth_host, ws
|
|
11
|
+
from paskia.fastapi.session import AUTH_COOKIE
|
|
12
|
+
from paskia.util import frontend, hostutil, passphrase
|
|
13
|
+
|
|
14
|
+
# Path to examples/index.html when running from source tree
|
|
15
|
+
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@asynccontextmanager
|
|
19
|
+
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|
20
|
+
"""Application lifespan to ensure globals (DB, passkey) are initialized in each process.
|
|
21
|
+
|
|
22
|
+
Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint)
|
|
23
|
+
so that uvicorn reload / multiprocess workers inherit the settings.
|
|
24
|
+
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
|
25
|
+
"""
|
|
26
|
+
import json
|
|
27
|
+
|
|
28
|
+
from paskia import globals
|
|
29
|
+
|
|
30
|
+
config = json.loads(os.environ["PASKIA_CONFIG"])
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
|
34
|
+
await globals.init(
|
|
35
|
+
rp_id=config["rp_id"],
|
|
36
|
+
rp_name=config["rp_name"],
|
|
37
|
+
origins=config["origins"],
|
|
38
|
+
bootstrap=False,
|
|
39
|
+
)
|
|
40
|
+
except ValueError as e:
|
|
41
|
+
logging.error(f"⚠️ {e}")
|
|
42
|
+
# Re-raise to fail fast
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
# Restore info level logging after startup (suppressed during uvicorn init in dev mode)
|
|
46
|
+
if frontend.is_dev_mode():
|
|
47
|
+
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
|
48
|
+
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
|
49
|
+
|
|
50
|
+
yield
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
app = FastAPI(lifespan=lifespan)
|
|
54
|
+
|
|
55
|
+
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
|
56
|
+
app.middleware("http")(auth_host.redirect_middleware)
|
|
57
|
+
|
|
58
|
+
app.mount("/auth/api/admin/", admin.app)
|
|
59
|
+
app.mount("/auth/api/", api.app)
|
|
60
|
+
app.mount("/auth/ws/", ws.app)
|
|
61
|
+
|
|
62
|
+
# In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
|
|
63
|
+
if not frontend.is_dev_mode():
|
|
64
|
+
app.mount(
|
|
65
|
+
"/auth/assets/",
|
|
66
|
+
StaticFiles(directory=frontend.file("auth", "assets")),
|
|
67
|
+
name="assets",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.get("/auth/restricted/")
|
|
72
|
+
async def restricted_view():
|
|
73
|
+
"""Serve the restricted/authentication UI for iframe embedding."""
|
|
74
|
+
return Response(*await frontend.read("/auth/restricted/index.html"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
|
78
|
+
# / is used on a dedicated auth site, /auth/ on app domains with auth
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.get("/")
|
|
82
|
+
@app.get("/auth/")
|
|
83
|
+
async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
84
|
+
"""Serve the user profile app.
|
|
85
|
+
|
|
86
|
+
The frontend handles mode detection (host mode vs full profile) based on settings.
|
|
87
|
+
Access control is handled via APIs.
|
|
88
|
+
"""
|
|
89
|
+
return Response(*await frontend.read("/auth/index.html"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.get("/admin", include_in_schema=False)
|
|
93
|
+
@app.get("/auth/admin", include_in_schema=False)
|
|
94
|
+
async def admin_root_redirect():
|
|
95
|
+
return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.get("/admin/", include_in_schema=False)
|
|
99
|
+
async def admin_root(request: Request, auth=AUTH_COOKIE):
|
|
100
|
+
return await admin.adminapp(request, auth) # Delegated to admin app
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.get("/auth/examples/", include_in_schema=False)
|
|
104
|
+
async def examples_page():
|
|
105
|
+
"""Serve examples/index.html when running from source tree.
|
|
106
|
+
|
|
107
|
+
This provides a simple test page for API mode authentication flows
|
|
108
|
+
without depending on the Vue frontend build.
|
|
109
|
+
"""
|
|
110
|
+
index_file = _EXAMPLES_DIR / "index.html"
|
|
111
|
+
if not index_file.is_file():
|
|
112
|
+
raise HTTPException(
|
|
113
|
+
status_code=404,
|
|
114
|
+
detail="Examples not available (not running from source tree)",
|
|
115
|
+
)
|
|
116
|
+
return FileResponse(index_file, media_type="text/html")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Note: this catch-all handler must be the last route defined
|
|
120
|
+
@app.get("/{token}")
|
|
121
|
+
@app.get("/auth/{token}")
|
|
122
|
+
async def token_link(token: str):
|
|
123
|
+
"""Serve the reset app for reset tokens (password reset / device addition).
|
|
124
|
+
|
|
125
|
+
The frontend will validate the token via /auth/api/token-info.
|
|
126
|
+
"""
|
|
127
|
+
if not passphrase.is_well_formed(token):
|
|
128
|
+
raise HTTPException(status_code=404)
|
|
129
|
+
|
|
130
|
+
return Response(*await frontend.read("/int/reset/index.html"))
|