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/migrate/__init__.py
CHANGED
|
@@ -11,13 +11,28 @@ Or via the CLI entry point (if installed):
|
|
|
11
11
|
paskia-migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
+
import argparse
|
|
14
15
|
import asyncio
|
|
15
|
-
|
|
16
|
+
import re
|
|
17
|
+
from datetime import UTC, datetime
|
|
16
18
|
from uuid import UUID
|
|
17
19
|
|
|
18
20
|
import base64url
|
|
21
|
+
import uuid7
|
|
22
|
+
from sqlalchemy import select
|
|
19
23
|
|
|
20
24
|
from paskia.authsession import EXPIRES
|
|
25
|
+
from paskia.db.jsonl import JsonlStore
|
|
26
|
+
from paskia.db.structs import (
|
|
27
|
+
DB,
|
|
28
|
+
Credential,
|
|
29
|
+
Org,
|
|
30
|
+
Permission,
|
|
31
|
+
ResetToken,
|
|
32
|
+
Role,
|
|
33
|
+
Session,
|
|
34
|
+
User,
|
|
35
|
+
)
|
|
21
36
|
|
|
22
37
|
from .sql import (
|
|
23
38
|
DB as SQLDB,
|
|
@@ -47,30 +62,14 @@ async def migrate_from_sql(
|
|
|
47
62
|
sql_db_path: SQLAlchemy connection string for the source SQL database
|
|
48
63
|
json_db_path: Path for the destination JSONL file
|
|
49
64
|
"""
|
|
50
|
-
# Import here to avoid circular imports and to not require JSON db at import time
|
|
51
|
-
import re
|
|
52
|
-
|
|
53
|
-
import uuid7
|
|
54
|
-
from sqlalchemy import select
|
|
55
|
-
|
|
56
|
-
from paskia.db.operations import DB as JSONDB
|
|
57
|
-
from paskia.db.structs import (
|
|
58
|
-
_CredentialData,
|
|
59
|
-
_OrgData,
|
|
60
|
-
_PermissionData,
|
|
61
|
-
_ResetTokenData,
|
|
62
|
-
_RoleData,
|
|
63
|
-
_SessionData,
|
|
64
|
-
_UserData,
|
|
65
|
-
)
|
|
66
|
-
|
|
67
65
|
# Initialize source SQL database
|
|
68
66
|
sql_db = SQLDB(sql_db_path)
|
|
69
67
|
await sql_db.init_db()
|
|
70
68
|
|
|
71
69
|
# Initialize destination JSON database (fresh, don't load existing)
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
db = DB()
|
|
71
|
+
store = JsonlStore(db, json_db_path)
|
|
72
|
+
db._store = store
|
|
74
73
|
|
|
75
74
|
print(f"Migrating from {sql_db_path} to {json_db_path}...")
|
|
76
75
|
|
|
@@ -90,11 +89,13 @@ async def migrate_from_sql(
|
|
|
90
89
|
# Migrate permissions with UUID keys and scope field
|
|
91
90
|
# Always create exactly one common auth:org:admin permission for all org admin needs
|
|
92
91
|
org_admin_perm_uuid: UUID = uuid7.create()
|
|
93
|
-
|
|
92
|
+
org_admin_perm = Permission(
|
|
94
93
|
scope="auth:org:admin",
|
|
95
94
|
display_name="Org Admin",
|
|
96
95
|
orgs={},
|
|
97
96
|
)
|
|
97
|
+
org_admin_perm.uuid = org_admin_perm_uuid
|
|
98
|
+
db.permissions[org_admin_perm_uuid] = org_admin_perm
|
|
98
99
|
|
|
99
100
|
# Mapping from old permission ID to new permission UUID
|
|
100
101
|
perm_id_to_uuid: dict[str, UUID] = {}
|
|
@@ -113,11 +114,13 @@ async def migrate_from_sql(
|
|
|
113
114
|
|
|
114
115
|
# Regular permission - create with UUID key
|
|
115
116
|
perm_uuid: UUID = uuid7.create()
|
|
116
|
-
|
|
117
|
+
new_perm = Permission(
|
|
117
118
|
scope=perm.id, # Old ID becomes the scope
|
|
118
119
|
display_name=perm.display_name,
|
|
119
120
|
orgs={},
|
|
120
121
|
)
|
|
122
|
+
new_perm.uuid = perm_uuid
|
|
123
|
+
db.permissions[perm_uuid] = new_perm
|
|
121
124
|
perm_id_to_uuid[perm.id] = perm_uuid
|
|
122
125
|
print(
|
|
123
126
|
f" Migrated {len(permissions)} permissions (with {len(org_admin_uuids)} org-specific admins consolidated to auth:org:admin)"
|
|
@@ -127,16 +130,16 @@ async def migrate_from_sql(
|
|
|
127
130
|
orgs = await sql_db.list_organizations()
|
|
128
131
|
for org in orgs:
|
|
129
132
|
org_key: UUID = org.uuid
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
new_org = Org(display_name=org.display_name)
|
|
134
|
+
new_org.uuid = org_key
|
|
135
|
+
db.orgs[org_key] = new_org
|
|
133
136
|
# Update permissions to allow this org to grant them (by UUID)
|
|
134
137
|
for old_perm_id in org.permissions:
|
|
135
138
|
perm_uuid = perm_id_to_uuid.get(old_perm_id)
|
|
136
|
-
if perm_uuid and perm_uuid in
|
|
137
|
-
|
|
139
|
+
if perm_uuid and perm_uuid in db.permissions:
|
|
140
|
+
db.permissions[perm_uuid].orgs[org_key] = True
|
|
138
141
|
# Ensure every org can grant auth:org:admin
|
|
139
|
-
|
|
142
|
+
db.permissions[org_admin_perm_uuid].orgs[org_key] = True
|
|
140
143
|
print(f" Migrated {len(orgs)} organizations")
|
|
141
144
|
|
|
142
145
|
# Migrate roles - convert old permission IDs to UUIDs
|
|
@@ -150,11 +153,13 @@ async def migrate_from_sql(
|
|
|
150
153
|
perm_uuid = perm_id_to_uuid.get(old_perm_id)
|
|
151
154
|
if perm_uuid:
|
|
152
155
|
new_permissions[perm_uuid] = True
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
new_role = Role(
|
|
157
|
+
org_uuid=role.org_uuid,
|
|
155
158
|
display_name=role.display_name,
|
|
156
159
|
permissions=new_permissions,
|
|
157
160
|
)
|
|
161
|
+
new_role.uuid = role_key
|
|
162
|
+
db.roles[role_key] = new_role
|
|
158
163
|
role_count += 1
|
|
159
164
|
print(f" Migrated {role_count} roles")
|
|
160
165
|
|
|
@@ -163,15 +168,17 @@ async def migrate_from_sql(
|
|
|
163
168
|
result = await session.execute(select(UserModel))
|
|
164
169
|
user_models = result.scalars().all()
|
|
165
170
|
for um in user_models:
|
|
166
|
-
|
|
167
|
-
user_key: UUID =
|
|
168
|
-
|
|
169
|
-
display_name=
|
|
170
|
-
|
|
171
|
-
created_at=
|
|
172
|
-
last_seen=
|
|
173
|
-
visits=
|
|
171
|
+
legacy_user = um.as_dataclass()
|
|
172
|
+
user_key: UUID = legacy_user.uuid
|
|
173
|
+
new_user = User(
|
|
174
|
+
display_name=legacy_user.display_name,
|
|
175
|
+
role_uuid=legacy_user.role_uuid,
|
|
176
|
+
created_at=legacy_user.created_at or datetime.now(UTC),
|
|
177
|
+
last_seen=legacy_user.last_seen,
|
|
178
|
+
visits=legacy_user.visits,
|
|
174
179
|
)
|
|
180
|
+
new_user.uuid = user_key
|
|
181
|
+
db.users[user_key] = new_user
|
|
175
182
|
print(f" Migrated {len(user_models)} users")
|
|
176
183
|
|
|
177
184
|
# Migrate credentials
|
|
@@ -179,18 +186,20 @@ async def migrate_from_sql(
|
|
|
179
186
|
result = await session.execute(select(CredentialModel))
|
|
180
187
|
cred_models = result.scalars().all()
|
|
181
188
|
for cm in cred_models:
|
|
182
|
-
|
|
183
|
-
cred_key: UUID =
|
|
184
|
-
|
|
185
|
-
credential_id=
|
|
186
|
-
|
|
187
|
-
aaguid=
|
|
188
|
-
public_key=
|
|
189
|
-
sign_count=
|
|
190
|
-
created_at=
|
|
191
|
-
last_used=
|
|
192
|
-
last_verified=
|
|
189
|
+
legacy_cred = cm.as_dataclass()
|
|
190
|
+
cred_key: UUID = legacy_cred.uuid
|
|
191
|
+
new_cred = Credential(
|
|
192
|
+
credential_id=legacy_cred.credential_id,
|
|
193
|
+
user_uuid=legacy_cred.user_uuid,
|
|
194
|
+
aaguid=legacy_cred.aaguid,
|
|
195
|
+
public_key=legacy_cred.public_key,
|
|
196
|
+
sign_count=legacy_cred.sign_count,
|
|
197
|
+
created_at=legacy_cred.created_at,
|
|
198
|
+
last_used=legacy_cred.last_used,
|
|
199
|
+
last_verified=legacy_cred.last_verified,
|
|
193
200
|
)
|
|
201
|
+
new_cred.uuid = cred_key
|
|
202
|
+
db.credentials[cred_key] = new_cred
|
|
194
203
|
print(f" Migrated {len(cred_models)} credentials")
|
|
195
204
|
|
|
196
205
|
# Migrate sessions
|
|
@@ -207,9 +216,9 @@ async def migrate_from_sql(
|
|
|
207
216
|
else:
|
|
208
217
|
# Already in new format or unknown - try to use as-is
|
|
209
218
|
session_key = base64url.enc(old_key[:12])
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
219
|
+
db.sessions[session_key] = Session(
|
|
220
|
+
user_uuid=sess.user_uuid,
|
|
221
|
+
credential_uuid=sess.credential_uuid,
|
|
213
222
|
host=sess.host,
|
|
214
223
|
ip=sess.ip,
|
|
215
224
|
user_agent=sess.user_agent,
|
|
@@ -231,26 +240,24 @@ async def migrate_from_sql(
|
|
|
231
240
|
else:
|
|
232
241
|
# Already in new format or unknown - truncate to 9 bytes
|
|
233
242
|
token_key = old_key[:9]
|
|
234
|
-
|
|
235
|
-
|
|
243
|
+
db.reset_tokens[token_key] = ResetToken(
|
|
244
|
+
user_uuid=token.user_uuid,
|
|
236
245
|
expiry=token.expiry,
|
|
237
246
|
token_type=token.token_type,
|
|
238
247
|
)
|
|
239
248
|
print(f" Migrated {len(token_models)} reset tokens")
|
|
240
249
|
|
|
241
|
-
# Queue and flush all changes
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
from paskia.db.jsonl import flush_changes
|
|
250
|
+
# Queue and flush all changes using the transaction mechanism
|
|
251
|
+
with db.transaction("migrate:sql"):
|
|
252
|
+
pass # All data already added to _data, transaction commits on exit
|
|
245
253
|
|
|
246
|
-
await
|
|
254
|
+
await store.flush()
|
|
247
255
|
|
|
248
256
|
print("Migration complete!")
|
|
249
257
|
|
|
250
258
|
|
|
251
259
|
def main():
|
|
252
260
|
"""CLI entry point for migration."""
|
|
253
|
-
import argparse
|
|
254
261
|
|
|
255
262
|
parser = argparse.ArgumentParser(
|
|
256
263
|
description="Migrate Paskia database from SQL to JSON"
|
paskia/migrate/sql.py
CHANGED
|
@@ -9,7 +9,7 @@ DO NOT use this module for new code. Use paskia.db instead.
|
|
|
9
9
|
|
|
10
10
|
from contextlib import asynccontextmanager
|
|
11
11
|
from dataclasses import dataclass
|
|
12
|
-
from datetime import
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
13
|
from uuid import UUID
|
|
14
14
|
|
|
15
15
|
from sqlalchemy import (
|
|
@@ -25,31 +25,62 @@ from sqlalchemy.dialects.sqlite import BLOB
|
|
|
25
25
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
26
26
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
27
27
|
|
|
28
|
-
from paskia.db import (
|
|
29
|
-
Credential,
|
|
30
|
-
Org,
|
|
31
|
-
ResetToken,
|
|
32
|
-
Role,
|
|
33
|
-
User,
|
|
34
|
-
)
|
|
35
28
|
|
|
29
|
+
# Legacy User class for SQL schema (uses 'role_uuid' not 'role')
|
|
30
|
+
@dataclass
|
|
31
|
+
class _LegacyUser:
|
|
32
|
+
"""User as stored in the old SQL schema with role_uuid field."""
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
uuid: UUID
|
|
35
|
+
display_name: str
|
|
36
|
+
role_uuid: UUID
|
|
37
|
+
created_at: datetime | None = None
|
|
38
|
+
last_seen: datetime | None = None
|
|
39
|
+
visits: int = 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Legacy Credential class for SQL schema (uses 'user_uuid' not 'user')
|
|
38
43
|
@dataclass
|
|
39
|
-
class
|
|
40
|
-
"""
|
|
44
|
+
class _LegacyCredential:
|
|
45
|
+
"""Credential as stored in the old SQL schema with user_uuid field."""
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
uuid: UUID
|
|
48
|
+
credential_id: bytes
|
|
49
|
+
user_uuid: UUID
|
|
50
|
+
aaguid: UUID
|
|
51
|
+
public_key: bytes
|
|
52
|
+
sign_count: int
|
|
53
|
+
created_at: datetime
|
|
54
|
+
last_used: datetime | None = None
|
|
55
|
+
last_verified: datetime | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Legacy Role class for SQL schema (uses 'org_uuid' not 'org')
|
|
59
|
+
@dataclass
|
|
60
|
+
class _LegacyRole:
|
|
61
|
+
"""Role as stored in the old SQL schema with org_uuid field."""
|
|
62
|
+
|
|
63
|
+
uuid: UUID
|
|
64
|
+
org_uuid: UUID
|
|
43
65
|
display_name: str
|
|
66
|
+
permissions: list[str] | None = None
|
|
44
67
|
|
|
45
68
|
|
|
46
|
-
|
|
69
|
+
# Legacy Org class for SQL schema (has mutable permissions/roles lists)
|
|
70
|
+
@dataclass
|
|
71
|
+
class _LegacyOrg:
|
|
72
|
+
"""Org as stored in the old SQL schema with mutable permissions/roles."""
|
|
73
|
+
|
|
74
|
+
uuid: UUID
|
|
75
|
+
display_name: str
|
|
76
|
+
permissions: list[str] | None = None
|
|
77
|
+
roles: list[_LegacyRole] | None = None
|
|
47
78
|
|
|
48
79
|
|
|
49
|
-
#
|
|
80
|
+
# Legacy Session class for SQL schema (uses 'key' as field, 'user_uuid', 'credential_uuid')
|
|
50
81
|
@dataclass
|
|
51
|
-
class
|
|
52
|
-
"""Session as stored in the old SQL schema
|
|
82
|
+
class _LegacySession:
|
|
83
|
+
"""Session as stored in the old SQL schema."""
|
|
53
84
|
|
|
54
85
|
key: bytes
|
|
55
86
|
user_uuid: UUID
|
|
@@ -60,12 +91,35 @@ class _SqlSession:
|
|
|
60
91
|
renewed: datetime
|
|
61
92
|
|
|
62
93
|
|
|
94
|
+
# Legacy ResetToken class for SQL schema (uses 'key' as field, 'user_uuid')
|
|
95
|
+
@dataclass
|
|
96
|
+
class _LegacyResetToken:
|
|
97
|
+
"""ResetToken as stored in the old SQL schema."""
|
|
98
|
+
|
|
99
|
+
key: bytes
|
|
100
|
+
user_uuid: UUID
|
|
101
|
+
token_type: str
|
|
102
|
+
expiry: datetime
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Local Permission class for SQL schema (uses 'id' not 'uuid' + 'scope')
|
|
106
|
+
@dataclass
|
|
107
|
+
class SqlPermission:
|
|
108
|
+
"""Permission as stored in the old SQL schema with id field."""
|
|
109
|
+
|
|
110
|
+
id: str
|
|
111
|
+
display_name: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
|
|
115
|
+
|
|
116
|
+
|
|
63
117
|
def _normalize_dt(value: datetime | None) -> datetime | None:
|
|
64
118
|
if value is None:
|
|
65
119
|
return None
|
|
66
120
|
if value.tzinfo is None:
|
|
67
|
-
return value.replace(tzinfo=
|
|
68
|
-
return value.astimezone(
|
|
121
|
+
return value.replace(tzinfo=UTC)
|
|
122
|
+
return value.astimezone(UTC)
|
|
69
123
|
|
|
70
124
|
|
|
71
125
|
class Base(DeclarativeBase):
|
|
@@ -80,10 +134,13 @@ class OrgModel(Base):
|
|
|
80
134
|
|
|
81
135
|
def as_dataclass(self):
|
|
82
136
|
# Base Org without permissions/roles (filled by data accessors)
|
|
83
|
-
return
|
|
137
|
+
return _LegacyOrg(
|
|
138
|
+
uuid=UUID(bytes=self.uuid),
|
|
139
|
+
display_name=self.display_name,
|
|
140
|
+
)
|
|
84
141
|
|
|
85
142
|
@staticmethod
|
|
86
|
-
def from_dataclass(org:
|
|
143
|
+
def from_dataclass(org: _LegacyOrg):
|
|
87
144
|
return OrgModel(uuid=org.uuid.bytes, display_name=org.display_name)
|
|
88
145
|
|
|
89
146
|
|
|
@@ -98,14 +155,14 @@ class RoleModel(Base):
|
|
|
98
155
|
|
|
99
156
|
def as_dataclass(self):
|
|
100
157
|
# Base Role without permissions (filled by data accessors)
|
|
101
|
-
return
|
|
158
|
+
return _LegacyRole(
|
|
102
159
|
uuid=UUID(bytes=self.uuid),
|
|
103
160
|
org_uuid=UUID(bytes=self.org_uuid),
|
|
104
161
|
display_name=self.display_name,
|
|
105
162
|
)
|
|
106
163
|
|
|
107
164
|
@staticmethod
|
|
108
|
-
def from_dataclass(role:
|
|
165
|
+
def from_dataclass(role: _LegacyRole):
|
|
109
166
|
return RoleModel(
|
|
110
167
|
uuid=role.uuid.bytes,
|
|
111
168
|
org_uuid=role.org_uuid.bytes,
|
|
@@ -122,15 +179,15 @@ class UserModel(Base):
|
|
|
122
179
|
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
|
|
123
180
|
)
|
|
124
181
|
created_at: Mapped[datetime] = mapped_column(
|
|
125
|
-
DateTime(timezone=True), default=lambda: datetime.now(
|
|
182
|
+
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
|
126
183
|
)
|
|
127
184
|
last_seen: Mapped[datetime | None] = mapped_column(
|
|
128
185
|
DateTime(timezone=True), nullable=True
|
|
129
186
|
)
|
|
130
187
|
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
131
188
|
|
|
132
|
-
def as_dataclass(self) ->
|
|
133
|
-
return
|
|
189
|
+
def as_dataclass(self) -> "_LegacyUser":
|
|
190
|
+
return _LegacyUser(
|
|
134
191
|
uuid=UUID(bytes=self.uuid),
|
|
135
192
|
display_name=self.display_name,
|
|
136
193
|
role_uuid=UUID(bytes=self.role_uuid),
|
|
@@ -140,12 +197,12 @@ class UserModel(Base):
|
|
|
140
197
|
)
|
|
141
198
|
|
|
142
199
|
@staticmethod
|
|
143
|
-
def from_dataclass(user:
|
|
200
|
+
def from_dataclass(user: "_LegacyUser"):
|
|
144
201
|
return UserModel(
|
|
145
202
|
uuid=user.uuid.bytes,
|
|
146
203
|
display_name=user.display_name,
|
|
147
204
|
role_uuid=user.role_uuid.bytes,
|
|
148
|
-
created_at=user.created_at or datetime.now(
|
|
205
|
+
created_at=user.created_at or datetime.now(UTC),
|
|
149
206
|
last_seen=user.last_seen,
|
|
150
207
|
visits=user.visits,
|
|
151
208
|
)
|
|
@@ -165,7 +222,7 @@ class CredentialModel(Base):
|
|
|
165
222
|
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
|
|
166
223
|
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
167
224
|
created_at: Mapped[datetime] = mapped_column(
|
|
168
|
-
DateTime(timezone=True), default=lambda: datetime.now(
|
|
225
|
+
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
|
169
226
|
)
|
|
170
227
|
last_used: Mapped[datetime | None] = mapped_column(
|
|
171
228
|
DateTime(timezone=True), nullable=True
|
|
@@ -175,7 +232,7 @@ class CredentialModel(Base):
|
|
|
175
232
|
)
|
|
176
233
|
|
|
177
234
|
def as_dataclass(self):
|
|
178
|
-
return
|
|
235
|
+
return _LegacyCredential(
|
|
179
236
|
uuid=UUID(bytes=self.uuid),
|
|
180
237
|
credential_id=self.credential_id,
|
|
181
238
|
user_uuid=UUID(bytes=self.user_uuid),
|
|
@@ -205,12 +262,12 @@ class SessionModel(Base):
|
|
|
205
262
|
user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
206
263
|
renewed: Mapped[datetime] = mapped_column(
|
|
207
264
|
DateTime(timezone=True),
|
|
208
|
-
default=lambda: datetime.now(
|
|
265
|
+
default=lambda: datetime.now(UTC),
|
|
209
266
|
nullable=False,
|
|
210
267
|
)
|
|
211
268
|
|
|
212
269
|
def as_dataclass(self):
|
|
213
|
-
return
|
|
270
|
+
return _LegacySession(
|
|
214
271
|
key=self.key,
|
|
215
272
|
user_uuid=UUID(bytes=self.user_uuid),
|
|
216
273
|
credential_uuid=UUID(bytes=self.credential_uuid),
|
|
@@ -221,7 +278,7 @@ class SessionModel(Base):
|
|
|
221
278
|
)
|
|
222
279
|
|
|
223
280
|
@staticmethod
|
|
224
|
-
def from_dataclass(session:
|
|
281
|
+
def from_dataclass(session: _LegacySession):
|
|
225
282
|
return SessionModel(
|
|
226
283
|
key=session.key,
|
|
227
284
|
user_uuid=session.user_uuid.bytes,
|
|
@@ -243,8 +300,8 @@ class ResetTokenModel(Base):
|
|
|
243
300
|
token_type: Mapped[str] = mapped_column(String, nullable=False)
|
|
244
301
|
expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
245
302
|
|
|
246
|
-
def as_dataclass(self) ->
|
|
247
|
-
return
|
|
303
|
+
def as_dataclass(self) -> _LegacyResetToken:
|
|
304
|
+
return _LegacyResetToken(
|
|
248
305
|
key=self.key,
|
|
249
306
|
user_uuid=UUID(bytes=self.user_uuid),
|
|
250
307
|
token_type=self.token_type,
|
|
@@ -338,7 +395,7 @@ class DB:
|
|
|
338
395
|
result = await session.execute(select(PermissionModel))
|
|
339
396
|
return [p.as_dataclass() for p in result.scalars().all()]
|
|
340
397
|
|
|
341
|
-
async def list_organizations(self) -> list[
|
|
398
|
+
async def list_organizations(self) -> list[_LegacyOrg]:
|
|
342
399
|
async with self.session() as session:
|
|
343
400
|
# Load all orgs
|
|
344
401
|
orgs_result = await session.execute(select(OrgModel))
|
|
@@ -365,13 +422,13 @@ class DB:
|
|
|
365
422
|
perms_by_role.setdefault(rp.role_uuid, []).append(rp.permission_id)
|
|
366
423
|
|
|
367
424
|
# Build org dataclasses with roles and permission IDs
|
|
368
|
-
roles_by_org: dict[bytes, list[
|
|
425
|
+
roles_by_org: dict[bytes, list[_LegacyRole]] = {}
|
|
369
426
|
for rm in role_models:
|
|
370
427
|
r_dc = rm.as_dataclass()
|
|
371
428
|
r_dc.permissions = perms_by_role.get(rm.uuid, [])
|
|
372
429
|
roles_by_org.setdefault(rm.org_uuid, []).append(r_dc)
|
|
373
430
|
|
|
374
|
-
orgs: list[
|
|
431
|
+
orgs: list[_LegacyOrg] = []
|
|
375
432
|
for om in org_models:
|
|
376
433
|
o_dc = om.as_dataclass()
|
|
377
434
|
o_dc.permissions = perms_by_org.get(om.uuid, [])
|
paskia/remoteauth.py
CHANGED
|
@@ -19,12 +19,12 @@ The first 3 words of the token serve as the pairing code for manual entry.
|
|
|
19
19
|
|
|
20
20
|
import asyncio
|
|
21
21
|
import logging
|
|
22
|
+
from collections.abc import Callable
|
|
22
23
|
from dataclasses import dataclass
|
|
23
|
-
from datetime import datetime, timedelta
|
|
24
|
-
from typing import Callable
|
|
24
|
+
from datetime import UTC, datetime, timedelta
|
|
25
25
|
from uuid import UUID
|
|
26
26
|
|
|
27
|
-
from paskia.util import passphrase
|
|
27
|
+
from paskia.util import passphrase, pow
|
|
28
28
|
|
|
29
29
|
# Remote auth requests expire after this duration
|
|
30
30
|
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
|
@@ -94,7 +94,7 @@ class RemoteAuthManager:
|
|
|
94
94
|
|
|
95
95
|
async def _cleanup_expired(self):
|
|
96
96
|
"""Remove expired requests and notify waiting clients."""
|
|
97
|
-
now = datetime.now(
|
|
97
|
+
now = datetime.now(UTC)
|
|
98
98
|
expired_keys = []
|
|
99
99
|
async with self._lock:
|
|
100
100
|
for key, req in self._requests.items():
|
|
@@ -123,7 +123,7 @@ class RemoteAuthManager:
|
|
|
123
123
|
Returns:
|
|
124
124
|
(code, expiry) - The 3-word passphrase code and expiration time
|
|
125
125
|
"""
|
|
126
|
-
now = datetime.now(
|
|
126
|
+
now = datetime.now(UTC)
|
|
127
127
|
expiry = now + REMOTE_AUTH_LIFETIME
|
|
128
128
|
|
|
129
129
|
async with self._lock:
|
|
@@ -160,7 +160,7 @@ class RemoteAuthManager:
|
|
|
160
160
|
req = self._requests.get(normalized)
|
|
161
161
|
if req is None:
|
|
162
162
|
return None
|
|
163
|
-
now = datetime.now(
|
|
163
|
+
now = datetime.now(UTC)
|
|
164
164
|
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
|
165
165
|
# Expired
|
|
166
166
|
del self._requests[normalized]
|
|
@@ -319,7 +319,6 @@ class RemoteAuthManager:
|
|
|
319
319
|
Returns:
|
|
320
320
|
PoW work units (pow.NORMAL or pow.HARD)
|
|
321
321
|
"""
|
|
322
|
-
from paskia.util import pow
|
|
323
322
|
|
|
324
323
|
count = self.get_connection_count()
|
|
325
324
|
return pow.HARD if count >= 10 else pow.NORMAL
|
|
@@ -332,7 +331,7 @@ class RemoteAuthManager:
|
|
|
332
331
|
req = self._requests.get(token)
|
|
333
332
|
if req is None:
|
|
334
333
|
return None
|
|
335
|
-
now = datetime.now(
|
|
334
|
+
now = datetime.now(UTC)
|
|
336
335
|
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
|
337
336
|
del self._requests[token]
|
|
338
337
|
return None
|
paskia/sansio.py
CHANGED
|
@@ -8,11 +8,9 @@ This module provides a unified interface for WebAuthn operations including:
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
-
from datetime import datetime, timezone
|
|
12
11
|
from urllib.parse import urlparse
|
|
13
12
|
from uuid import UUID
|
|
14
13
|
|
|
15
|
-
import uuid7
|
|
16
14
|
from webauthn import (
|
|
17
15
|
generate_authentication_options,
|
|
18
16
|
generate_registration_options,
|
|
@@ -176,14 +174,12 @@ class Passkey:
|
|
|
176
174
|
expected_origin=origin,
|
|
177
175
|
expected_rp_id=self.rp_id,
|
|
178
176
|
)
|
|
179
|
-
return Credential(
|
|
180
|
-
uuid=uuid7.create(),
|
|
177
|
+
return Credential.create(
|
|
181
178
|
credential_id=credential.raw_id,
|
|
182
|
-
|
|
179
|
+
user=user_uuid,
|
|
183
180
|
aaguid=UUID(registration.aaguid),
|
|
184
181
|
public_key=registration.credential_public_key,
|
|
185
182
|
sign_count=registration.sign_count,
|
|
186
|
-
created_at=datetime.now(timezone.utc),
|
|
187
183
|
)
|
|
188
184
|
|
|
189
185
|
### Authentication Methods ###
|
|
@@ -234,8 +230,11 @@ class Passkey:
|
|
|
234
230
|
Args:
|
|
235
231
|
credential: The authentication credential response from the client
|
|
236
232
|
expected_challenge: The earlier generated challenge bytes
|
|
237
|
-
stored_cred: The server stored credential record (modified
|
|
233
|
+
stored_cred: The server stored credential record (NOT modified)
|
|
238
234
|
origin: The origin URL (required, must be pre-validated)
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
VerifiedAuthentication with new_sign_count and user_verified status
|
|
239
238
|
"""
|
|
240
239
|
# Verify the authentication response
|
|
241
240
|
verification = verify_authentication_response(
|
|
@@ -246,11 +245,6 @@ class Passkey:
|
|
|
246
245
|
credential_public_key=stored_cred.public_key,
|
|
247
246
|
credential_current_sign_count=stored_cred.sign_count,
|
|
248
247
|
)
|
|
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
248
|
return verification
|
|
255
249
|
|
|
256
250
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paskia
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Passkey Auth made easy: all sites and APIs can be guarded even without any changes on the protected site.
|
|
5
5
|
Project-URL: Homepage, https://git.zi.fi/LeoVasanko/paskia
|
|
6
6
|
Project-URL: Repository, https://github.com/LeoVasanko/paskia
|