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/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
|