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.
Files changed (64) hide show
  1. paskia/__init__.py +3 -0
  2. paskia/_version.py +34 -0
  3. paskia/aaguid/__init__.py +32 -0
  4. paskia/aaguid/combined_aaguid.json +1 -0
  5. paskia/authsession.py +112 -0
  6. paskia/bootstrap.py +190 -0
  7. paskia/config.py +25 -0
  8. paskia/db/__init__.py +415 -0
  9. paskia/db/sql.py +1424 -0
  10. paskia/fastapi/__init__.py +3 -0
  11. paskia/fastapi/__main__.py +335 -0
  12. paskia/fastapi/admin.py +850 -0
  13. paskia/fastapi/api.py +308 -0
  14. paskia/fastapi/auth_host.py +97 -0
  15. paskia/fastapi/authz.py +110 -0
  16. paskia/fastapi/mainapp.py +130 -0
  17. paskia/fastapi/remote.py +504 -0
  18. paskia/fastapi/reset.py +101 -0
  19. paskia/fastapi/session.py +52 -0
  20. paskia/fastapi/user.py +162 -0
  21. paskia/fastapi/ws.py +163 -0
  22. paskia/fastapi/wsutil.py +91 -0
  23. paskia/frontend-build/auth/admin/index.html +18 -0
  24. paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css +1 -0
  25. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +8 -0
  26. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +1 -0
  27. paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css +1 -0
  28. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +1 -0
  29. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js +2 -0
  30. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +1 -0
  31. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +1 -0
  32. paskia/frontend-build/auth/assets/auth-BU_O38k2.css +1 -0
  33. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +1 -0
  34. paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js +1 -0
  35. paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +1 -0
  36. paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +1 -0
  37. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +1 -0
  38. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +1 -0
  39. paskia/frontend-build/auth/assets/restricted-C0IQufuH.js +1 -0
  40. paskia/frontend-build/auth/index.html +19 -0
  41. paskia/frontend-build/auth/restricted/index.html +16 -0
  42. paskia/frontend-build/int/forward/index.html +18 -0
  43. paskia/frontend-build/int/reset/index.html +15 -0
  44. paskia/globals.py +71 -0
  45. paskia/remoteauth.py +359 -0
  46. paskia/sansio.py +263 -0
  47. paskia/util/frontend.py +75 -0
  48. paskia/util/hostutil.py +76 -0
  49. paskia/util/htmlutil.py +47 -0
  50. paskia/util/passphrase.py +20 -0
  51. paskia/util/permutil.py +32 -0
  52. paskia/util/pow.py +45 -0
  53. paskia/util/querysafe.py +11 -0
  54. paskia/util/sessionutil.py +37 -0
  55. paskia/util/startupbox.py +75 -0
  56. paskia/util/timeutil.py +47 -0
  57. paskia/util/tokens.py +44 -0
  58. paskia/util/useragent.py +10 -0
  59. paskia/util/userinfo.py +159 -0
  60. paskia/util/wordlist.py +54 -0
  61. paskia-0.7.1.dist-info/METADATA +22 -0
  62. paskia-0.7.1.dist-info/RECORD +64 -0
  63. paskia-0.7.1.dist-info/WHEEL +4 -0
  64. paskia-0.7.1.dist-info/entry_points.txt +2 -0
paskia/authsession.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ Core session management for WebAuthn authentication.
3
+
4
+ This module provides generic session management functionality that is
5
+ independent of any web framework:
6
+ - Session creation and validation
7
+ - Token handling and refresh
8
+ - Credential management
9
+ """
10
+
11
+ from datetime import datetime, timezone
12
+ from uuid import UUID
13
+
14
+ from paskia.config import SESSION_LIFETIME
15
+ from paskia.db import ResetToken, Session
16
+ from paskia.globals import db, passkey
17
+ from paskia.util import hostutil
18
+ from paskia.util.tokens import create_token, reset_key, session_key
19
+
20
+ EXPIRES = SESSION_LIFETIME
21
+
22
+
23
+ def expires() -> datetime:
24
+ return datetime.now(timezone.utc) + EXPIRES
25
+
26
+
27
+ def reset_expires() -> datetime:
28
+ from .config import RESET_LIFETIME
29
+
30
+ return datetime.now(timezone.utc) + RESET_LIFETIME
31
+
32
+
33
+ def session_expiry(session: Session) -> datetime:
34
+ """Calculate the expiration timestamp for a session (UTC aware)."""
35
+ # After migration all renewed timestamps are timezone-aware UTC
36
+ return session.renewed + EXPIRES
37
+
38
+
39
+ async def create_session(
40
+ user_uuid: UUID,
41
+ credential_uuid: UUID,
42
+ *,
43
+ host: str,
44
+ ip: str,
45
+ user_agent: str,
46
+ ) -> str:
47
+ """Create a new session and return a session token."""
48
+ normalized_host = hostutil.normalize_host(host)
49
+ if not normalized_host:
50
+ raise ValueError("Host required for session creation")
51
+ hostname = normalized_host.split(":")[0] # Domain names only, IPs aren't supported
52
+ rp_id = passkey.instance.rp_id
53
+ if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
54
+ raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
55
+ token = create_token()
56
+ now = datetime.now(timezone.utc)
57
+ await db.instance.create_session(
58
+ user_uuid=user_uuid,
59
+ credential_uuid=credential_uuid,
60
+ key=session_key(token),
61
+ host=normalized_host,
62
+ ip=ip,
63
+ user_agent=user_agent,
64
+ renewed=now,
65
+ )
66
+ return token
67
+
68
+
69
+ async def get_reset(token: str) -> ResetToken:
70
+ """Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
71
+ record = await db.instance.get_reset_token(reset_key(token))
72
+ if record and record.expiry >= datetime.now(timezone.utc):
73
+ return record
74
+ raise ValueError("This authentication link is no longer valid.")
75
+
76
+
77
+ async def get_session(token: str, host: str | None = None) -> Session:
78
+ """Validate a session token and return session data if valid."""
79
+ host = hostutil.normalize_host(host)
80
+ if not host:
81
+ raise ValueError("Invalid host")
82
+ session = await db.instance.get_session(session_key(token))
83
+ if session and session_expiry(session) >= datetime.now(timezone.utc):
84
+ if session.host is None:
85
+ # First time binding: store exact host:port (or IPv6 form) now.
86
+ await db.instance.set_session_host(session.key, host)
87
+ session.host = host
88
+ elif session.host != host:
89
+ raise ValueError("Session host mismatch")
90
+ return session
91
+ raise ValueError("Your session has expired. Please sign in again!")
92
+
93
+
94
+ async def refresh_session_token(token: str, *, ip: str, user_agent: str):
95
+ """Refresh a session extending its expiry."""
96
+ session_record = await db.instance.get_session(session_key(token))
97
+ if not session_record:
98
+ raise ValueError("Session not found or expired")
99
+ updated = await db.instance.update_session(
100
+ session_key(token),
101
+ ip=ip,
102
+ user_agent=user_agent,
103
+ renewed=datetime.now(timezone.utc),
104
+ )
105
+ if not updated:
106
+ raise ValueError("Session not found or expired")
107
+
108
+
109
+ async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
110
+ """Delete a specific credential for the current user."""
111
+ s = await get_session(auth, host=host)
112
+ await db.instance.delete_credential(credential_uuid, s.user_uuid)
paskia/bootstrap.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ Bootstrap module for passkey authentication system.
3
+
4
+ This module handles initial system setup when a new database is created,
5
+ including creating default admin user, organization, permissions, and
6
+ generating a reset link for initial admin setup.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ from datetime import datetime, timezone
12
+
13
+ import uuid7
14
+
15
+ from paskia import authsession, globals
16
+ from paskia.db import Org, Permission, Role, User
17
+ from paskia.util import hostutil, passphrase, tokens
18
+
19
+
20
+ def _init_logger() -> logging.Logger:
21
+ logger = logging.getLogger(__name__)
22
+ if not logger.handlers and not logging.getLogger().handlers:
23
+ h = logging.StreamHandler()
24
+ h.setFormatter(logging.Formatter("%(message)s"))
25
+ logger.addHandler(h)
26
+ logger.setLevel(logging.INFO)
27
+ return logger
28
+
29
+
30
+ logger = _init_logger()
31
+
32
+ # Shared log message template for admin reset links
33
+ ADMIN_RESET_MESSAGE = """\
34
+ %s
35
+
36
+ 👤 Admin %s
37
+ - Use this link to register a Passkey for the admin user!
38
+ """
39
+
40
+
41
+ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
42
+ """Create an admin reset link and log it with the provided message."""
43
+ token = passphrase.generate()
44
+ expiry = authsession.reset_expires()
45
+ await globals.db.instance.create_reset_token(
46
+ user_uuid=user_uuid,
47
+ key=tokens.reset_key(token),
48
+ expiry=expiry,
49
+ token_type=session_type,
50
+ )
51
+ reset_link = hostutil.reset_link_url(token)
52
+ logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
53
+ return reset_link
54
+
55
+
56
+ async def bootstrap_system() -> dict:
57
+ """
58
+ Bootstrap the entire system with default data.
59
+
60
+ Returns:
61
+ dict: Contains information about created entities and reset link
62
+ """
63
+ # Create permission first - will fail if already exists
64
+ perm0 = Permission(id="auth:admin", display_name="Master Admin")
65
+ await globals.db.instance.create_permission(perm0)
66
+
67
+ org = Org(uuid7.create(), "Organization")
68
+ await globals.db.instance.create_organization(org)
69
+
70
+ # After creation, org.permissions now includes the auto-created org admin permission
71
+ # Allow this org to grant global admin explicitly
72
+ await globals.db.instance.add_permission_to_organization(str(org.uuid), perm0.id)
73
+
74
+ # Create an Administration role granting both org and global admin
75
+ # Compose permissions for Administration role: global admin + org admin auto-perm
76
+ role = Role(
77
+ uuid7.create(),
78
+ org.uuid,
79
+ "Administration",
80
+ permissions=[perm0.id, *org.permissions],
81
+ )
82
+ await globals.db.instance.create_role(role)
83
+
84
+ user = User(
85
+ uuid=uuid7.create(),
86
+ display_name="Admin",
87
+ role_uuid=role.uuid,
88
+ created_at=datetime.now(timezone.utc),
89
+ visits=0,
90
+ )
91
+ await globals.db.instance.create_user(user)
92
+
93
+ # Generate reset link and log it
94
+ reset_link = await _create_and_log_admin_reset_link(
95
+ user.uuid, "✅ Bootstrap completed!", "admin bootstrap"
96
+ )
97
+
98
+ return {
99
+ "user": user,
100
+ "org": org,
101
+ "role": role,
102
+ "permissions": [
103
+ perm0,
104
+ *[Permission(id=p, display_name="") for p in org.permissions],
105
+ ],
106
+ "reset_link": reset_link,
107
+ }
108
+
109
+
110
+ async def check_admin_credentials() -> bool:
111
+ """
112
+ Check if the admin user needs credentials and create a reset link if needed.
113
+
114
+ Returns:
115
+ bool: True if a reset link was created, False if admin already has credentials
116
+ """
117
+ try:
118
+ # Get permission organizations to find admin users
119
+ permission_orgs = await globals.db.instance.get_permission_organizations(
120
+ "auth:admin"
121
+ )
122
+
123
+ if not permission_orgs:
124
+ return False
125
+
126
+ # Get users from the first organization with admin permission
127
+ org_users = await globals.db.instance.get_organization_users(
128
+ str(permission_orgs[0].uuid)
129
+ )
130
+ admin_users = [user for user, role in org_users if role == "Administration"]
131
+
132
+ if not admin_users:
133
+ return False
134
+
135
+ # Check first admin user for credentials
136
+ admin_user = admin_users[0]
137
+ credentials = await globals.db.instance.get_credentials_by_user_uuid(
138
+ admin_user.uuid
139
+ )
140
+
141
+ if not credentials:
142
+ # Admin exists but has no credentials, create reset link
143
+ await _create_and_log_admin_reset_link(
144
+ admin_user.uuid,
145
+ "⚠️ Admin user has no credentials!",
146
+ "admin registration",
147
+ )
148
+ return True
149
+
150
+ return False
151
+
152
+ except Exception:
153
+ return False
154
+
155
+
156
+ async def bootstrap_if_needed() -> bool:
157
+ """
158
+ Check if system needs bootstrapping and perform it if necessary.
159
+
160
+ Returns:
161
+ bool: True if bootstrapping was performed, False if system was already set up
162
+ """
163
+ try:
164
+ # Check if the admin permission exists - if it does, system is already bootstrapped
165
+ await globals.db.instance.get_permission("auth:admin")
166
+ # Permission exists, system is already bootstrapped
167
+ # Check if admin needs credentials (only for already-bootstrapped systems)
168
+ await check_admin_credentials()
169
+ return False
170
+ except Exception:
171
+ # Permission doesn't exist, need to bootstrap
172
+ pass
173
+
174
+ # No admin permission found, need to bootstrap
175
+ # Bootstrap creates the admin user AND the reset link, so no need to check credentials after
176
+ await bootstrap_system()
177
+ return True
178
+
179
+
180
+ # CLI interface
181
+ async def main():
182
+ """Main CLI entry point for bootstrapping."""
183
+ # Configure logging for CLI usage
184
+ logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
185
+
186
+ await globals.init()
187
+
188
+
189
+ if __name__ == "__main__":
190
+ asyncio.run(main())
paskia/config.py ADDED
@@ -0,0 +1,25 @@
1
+ from dataclasses import dataclass
2
+ from datetime import timedelta
3
+
4
+ # Shared configuration constants for session management.
5
+ SESSION_LIFETIME = timedelta(hours=24)
6
+
7
+ # Lifetime for reset links created by admins
8
+ RESET_LIFETIME = timedelta(days=14)
9
+
10
+
11
+ @dataclass
12
+ class PaskiaConfig:
13
+ """Runtime configuration for the Paskia authentication server."""
14
+
15
+ rp_id: str
16
+ rp_name: str | None
17
+ origins: list[str] | None
18
+ auth_host: str | None
19
+ site_url: str # Base URL without trailing path (e.g. https://example.com)
20
+ site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
21
+ # Listen address (one of host:port or uds)
22
+ host: str | None = None
23
+ port: int | None = None
24
+ uds: str | None = None
25
+ devmode: bool = False