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
|
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:
|
async def get_reset(token: str) -> ResetToken:
|
||||||
"""Validate a credential reset token."""
|
"""Validate a credential reset token."""
|
||||||
record = db.get_reset_token(reset_key(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:
|
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:
|
if org.uuid in _db._data.orgs:
|
||||||
raise ValueError(f"Organization {org.uuid} already exists")
|
raise ValueError(f"Organization {org.uuid} already exists")
|
||||||
with _db.transaction(actor):
|
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():
|
for pid, p in _db._data.permissions.items():
|
||||||
if p.scope == scope:
|
if p.scope == scope:
|
||||||
p.orgs[org.uuid] = True
|
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(
|
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:
|
def login(
|
||||||
"""Update user last_seen and credential sign_count/last_used on 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):
|
if isinstance(user_uuid, str):
|
||||||
user_uuid = UUID(user_uuid)
|
user_uuid = UUID(user_uuid)
|
||||||
now = datetime.now(timezone.utc)
|
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")
|
raise ValueError(f"User {user_uuid} not found")
|
||||||
if credential.uuid not in _db._data.credentials:
|
if credential.uuid not in _db._data.credentials:
|
||||||
raise ValueError(f"Credential {credential.uuid} not found")
|
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):
|
with _db.transaction(actor):
|
||||||
|
# Update user
|
||||||
_db._data.users[user_uuid].last_seen = now
|
_db._data.users[user_uuid].last_seen = now
|
||||||
_db._data.users[user_uuid].visits += 1
|
_db._data.users[user_uuid].visits += 1
|
||||||
|
# Update credential
|
||||||
_db._data.credentials[credential.uuid].sign_count = credential.sign_count
|
_db._data.credentials[credential.uuid].sign_count = credential.sign_count
|
||||||
_db._data.credentials[credential.uuid].last_used = now
|
_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(
|
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
|
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 Org as OrgDC # local import to avoid cycles
|
||||||
from ..db import Role as RoleDC # local import to avoid cycles
|
|
||||||
|
|
||||||
actor = str(ctx.user.uuid)
|
actor = str(ctx.user.uuid)
|
||||||
org_uuid = uuid4()
|
org_uuid = uuid4()
|
||||||
@@ -145,17 +144,6 @@ async def admin_create_org(
|
|||||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||||
db.create_organization(org, actor=actor)
|
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)}
|
return {"uuid": str(org_uuid)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -289,11 +289,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|||||||
if not auth:
|
if not auth:
|
||||||
return {"message": "Already logged out"}
|
return {"message": "Already logged out"}
|
||||||
try:
|
try:
|
||||||
await get_session(auth, host=request.headers.get("host"))
|
s = await get_session(auth, host=request.headers.get("host"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return {"message": "Already logged out"}
|
return {"message": "Already logged out"}
|
||||||
with suppress(Exception):
|
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)
|
session.clear_session_cookie(response)
|
||||||
return {"message": "Logged out successfully"}
|
return {"message": "Logged out successfully"}
|
||||||
|
|
||||||
|
|||||||
+20
-13
@@ -16,7 +16,6 @@ import base64url
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from paskia import db, remoteauth
|
from paskia import db, remoteauth
|
||||||
from paskia.authsession import create_session
|
|
||||||
from paskia.fastapi.session import infodict
|
from paskia.fastapi.session import infodict
|
||||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||||
from paskia.globals import passkey
|
from paskia.globals import passkey
|
||||||
@@ -334,9 +333,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|||||||
credential, webauthn_challenge, stored_cred, origin
|
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
|
# Create a session for the REQUESTING device
|
||||||
assert stored_cred.uuid is not None
|
assert stored_cred.uuid is not None
|
||||||
|
|
||||||
@@ -346,7 +342,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|||||||
if request.action == "register":
|
if request.action == "register":
|
||||||
# For registration, create a reset token for device addition
|
# For registration, create a reset token for device addition
|
||||||
from paskia.authsession import expires
|
from paskia.authsession import expires
|
||||||
from paskia.util import tokens
|
from paskia.util import hostutil, tokens
|
||||||
|
|
||||||
token_str = passphrase.generate()
|
token_str = passphrase.generate()
|
||||||
expiry = expires()
|
expiry = expires()
|
||||||
@@ -355,25 +351,36 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|||||||
key=tokens.reset_key(token_str),
|
key=tokens.reset_key(token_str),
|
||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
token_type="device addition",
|
token_type="device addition",
|
||||||
|
actor=str(stored_cred.user_uuid),
|
||||||
)
|
)
|
||||||
reset_token = token_str
|
reset_token = token_str
|
||||||
# Also create a session so the device is logged in?
|
# 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 = passphrase.generate()
|
||||||
session_token = await create_session(
|
normalized_host = hostutil.normalize_host(request.host)
|
||||||
|
db.login(
|
||||||
user_uuid=stored_cred.user_uuid,
|
user_uuid=stored_cred.user_uuid,
|
||||||
credential_uuid=stored_cred.uuid,
|
credential=stored_cred,
|
||||||
host=request.host,
|
session_key=tokens.session_key(session_token),
|
||||||
|
host=normalized_host,
|
||||||
ip=request.ip,
|
ip=request.ip,
|
||||||
user_agent=request.user_agent,
|
user_agent=request.user_agent,
|
||||||
|
expiry=expires(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Default login action
|
# 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,
|
user_uuid=stored_cred.user_uuid,
|
||||||
credential_uuid=stored_cred.uuid,
|
credential=stored_cred,
|
||||||
host=request.host,
|
session_key=tokens.session_key(session_token),
|
||||||
|
host=normalized_host,
|
||||||
ip=request.ip,
|
ip=request.ip,
|
||||||
user_agent=request.user_agent,
|
user_agent=request.user_agent,
|
||||||
|
expiry=expires(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Complete the remote auth request (notifies the waiting device)
|
# 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")
|
raise HTTPException(status_code=400, detail="display_name required")
|
||||||
if len(new_name) > 64:
|
if len(new_name) > 64:
|
||||||
raise HTTPException(status_code=400, detail="display_name too long")
|
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"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
|
|||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=401, detail="Session expired", mode="login"
|
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)
|
session.clear_session_cookie(response)
|
||||||
return {"message": "Logged out from all hosts"}
|
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:
|
if not target_session or target_session.user_uuid != current_session.user_uuid:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
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)
|
current_terminated = target_key == session_key(auth)
|
||||||
if current_terminated:
|
if current_terminated:
|
||||||
session.clear_session_cookie(response) # explicit because 200
|
session.clear_session_cookie(response) # explicit because 200
|
||||||
@@ -149,6 +149,7 @@ async def api_create_link(
|
|||||||
key=tokens.reset_key(token),
|
key=tokens.reset_key(token),
|
||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
token_type="device addition",
|
token_type="device addition",
|
||||||
|
actor=str(s.user_uuid),
|
||||||
)
|
)
|
||||||
url = hostutil.reset_link_url(token)
|
url = hostutil.reset_link_url(token)
|
||||||
return {
|
return {
|
||||||
|
|||||||
+18
-8
@@ -3,12 +3,12 @@ from uuid import UUID
|
|||||||
from fastapi import FastAPI, WebSocket
|
from fastapi import FastAPI, WebSocket
|
||||||
|
|
||||||
from paskia import db
|
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 import authz, remote
|
||||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||||
from paskia.globals import passkey
|
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
|
from paskia.util.tokens import create_token, session_key
|
||||||
|
|
||||||
# Create a FastAPI subapp for WebSocket endpoints
|
# Create a FastAPI subapp for WebSocket endpoints
|
||||||
@@ -89,6 +89,7 @@ async def websocket_register_add(
|
|||||||
host=host,
|
host=host,
|
||||||
ip=metadata.get("ip"),
|
ip=metadata.get("ip"),
|
||||||
user_agent=metadata.get("user_agent"),
|
user_agent=metadata.get("user_agent"),
|
||||||
|
actor=str(user_uuid),
|
||||||
)
|
)
|
||||||
auth = token
|
auth = token
|
||||||
|
|
||||||
@@ -140,18 +141,27 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
|||||||
|
|
||||||
# Verify the credential matches the stored data
|
# Verify the credential matches the stored data
|
||||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
|
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
|
assert stored_cred.uuid is not None
|
||||||
metadata = infodict(ws, "auth")
|
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,
|
user_uuid=stored_cred.user_uuid,
|
||||||
credential_uuid=stored_cred.uuid,
|
credential=stored_cred,
|
||||||
host=host,
|
session_key=session_key(token),
|
||||||
|
host=normalized_host,
|
||||||
ip=metadata.get("ip") or "",
|
ip=metadata.get("ip") or "",
|
||||||
user_agent=metadata.get("user_agent") or "",
|
user_agent=metadata.get("user_agent") or "",
|
||||||
|
expiry=expires(),
|
||||||
)
|
)
|
||||||
|
|
||||||
await ws.send_json(
|
await ws.send_json(
|
||||||
|
|||||||
Reference in New Issue
Block a user