Fix actor fields and transactions for API operations as they are recorded to DB.
This commit is contained in:
@@ -31,35 +31,6 @@ def reset_expires() -> datetime:
|
||||
return datetime.now(timezone.utc) + RESET_LIFETIME
|
||||
|
||||
|
||||
async def create_session(
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
*,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> str:
|
||||
"""Create a new session and return a session token."""
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
if not normalized_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
hostname = normalized_host.split(":")[0] # Domain names only, IPs aren't supported
|
||||
rp_id = passkey.instance.rp_id
|
||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
token = create_token()
|
||||
db.create_session(
|
||||
user_uuid=user_uuid,
|
||||
credential_uuid=credential_uuid,
|
||||
key=session_key(token),
|
||||
host=normalized_host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_reset(token: str) -> ResetToken:
|
||||
"""Validate a credential reset token."""
|
||||
record = db.get_reset_token(reset_key(token))
|
||||
|
||||
+47
-3
@@ -530,7 +530,10 @@ def delete_permission(uuid: str | UUID, actor: str = "system") -> None:
|
||||
|
||||
|
||||
def create_organization(org: Org, actor: str = "system") -> None:
|
||||
"""Create a new organization."""
|
||||
"""Create a new organization with an Administration role.
|
||||
|
||||
Automatically creates an 'Administration' role with auth:org:admin permission.
|
||||
"""
|
||||
if org.uuid in _db._data.orgs:
|
||||
raise ValueError(f"Organization {org.uuid} already exists")
|
||||
with _db.transaction(actor):
|
||||
@@ -542,6 +545,15 @@ def create_organization(org: Org, actor: str = "system") -> None:
|
||||
for pid, p in _db._data.permissions.items():
|
||||
if p.scope == scope:
|
||||
p.orgs[org.uuid] = True
|
||||
# Create Administration role with org admin permission
|
||||
import uuid7
|
||||
|
||||
admin_role_uuid = uuid7.create()
|
||||
_db._data.roles[admin_role_uuid] = _RoleData(
|
||||
org=org.uuid,
|
||||
display_name="Administration",
|
||||
permissions={"auth:org:admin": True},
|
||||
)
|
||||
|
||||
|
||||
def update_organization_name(
|
||||
@@ -939,8 +951,25 @@ def cleanup_expired(actor: str = "system") -> int:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def login(user_uuid: str | UUID, credential: Credential, actor: str = "system") -> None:
|
||||
"""Update user last_seen and credential sign_count/last_used on login."""
|
||||
def login(
|
||||
user_uuid: str | UUID,
|
||||
credential: Credential,
|
||||
session_key: bytes,
|
||||
host: str | None,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
expiry: datetime,
|
||||
) -> None:
|
||||
"""Update user/credential on login and create session in a single transaction.
|
||||
|
||||
Updates:
|
||||
- user.last_seen, user.visits
|
||||
- credential.sign_count, credential.last_used
|
||||
Creates:
|
||||
- new session
|
||||
|
||||
Actor is set to the user UUID being logged in.
|
||||
"""
|
||||
if isinstance(user_uuid, str):
|
||||
user_uuid = UUID(user_uuid)
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -948,11 +977,26 @@ def login(user_uuid: str | UUID, credential: Credential, actor: str = "system")
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
if credential.uuid not in _db._data.credentials:
|
||||
raise ValueError(f"Credential {credential.uuid} not found")
|
||||
if session_key in _db._data.sessions:
|
||||
raise ValueError("Session already exists")
|
||||
|
||||
actor = str(user_uuid)
|
||||
with _db.transaction(actor):
|
||||
# Update user
|
||||
_db._data.users[user_uuid].last_seen = now
|
||||
_db._data.users[user_uuid].visits += 1
|
||||
# Update credential
|
||||
_db._data.credentials[credential.uuid].sign_count = credential.sign_count
|
||||
_db._data.credentials[credential.uuid].last_used = now
|
||||
# Create session
|
||||
_db._data.sessions[session_key] = _SessionData(
|
||||
user=user_uuid,
|
||||
credential=credential.uuid,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
)
|
||||
|
||||
|
||||
def create_credential_session(
|
||||
|
||||
@@ -136,7 +136,6 @@ async def admin_create_org(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
from ..db import Role as RoleDC # local import to avoid cycles
|
||||
|
||||
actor = str(ctx.user.uuid)
|
||||
org_uuid = uuid4()
|
||||
@@ -145,17 +144,6 @@ async def admin_create_org(
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
db.create_organization(org, actor=actor)
|
||||
|
||||
# Automatically create Administration role with org admin permission
|
||||
# The auth:org:admin permission is automatically created/enabled by create_organization
|
||||
role_uuid = uuid4()
|
||||
admin_role = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
display_name="Administration",
|
||||
permissions=["auth:org:admin"],
|
||||
)
|
||||
db.create_role(admin_role, actor=actor)
|
||||
|
||||
return {"uuid": str(org_uuid)}
|
||||
|
||||
|
||||
|
||||
@@ -289,11 +289,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
try:
|
||||
await get_session(auth, host=request.headers.get("host"))
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
return {"message": "Already logged out"}
|
||||
with suppress(Exception):
|
||||
db.delete_session(session_key(auth))
|
||||
db.delete_session(session_key(auth), actor=str(s.user_uuid))
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
+20
-13
@@ -16,7 +16,6 @@ import base64url
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
|
||||
from paskia import db, remoteauth
|
||||
from paskia.authsession import create_session
|
||||
from paskia.fastapi.session import infodict
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.globals import passkey
|
||||
@@ -334,9 +333,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
credential, webauthn_challenge, stored_cred, origin
|
||||
)
|
||||
|
||||
# Update credential last_used
|
||||
db.login(stored_cred.user_uuid, stored_cred)
|
||||
|
||||
# Create a session for the REQUESTING device
|
||||
assert stored_cred.uuid is not None
|
||||
|
||||
@@ -346,7 +342,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
if request.action == "register":
|
||||
# For registration, create a reset token for device addition
|
||||
from paskia.authsession import expires
|
||||
from paskia.util import tokens
|
||||
from paskia.util import hostutil, tokens
|
||||
|
||||
token_str = passphrase.generate()
|
||||
expiry = expires()
|
||||
@@ -355,25 +351,36 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
key=tokens.reset_key(token_str),
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
actor=str(stored_cred.user_uuid),
|
||||
)
|
||||
reset_token = token_str
|
||||
# Also create a session so the device is logged in?
|
||||
# User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities."
|
||||
session_token = await create_session(
|
||||
# Also create a session so the device is logged in
|
||||
session_token = passphrase.generate()
|
||||
normalized_host = hostutil.normalize_host(request.host)
|
||||
db.login(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
host=request.host,
|
||||
credential=stored_cred,
|
||||
session_key=tokens.session_key(session_token),
|
||||
host=normalized_host,
|
||||
ip=request.ip,
|
||||
user_agent=request.user_agent,
|
||||
expiry=expires(),
|
||||
)
|
||||
else:
|
||||
# Default login action
|
||||
session_token = await create_session(
|
||||
from paskia.authsession import expires
|
||||
from paskia.util import hostutil, tokens
|
||||
|
||||
session_token = passphrase.generate()
|
||||
normalized_host = hostutil.normalize_host(request.host)
|
||||
db.login(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
host=request.host,
|
||||
credential=stored_cred,
|
||||
session_key=tokens.session_key(session_token),
|
||||
host=normalized_host,
|
||||
ip=request.ip,
|
||||
user_agent=request.user_agent,
|
||||
expiry=expires(),
|
||||
)
|
||||
|
||||
# Complete the remote auth request (notifies the waiting device)
|
||||
|
||||
@@ -55,7 +55,7 @@ async def user_update_display_name(
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
if len(new_name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
db.update_user_display_name(s.user_uuid, new_name)
|
||||
db.update_user_display_name(s.user_uuid, new_name, actor=str(s.user_uuid))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
db.delete_sessions_for_user(s.user_uuid)
|
||||
db.delete_sessions_for_user(s.user_uuid, actor=str(s.user_uuid))
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out from all hosts"}
|
||||
|
||||
@@ -103,7 +103,7 @@ async def api_delete_session(
|
||||
if not target_session or target_session.user_uuid != current_session.user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
db.delete_session(target_key)
|
||||
db.delete_session(target_key, actor=str(current_session.user_uuid))
|
||||
current_terminated = target_key == session_key(auth)
|
||||
if current_terminated:
|
||||
session.clear_session_cookie(response) # explicit because 200
|
||||
@@ -149,6 +149,7 @@ async def api_create_link(
|
||||
key=tokens.reset_key(token),
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
actor=str(s.user_uuid),
|
||||
)
|
||||
url = hostutil.reset_link_url(token)
|
||||
return {
|
||||
|
||||
+18
-8
@@ -3,12 +3,12 @@ from uuid import UUID
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import create_session, get_reset, get_session
|
||||
from paskia.authsession import expires, get_reset, get_session
|
||||
from paskia.fastapi import authz, remote
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import passphrase
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util.tokens import create_token, session_key
|
||||
|
||||
# Create a FastAPI subapp for WebSocket endpoints
|
||||
@@ -89,6 +89,7 @@ async def websocket_register_add(
|
||||
host=host,
|
||||
ip=metadata.get("ip"),
|
||||
user_agent=metadata.get("user_agent"),
|
||||
actor=str(user_uuid),
|
||||
)
|
||||
auth = token
|
||||
|
||||
@@ -140,18 +141,27 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
|
||||
# Verify the credential matches the stored data
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
|
||||
# Update both credential and user's last_seen timestamp
|
||||
db.login(stored_cred.user_uuid, stored_cred)
|
||||
|
||||
# Create a session token for the authenticated user
|
||||
# Create session and update user/credential in a single transaction
|
||||
assert stored_cred.uuid is not None
|
||||
metadata = infodict(ws, "auth")
|
||||
token = await create_session(
|
||||
token = create_token()
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
if not normalized_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
hostname = normalized_host.split(":")[0]
|
||||
rp_id = passkey.instance.rp_id
|
||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
|
||||
db.login(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
host=host,
|
||||
credential=stored_cred,
|
||||
session_key=session_key(token),
|
||||
host=normalized_host,
|
||||
ip=metadata.get("ip") or "",
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
expiry=expires(),
|
||||
)
|
||||
|
||||
await ws.send_json(
|
||||
|
||||
Reference in New Issue
Block a user