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/sansio.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebAuthn handler class that combines registration and authentication functionality.
|
|
3
|
+
|
|
4
|
+
This module provides a unified interface for WebAuthn operations including:
|
|
5
|
+
- Registration challenge generation and verification
|
|
6
|
+
- Authentication challenge generation and verification
|
|
7
|
+
- Credential validation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
import uuid7
|
|
16
|
+
from webauthn import (
|
|
17
|
+
generate_authentication_options,
|
|
18
|
+
generate_registration_options,
|
|
19
|
+
verify_authentication_response,
|
|
20
|
+
verify_registration_response,
|
|
21
|
+
)
|
|
22
|
+
from webauthn.authentication.verify_authentication_response import (
|
|
23
|
+
VerifiedAuthentication,
|
|
24
|
+
)
|
|
25
|
+
from webauthn.helpers import (
|
|
26
|
+
options_to_json,
|
|
27
|
+
parse_authentication_credential_json,
|
|
28
|
+
parse_registration_credential_json,
|
|
29
|
+
)
|
|
30
|
+
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
|
31
|
+
from webauthn.helpers.structs import (
|
|
32
|
+
AttestationConveyancePreference,
|
|
33
|
+
AuthenticationCredential,
|
|
34
|
+
AuthenticatorSelectionCriteria,
|
|
35
|
+
PublicKeyCredentialDescriptor,
|
|
36
|
+
ResidentKeyRequirement,
|
|
37
|
+
UserVerificationRequirement,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from paskia.db import Credential
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Passkey:
|
|
44
|
+
"""WebAuthn handler for registration and authentication operations."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
rp_id: str,
|
|
49
|
+
rp_name: str | None = None,
|
|
50
|
+
origins: list[str] | None = None,
|
|
51
|
+
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Initialize the WebAuthn handler.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
rp_id: Your security domain (e.g. "example.com")
|
|
58
|
+
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
|
|
59
|
+
origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]).
|
|
60
|
+
Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed.
|
|
61
|
+
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
|
|
65
|
+
"""
|
|
66
|
+
self.rp_id = rp_id
|
|
67
|
+
self.rp_name = rp_name or rp_id
|
|
68
|
+
self.allowed_origins: set[str] | None = None
|
|
69
|
+
if origins:
|
|
70
|
+
# Validate and deduplicate origins into a set for O(1) lookups
|
|
71
|
+
for o in origins:
|
|
72
|
+
self._validate_origin(o, rp_id)
|
|
73
|
+
self.allowed_origins = set(origins)
|
|
74
|
+
self.supported_pub_key_algs = supported_pub_key_algs or [
|
|
75
|
+
COSEAlgorithmIdentifier.EDDSA,
|
|
76
|
+
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
|
77
|
+
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def _validate_origin(self, origin: str, rp_id: str) -> None:
|
|
81
|
+
"""Validate an origin URL against the rp_id."""
|
|
82
|
+
hostname = urlparse(origin).hostname
|
|
83
|
+
if not hostname:
|
|
84
|
+
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
|
85
|
+
|
|
86
|
+
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def validate_origin(self, origin: str) -> str:
|
|
94
|
+
"""Validate that origin is allowed and return it.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
origin: The origin URL to validate (from WebSocket request header)
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The validated origin URL
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If origin is not in the allowed list (when origins are configured)
|
|
104
|
+
or if origin is not a valid subdomain of rp_id
|
|
105
|
+
"""
|
|
106
|
+
self._validate_origin(origin, self.rp_id)
|
|
107
|
+
if self.allowed_origins is not None and origin not in self.allowed_origins:
|
|
108
|
+
raise ValueError(f"Origin '{origin}' is not in the allowed origins list")
|
|
109
|
+
return origin
|
|
110
|
+
|
|
111
|
+
### Registration Methods ###
|
|
112
|
+
|
|
113
|
+
def reg_generate_options(
|
|
114
|
+
self,
|
|
115
|
+
user_id: UUID,
|
|
116
|
+
user_name: str,
|
|
117
|
+
credential_ids: list[bytes] | None = None,
|
|
118
|
+
origin: str | None = None,
|
|
119
|
+
**regopts,
|
|
120
|
+
) -> tuple[dict, bytes]:
|
|
121
|
+
"""
|
|
122
|
+
Generate registration options for WebAuthn registration.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
user_id: The user ID as bytes
|
|
126
|
+
user_name: The username
|
|
127
|
+
credential_ids: For an already authenticated user, a list of credential IDs
|
|
128
|
+
associated with the account. This prevents accidentally adding another
|
|
129
|
+
credential on an authenticator that already has one of the listed IDs.
|
|
130
|
+
origin: The origin URL of the application (e.g. "https://app.example.com"). Must be a subdomain or same as rp_id, with port and scheme but no path included.
|
|
131
|
+
regopts: Additional arguments to generate_registration_options.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
JSON dict containing options to be sent to client,
|
|
135
|
+
challenge bytes to keep during the registration process.
|
|
136
|
+
"""
|
|
137
|
+
options = generate_registration_options(
|
|
138
|
+
rp_id=self.rp_id,
|
|
139
|
+
rp_name=self.rp_name,
|
|
140
|
+
user_id=user_id.bytes,
|
|
141
|
+
user_name=user_name,
|
|
142
|
+
attestation=AttestationConveyancePreference.DIRECT,
|
|
143
|
+
authenticator_selection=AuthenticatorSelectionCriteria(
|
|
144
|
+
resident_key=ResidentKeyRequirement.REQUIRED,
|
|
145
|
+
user_verification=UserVerificationRequirement.PREFERRED,
|
|
146
|
+
),
|
|
147
|
+
exclude_credentials=_convert_credential_ids(credential_ids),
|
|
148
|
+
supported_pub_key_algs=self.supported_pub_key_algs,
|
|
149
|
+
**regopts,
|
|
150
|
+
)
|
|
151
|
+
return json.loads(options_to_json(options)), options.challenge
|
|
152
|
+
|
|
153
|
+
def reg_verify(
|
|
154
|
+
self,
|
|
155
|
+
response_json: dict | str,
|
|
156
|
+
expected_challenge: bytes,
|
|
157
|
+
user_uuid: UUID,
|
|
158
|
+
origin: str,
|
|
159
|
+
) -> Credential:
|
|
160
|
+
"""
|
|
161
|
+
Verify registration response.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
response_json: The credential response from the client
|
|
165
|
+
expected_challenge: The expected challenge bytes
|
|
166
|
+
user_uuid: The user's UUID
|
|
167
|
+
origin: The origin URL (required, must be pre-validated)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Registration verification result
|
|
171
|
+
"""
|
|
172
|
+
credential = parse_registration_credential_json(response_json)
|
|
173
|
+
registration = verify_registration_response(
|
|
174
|
+
credential=credential,
|
|
175
|
+
expected_challenge=expected_challenge,
|
|
176
|
+
expected_origin=origin,
|
|
177
|
+
expected_rp_id=self.rp_id,
|
|
178
|
+
)
|
|
179
|
+
return Credential(
|
|
180
|
+
uuid=uuid7.create(),
|
|
181
|
+
credential_id=credential.raw_id,
|
|
182
|
+
user_uuid=user_uuid,
|
|
183
|
+
aaguid=UUID(registration.aaguid),
|
|
184
|
+
public_key=registration.credential_public_key,
|
|
185
|
+
sign_count=registration.sign_count,
|
|
186
|
+
created_at=datetime.now(timezone.utc),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
### Authentication Methods ###
|
|
190
|
+
|
|
191
|
+
def auth_generate_options(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
user_verification_required=False,
|
|
195
|
+
credential_ids: list[bytes] | None = None,
|
|
196
|
+
**authopts,
|
|
197
|
+
) -> tuple[dict, bytes]:
|
|
198
|
+
"""
|
|
199
|
+
Generate authentication options for WebAuthn authentication.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
user_verification_required: The user will have to re-enter PIN or use biometrics for this operation. Useful when accessing security settings etc.
|
|
203
|
+
credential_ids: For an already known user, a list of credential IDs associated with the account (less prompts during authentication).
|
|
204
|
+
authopts: Additional arguments to generate_authentication_options.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Tuple of (JSON dict to be sent to client, challenge bytes to store)
|
|
208
|
+
"""
|
|
209
|
+
options = generate_authentication_options(
|
|
210
|
+
rp_id=self.rp_id,
|
|
211
|
+
user_verification=(
|
|
212
|
+
UserVerificationRequirement.REQUIRED
|
|
213
|
+
if user_verification_required
|
|
214
|
+
else UserVerificationRequirement.DISCOURAGED
|
|
215
|
+
),
|
|
216
|
+
allow_credentials=_convert_credential_ids(credential_ids),
|
|
217
|
+
**authopts,
|
|
218
|
+
)
|
|
219
|
+
return json.loads(options_to_json(options)), options.challenge
|
|
220
|
+
|
|
221
|
+
def auth_parse(self, response: dict | str) -> AuthenticationCredential:
|
|
222
|
+
return parse_authentication_credential_json(response)
|
|
223
|
+
|
|
224
|
+
def auth_verify(
|
|
225
|
+
self,
|
|
226
|
+
credential: AuthenticationCredential,
|
|
227
|
+
expected_challenge: bytes,
|
|
228
|
+
stored_cred: Credential,
|
|
229
|
+
origin: str,
|
|
230
|
+
) -> VerifiedAuthentication:
|
|
231
|
+
"""
|
|
232
|
+
Verify authentication response against locally stored credential data.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
credential: The authentication credential response from the client
|
|
236
|
+
expected_challenge: The earlier generated challenge bytes
|
|
237
|
+
stored_cred: The server stored credential record (modified by this function)
|
|
238
|
+
origin: The origin URL (required, must be pre-validated)
|
|
239
|
+
"""
|
|
240
|
+
# Verify the authentication response
|
|
241
|
+
verification = verify_authentication_response(
|
|
242
|
+
credential=credential,
|
|
243
|
+
expected_challenge=expected_challenge,
|
|
244
|
+
expected_origin=origin,
|
|
245
|
+
expected_rp_id=self.rp_id,
|
|
246
|
+
credential_public_key=stored_cred.public_key,
|
|
247
|
+
credential_current_sign_count=stored_cred.sign_count,
|
|
248
|
+
)
|
|
249
|
+
stored_cred.sign_count = verification.new_sign_count
|
|
250
|
+
now = datetime.now(timezone.utc)
|
|
251
|
+
stored_cred.last_used = now
|
|
252
|
+
if verification.user_verified:
|
|
253
|
+
stored_cred.last_verified = now
|
|
254
|
+
return verification
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _convert_credential_ids(
|
|
258
|
+
credential_ids: list[bytes] | None,
|
|
259
|
+
) -> list[PublicKeyCredentialDescriptor] | None:
|
|
260
|
+
"""A helper to convert a list of credential IDs to PublicKeyCredentialDescriptor objects, or pass through None."""
|
|
261
|
+
if credential_ids is None:
|
|
262
|
+
return None
|
|
263
|
+
return [PublicKeyCredentialDescriptor(id) for id in credential_ids]
|
paskia/util/frontend.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import mimetypes
|
|
3
|
+
import os
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
__all__ = ["path", "file", "read", "is_dev_mode"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_dev_server() -> str | None:
|
|
13
|
+
"""Get the dev server URL from environment, or None if not in dev mode."""
|
|
14
|
+
return os.environ.get("PASKIA_DEVMODE") or None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_static_dir() -> Path:
|
|
18
|
+
# Try packaged path via importlib.resources (works for wheel/installed).
|
|
19
|
+
try: # pragma: no cover - trivial path resolution
|
|
20
|
+
pkg_dir = resources.files("paskia") / "frontend-build"
|
|
21
|
+
fs_path = Path(str(pkg_dir))
|
|
22
|
+
if fs_path.is_dir():
|
|
23
|
+
return fs_path
|
|
24
|
+
except Exception: # pragma: no cover - defensive
|
|
25
|
+
pass
|
|
26
|
+
# Fallback for editable/development before build.
|
|
27
|
+
return Path(__file__).parent.parent / "frontend-build"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
path: Path = _resolve_static_dir()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def file(*parts: str) -> Path:
|
|
34
|
+
"""Return a child path under the static root."""
|
|
35
|
+
return path.joinpath(*parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_dev_mode() -> bool:
|
|
39
|
+
"""Check if we're running in dev mode (Vite frontend server)."""
|
|
40
|
+
return bool(_get_dev_server())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
|
44
|
+
"""Read file content and return response tuple.
|
|
45
|
+
|
|
46
|
+
In dev mode, fetches from the Vite dev server.
|
|
47
|
+
In production, reads from the static build directory.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Tuple of (content, status_code, headers) suitable for
|
|
54
|
+
FastAPI Response(*args) or Sanic raw response.
|
|
55
|
+
"""
|
|
56
|
+
if is_dev_mode():
|
|
57
|
+
dev_server = _get_dev_server()
|
|
58
|
+
async with httpx.AsyncClient() as client:
|
|
59
|
+
resp = await client.get(f"{dev_server}{filepath}")
|
|
60
|
+
resp.raise_for_status()
|
|
61
|
+
mime = resp.headers.get("content-type", "application/octet-stream")
|
|
62
|
+
# Strip charset suffix if present
|
|
63
|
+
mime = mime.split(";")[0].strip()
|
|
64
|
+
return resp.content, resp.status_code, {"content-type": mime}
|
|
65
|
+
else:
|
|
66
|
+
# Production: read from static build
|
|
67
|
+
file_path = path / filepath.lstrip("/")
|
|
68
|
+
content = await _read_file_async(file_path)
|
|
69
|
+
mime, _ = mimetypes.guess_type(str(file_path))
|
|
70
|
+
return content, 200, {"content-type": mime or "application/octet-stream"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _read_file_async(file_path: Path) -> bytes:
|
|
74
|
+
"""Read file asynchronously using asyncio.to_thread."""
|
|
75
|
+
return await asyncio.to_thread(file_path.read_bytes)
|
paskia/util/hostutil.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Utilities for determining the auth UI host and base URLs."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@lru_cache(maxsize=1)
|
|
10
|
+
def _load_config() -> dict:
|
|
11
|
+
"""Load PASKIA_CONFIG JSON."""
|
|
12
|
+
config_json = os.getenv("PASKIA_CONFIG")
|
|
13
|
+
if not config_json:
|
|
14
|
+
return {}
|
|
15
|
+
return json.loads(config_json)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_root_mode() -> bool:
|
|
19
|
+
return _load_config().get("auth_host") is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dedicated_auth_host() -> str | None:
|
|
23
|
+
"""Return configured auth_host netloc, or None."""
|
|
24
|
+
auth_host = _load_config().get("auth_host")
|
|
25
|
+
if not auth_host:
|
|
26
|
+
return None
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
|
30
|
+
return parsed.netloc or parsed.path or None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ui_base_path() -> str:
|
|
34
|
+
return "/" if is_root_mode() else "/auth/"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def auth_site_url() -> str:
|
|
38
|
+
"""Return the base URL for the auth site UI (computed at startup)."""
|
|
39
|
+
cfg = _load_config()
|
|
40
|
+
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def reset_link_url(token: str) -> str:
|
|
44
|
+
"""Generate a reset link URL for the given token."""
|
|
45
|
+
return f"{auth_site_url()}{token}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_origin(origin: str) -> str:
|
|
49
|
+
"""Normalize an origin URL by adding https:// if no scheme is present."""
|
|
50
|
+
if "://" not in origin:
|
|
51
|
+
return f"https://{origin}"
|
|
52
|
+
return origin
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reload_config() -> None:
|
|
56
|
+
_load_config.cache_clear()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize_host(raw_host: str | None) -> str | None:
|
|
60
|
+
"""Normalize a Host header preserving port (exact match required)."""
|
|
61
|
+
if not raw_host:
|
|
62
|
+
return None
|
|
63
|
+
candidate = raw_host.strip()
|
|
64
|
+
if not candidate:
|
|
65
|
+
return None
|
|
66
|
+
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
|
|
67
|
+
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
|
|
68
|
+
netloc = parsed.netloc or parsed.path or ""
|
|
69
|
+
# Strip IPv6 brackets around host part but retain port suffix.
|
|
70
|
+
if netloc.startswith("["):
|
|
71
|
+
# format: [ipv6]:port or [ipv6]
|
|
72
|
+
if "]" in netloc:
|
|
73
|
+
host_part, _, rest = netloc.partition("]")
|
|
74
|
+
port_part = rest.lstrip(":")
|
|
75
|
+
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
|
|
76
|
+
return netloc.lower() or None
|
paskia/util/htmlutil.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Utility functions for HTML manipulation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
|
|
7
|
+
"""Patch HTML by adding data attributes to the <html> tag.
|
|
8
|
+
|
|
9
|
+
If an <html> tag exists, adds data attributes to it.
|
|
10
|
+
If no <html> tag exists, prepends one with the data attributes.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
html: The HTML content as bytes
|
|
14
|
+
**data_attrs: Key-value pairs for data attributes (e.g., mode='reauth')
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Modified HTML as bytes
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
>>> patch_html_data_attrs(b'<html><body>test</body></html>', mode='reauth')
|
|
21
|
+
b'<html data-mode="reauth"><body>test</body></html>'
|
|
22
|
+
|
|
23
|
+
>>> patch_html_data_attrs(b'<body>test</body>', mode='reauth')
|
|
24
|
+
b'<html data-mode="reauth"><body>test</body>'
|
|
25
|
+
"""
|
|
26
|
+
if not data_attrs:
|
|
27
|
+
return html
|
|
28
|
+
|
|
29
|
+
html_str = html.decode("utf-8")
|
|
30
|
+
|
|
31
|
+
# Build the data attributes string
|
|
32
|
+
attrs_str = " ".join(f'data-{key}="{value}"' for key, value in data_attrs.items())
|
|
33
|
+
|
|
34
|
+
# Check if there's an <html> tag (case-insensitive, may have existing attributes)
|
|
35
|
+
html_tag_pattern = re.compile(r"<html([^>]*)>", re.IGNORECASE)
|
|
36
|
+
match = html_tag_pattern.search(html_str)
|
|
37
|
+
|
|
38
|
+
if match:
|
|
39
|
+
# Insert data attributes into existing <html> tag
|
|
40
|
+
existing_attrs = match.group(1)
|
|
41
|
+
new_tag = f"<html{existing_attrs} {attrs_str}>"
|
|
42
|
+
html_str = html_tag_pattern.sub(new_tag, html_str, count=1)
|
|
43
|
+
else:
|
|
44
|
+
# Prepend <html> tag with data attributes
|
|
45
|
+
html_str = f"<html {attrs_str}>" + html_str
|
|
46
|
+
|
|
47
|
+
return html_str.encode("utf-8")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
|
|
3
|
+
from paskia.util.wordlist import words
|
|
4
|
+
|
|
5
|
+
N_WORDS = 5
|
|
6
|
+
N_WORDS_SHORT = 3
|
|
7
|
+
|
|
8
|
+
wset = set(words)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate(n=N_WORDS, sep="."):
|
|
12
|
+
"""Generate a password of random words without repeating any word."""
|
|
13
|
+
wl = words.copy()
|
|
14
|
+
return sep.join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_well_formed(passphrase: str, n=N_WORDS, sep=".") -> bool:
|
|
18
|
+
"""Check if the passphrase is well-formed according to the regex pattern."""
|
|
19
|
+
p = passphrase.split(sep)
|
|
20
|
+
return len(p) == n and all(w in wset for w in passphrase.split("."))
|
paskia/util/permutil.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Minimal permission helpers with '*' wildcard support (no DB expansion)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from fnmatch import fnmatchcase
|
|
5
|
+
|
|
6
|
+
from paskia.globals import db
|
|
7
|
+
from paskia.util.hostutil import normalize_host
|
|
8
|
+
from paskia.util.tokens import session_key
|
|
9
|
+
|
|
10
|
+
__all__ = ["has_any", "has_all", "session_context"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _match(perms: set[str], patterns: Sequence[str]):
|
|
14
|
+
return (
|
|
15
|
+
any(fnmatchcase(p, pat) for p in perms) if "*" in pat else pat in perms
|
|
16
|
+
for pat in patterns
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def has_any(ctx, patterns: Sequence[str]) -> bool:
|
|
21
|
+
return any(_match(ctx.role.permissions, patterns)) if ctx else False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def has_all(ctx, patterns: Sequence[str]) -> bool:
|
|
25
|
+
return all(_match(ctx.role.permissions, patterns)) if ctx else False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def session_context(auth: str | None, host: str | None = None):
|
|
29
|
+
if not auth:
|
|
30
|
+
return None
|
|
31
|
+
normalized_host = normalize_host(host) if host else None
|
|
32
|
+
return await db.instance.get_session_context(session_key(auth), normalized_host)
|
paskia/util/pow.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proof of Work utility using PBKDF2-SHA512.
|
|
3
|
+
|
|
4
|
+
The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
|
5
|
+
output with a zero first byte. Each work unit requires finding one such nonce.
|
|
6
|
+
All valid nonces are concatenated into a solution for server verification.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import secrets
|
|
11
|
+
|
|
12
|
+
EASY = 2 # Around 0.25s
|
|
13
|
+
NORMAL = 8 # Around 1s
|
|
14
|
+
HARD = 32 # Around 4s
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_challenge() -> bytes:
|
|
18
|
+
"""Generate a random 8-byte challenge."""
|
|
19
|
+
return secrets.token_bytes(8)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_pow(challenge: bytes, solution: bytes, work: int = NORMAL) -> None:
|
|
23
|
+
"""Verify a Proof of Work solution.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
challenge: 8-byte server-provided challenge
|
|
27
|
+
solution: Concatenated 8-byte nonces (8 * work bytes)
|
|
28
|
+
work: Number of work units expected
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If the solution is invalid
|
|
32
|
+
"""
|
|
33
|
+
if len(challenge) != 8:
|
|
34
|
+
raise ValueError("Invalid challenge length")
|
|
35
|
+
|
|
36
|
+
if len(solution) != 8 * work:
|
|
37
|
+
raise ValueError("Invalid solution length")
|
|
38
|
+
|
|
39
|
+
# Verify each work unit - check that PBKDF2 output starts with 0x00
|
|
40
|
+
for i in range(work):
|
|
41
|
+
nonce = solution[i * 8 : (i + 1) * 8]
|
|
42
|
+
# Require first byte of PBKDF2-SHA512 to be zero
|
|
43
|
+
result = hashlib.pbkdf2_hmac("sha512", challenge, nonce, 128, 2)
|
|
44
|
+
if result[0] or result[1] & 0x07:
|
|
45
|
+
raise ValueError("Invalid PoW solution")
|
paskia/util/querysafe.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
_SAFE_RE = re.compile(r"^[A-Za-z0-9:._~-]+$")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def assert_safe(value: str, *, field: str = "value") -> None:
|
|
7
|
+
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
|
|
8
|
+
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["assert_safe"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Utility functions for session validation and checking."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from paskia.db import SessionContext
|
|
6
|
+
from paskia.util.timeutil import parse_duration
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
|
10
|
+
"""Check if a session satisfies the max_age requirement.
|
|
11
|
+
|
|
12
|
+
Uses the credential's last_used timestamp to determine authentication age,
|
|
13
|
+
since session renewal can happen without re-authentication.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
ctx: The session context containing session and credential info
|
|
17
|
+
max_age: Maximum age string (e.g., "5m", "1h", "30s") or None
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
True if authentication is recent enough or max_age is None, False if too old
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ValueError: If max_age format is invalid
|
|
24
|
+
"""
|
|
25
|
+
if not max_age:
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
max_age_delta = parse_duration(max_age)
|
|
29
|
+
|
|
30
|
+
# Use credential's last_used time if available, fall back to session renewed
|
|
31
|
+
if ctx.credential and ctx.credential.last_used:
|
|
32
|
+
auth_time = ctx.credential.last_used
|
|
33
|
+
else:
|
|
34
|
+
auth_time = ctx.session.renewed
|
|
35
|
+
|
|
36
|
+
time_since_auth = datetime.now(timezone.utc) - auth_time
|
|
37
|
+
return time_since_auth <= max_age_delta
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Startup configuration box formatting utilities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from sys import stderr
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from paskia._version import __version__
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from paskia.config import PaskiaConfig
|
|
11
|
+
|
|
12
|
+
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def line(text: str = "") -> str:
|
|
16
|
+
"""Format a line inside the box with proper padding, truncating if needed."""
|
|
17
|
+
if len(text) > BOX_WIDTH:
|
|
18
|
+
text = text[: BOX_WIDTH - 1] + "…"
|
|
19
|
+
return f"┃ {text:<{BOX_WIDTH}} ┃\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def top() -> str:
|
|
23
|
+
return "┏" + "━" * (BOX_WIDTH + 2) + "┓\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def bottom() -> str:
|
|
27
|
+
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def print_startup_config(config: "PaskiaConfig") -> None:
|
|
31
|
+
"""Print server configuration on startup."""
|
|
32
|
+
lines = [top()]
|
|
33
|
+
lines.append(line(" ▄▄▄▄▄"))
|
|
34
|
+
lines.append(line("█ █ Paskia " + __version__))
|
|
35
|
+
lines.append(line("█ █▄▄▄▄▄▄▄▄▄▄▄▄"))
|
|
36
|
+
lines.append(line("█ █▀▀▀▀█▀▀█▀▀█ " + config.site_url + config.site_path))
|
|
37
|
+
lines.append(line(" ▀▀▀▀▀"))
|
|
38
|
+
|
|
39
|
+
# Format auth host section
|
|
40
|
+
if config.auth_host:
|
|
41
|
+
lines.append(line(f"Auth Host: {config.auth_host}"))
|
|
42
|
+
|
|
43
|
+
# Show frontend URL if in dev mode
|
|
44
|
+
devmode = os.environ.get("PASKIA_DEVMODE")
|
|
45
|
+
if devmode:
|
|
46
|
+
lines.append(line(f"Dev Frontend: {devmode}"))
|
|
47
|
+
|
|
48
|
+
# Format listen address with scheme
|
|
49
|
+
if config.uds:
|
|
50
|
+
listen = f"unix:{config.uds}"
|
|
51
|
+
elif config.host:
|
|
52
|
+
listen = f"http://{config.host}:{config.port}"
|
|
53
|
+
else:
|
|
54
|
+
listen = f"http://0.0.0.0:{config.port} + [::]:{config.port}"
|
|
55
|
+
lines.append(line(f"Backend: {listen}"))
|
|
56
|
+
|
|
57
|
+
# Relying Party line (omit name if same as id)
|
|
58
|
+
rp_id = config.rp_id
|
|
59
|
+
rp_name = config.rp_name
|
|
60
|
+
if rp_name and rp_name != rp_id:
|
|
61
|
+
lines.append(line(f"Relying Party: {rp_id} ({rp_name})"))
|
|
62
|
+
else:
|
|
63
|
+
lines.append(line(f"Relying Party: {rp_id}"))
|
|
64
|
+
|
|
65
|
+
# Format origins section
|
|
66
|
+
allowed = config.origins
|
|
67
|
+
if allowed:
|
|
68
|
+
lines.append(line("Permitted Origins:"))
|
|
69
|
+
for origin in sorted(allowed):
|
|
70
|
+
lines.append(line(f" - {origin}"))
|
|
71
|
+
else:
|
|
72
|
+
lines.append(line(f"Origin: {rp_id} and all subdomains allowed"))
|
|
73
|
+
|
|
74
|
+
lines.append(bottom())
|
|
75
|
+
stderr.write("".join(lines))
|