Simplify session and reset token formats; removes the token utility functions entirely.

This commit is contained in:
Leo Vasanko
2026-01-23 21:01:45 +00:00
parent e93578f2e7
commit 3f5018918e
15 changed files with 112 additions and 130 deletions
+4 -6
View File
@@ -14,9 +14,7 @@ from uuid import UUID
from paskia import db from paskia import db
from paskia.config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from paskia.db import ResetToken, Session from paskia.db import ResetToken, Session
from paskia.globals import passkey
from paskia.util import hostutil from paskia.util import hostutil
from paskia.util.tokens import create_token, reset_key, session_key
EXPIRES = SESSION_LIFETIME EXPIRES = SESSION_LIFETIME
@@ -33,7 +31,7 @@ def reset_expires() -> datetime:
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(token)
if record: if record:
return record return record
raise ValueError("This authentication link is no longer valid.") raise ValueError("This authentication link is no longer valid.")
@@ -44,7 +42,7 @@ async def get_session(token: str, host: str | None = None) -> Session:
host = hostutil.normalize_host(host) host = hostutil.normalize_host(host)
if not host: if not host:
raise ValueError("Invalid host") raise ValueError("Invalid host")
session = db.get_session(session_key(token)) session = db.get_session(token)
if session: if session:
if session.host is None: if session.host is None:
# First time binding: store exact host:port (or IPv6 form) now. # First time binding: store exact host:port (or IPv6 form) now.
@@ -58,11 +56,11 @@ async def get_session(token: str, host: str | None = None) -> Session:
async def refresh_session_token(token: str, *, ip: str, user_agent: str): async def refresh_session_token(token: str, *, ip: str, user_agent: str):
"""Refresh a session extending its expiry.""" """Refresh a session extending its expiry."""
session_record = db.get_session(session_key(token)) session_record = db.get_session(token)
if not session_record: if not session_record:
raise ValueError("Session not found or expired") raise ValueError("Session not found or expired")
updated = db.update_session( updated = db.update_session(
session_key(token), token,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
expiry=expires(), expiry=expires(),
+2 -2
View File
@@ -14,7 +14,7 @@ import uuid7
from paskia import authsession, db from paskia import authsession, db
from paskia.db import Org, Permission, Role, User from paskia.db import Org, Permission, Role, User
from paskia.util import hostutil, passphrase, tokens from paskia.util import hostutil, passphrase
def _init_logger() -> logging.Logger: def _init_logger() -> logging.Logger:
@@ -44,7 +44,7 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) ->
expiry = authsession.reset_expires() expiry = authsession.reset_expires()
db.create_reset_token( db.create_reset_token(
user_uuid=user_uuid, user_uuid=user_uuid,
key=tokens.reset_key(token), passphrase=token,
expiry=expiry, expiry=expiry,
token_type=session_type, token_type=session_type,
) )
+1
View File
@@ -76,6 +76,7 @@ from paskia.db.operations import (
remove_permission_from_organization, remove_permission_from_organization,
remove_permission_from_role, remove_permission_from_role,
rename_permission, rename_permission,
set_session_host,
update_credential_sign_count, update_credential_sign_count,
update_organization_name, update_organization_name,
update_permission, update_permission,
+51 -17
View File
@@ -6,7 +6,9 @@ Context lookup: get_session_context() returns full SessionContext with effective
Write operations: Functions that validate and commit, or raise ValueError. Write operations: Functions that validate and commit, or raise ValueError.
""" """
import hashlib
import os import os
import secrets
from collections import deque from collections import deque
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -41,6 +43,7 @@ from paskia.db.structs import (
_SessionData, _SessionData,
_UserData, _UserData,
) )
from paskia.util.passphrase import is_well_formed as _is_passphrase
# msgspec encoder/decoder # msgspec encoder/decoder
_json_encoder = msgspec.json.Encoder() _json_encoder = msgspec.json.Encoder()
@@ -174,7 +177,7 @@ def build_credential(uuid: UUID) -> Credential:
) )
def build_session(key: bytes) -> Session: def build_session(key: str) -> Session:
s = _db._data.sessions[key] s = _db._data.sessions[key]
return Session( return Session(
key=key, key=key,
@@ -331,7 +334,7 @@ def get_credentials_by_user_uuid(user_uuid: str | UUID) -> list[Credential]:
] ]
def get_session(key: bytes) -> Session | None: def get_session(key: str) -> Session | None:
"""Get session by key.""" """Get session by key."""
if key not in _db._data.sessions: if key not in _db._data.sessions:
return None return None
@@ -353,8 +356,20 @@ def list_sessions_for_user(user_uuid: str | UUID) -> list[Session]:
] ]
def get_reset_token(key: bytes) -> ResetToken | None: def _reset_key(passphrase: str) -> bytes:
"""Get reset token by key.""" """Hash a passphrase to bytes for reset token storage."""
if not _is_passphrase(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hashlib.sha512(passphrase.encode()).digest()[:9]
def get_reset_token(passphrase: str) -> ResetToken | None:
"""Get reset token by passphrase."""
key = _reset_key(passphrase)
if key not in _db._data.reset_tokens: if key not in _db._data.reset_tokens:
return None return None
t = _db._data.reset_tokens[key] t = _db._data.reset_tokens[key]
@@ -369,12 +384,12 @@ def get_reset_token(key: bytes) -> ResetToken | None:
def get_session_context( def get_session_context(
session_key: bytes, host: str | None = None session_key: str, host: str | None = None
) -> SessionContext | None: ) -> SessionContext | None:
"""Get full session context with effective permissions. """Get full session context with effective permissions.
Args: Args:
session_key: The session key bytes session_key: The session key string
host: Optional host for binding/validation and domain-scoped permissions host: Optional host for binding/validation and domain-scoped permissions
Returns: Returns:
@@ -832,7 +847,7 @@ def delete_credential(
def create_session( def create_session(
key: bytes, key: str,
user_uuid: UUID, user_uuid: UUID,
credential_uuid: UUID, credential_uuid: UUID,
host: str | None, host: str | None,
@@ -860,7 +875,8 @@ def create_session(
def update_session( def update_session(
key: bytes, key: str,
host: str | None = None,
ip: str | None = None, ip: str | None = None,
user_agent: str | None = None, user_agent: str | None = None,
expiry: datetime | None = None, expiry: datetime | None = None,
@@ -871,6 +887,8 @@ def update_session(
raise ValueError("Session not found") raise ValueError("Session not found")
with _db.transaction(actor): with _db.transaction(actor):
s = _db._data.sessions[key] s = _db._data.sessions[key]
if host is not None:
s.host = host
if ip is not None: if ip is not None:
s.ip = ip s.ip = ip
if user_agent is not None: if user_agent is not None:
@@ -879,7 +897,12 @@ def update_session(
s.expiry = expiry s.expiry = expiry
def delete_session(key: bytes, actor: str = "system") -> None: def set_session_host(key: str, host: str, actor: str = "system") -> None:
"""Set the host for a session (first-time binding)."""
update_session(key, host=host, actor=actor)
def delete_session(key: str, actor: str = "system") -> None:
"""Delete a session.""" """Delete a session."""
if key not in _db._data.sessions: if key not in _db._data.sessions:
raise ValueError("Session not found") raise ValueError("Session not found")
@@ -898,13 +921,14 @@ def delete_sessions_for_user(user_uuid: str | UUID, actor: str = "system") -> No
def create_reset_token( def create_reset_token(
key: bytes, passphrase: str,
user_uuid: UUID, user_uuid: UUID,
expiry: datetime, expiry: datetime,
token_type: str, token_type: str,
actor: str = "system", actor: str = "system",
) -> None: ) -> None:
"""Create a reset token.""" """Create a reset token from a passphrase."""
key = _reset_key(passphrase)
if key in _db._data.reset_tokens: if key in _db._data.reset_tokens:
raise ValueError("Reset token already exists") raise ValueError("Reset token already exists")
if user_uuid not in _db._data.users: if user_uuid not in _db._data.users:
@@ -951,15 +975,21 @@ def cleanup_expired(actor: str = "system") -> int:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def _create_token() -> str:
"""Generate a 16-character session token using standard base64."""
import base64
return base64.b64encode(secrets.token_bytes(12)).decode()
def login( def login(
user_uuid: str | UUID, user_uuid: str | UUID,
credential: Credential, credential: Credential,
session_key: bytes,
host: str | None, host: str | None,
ip: str | None, ip: str | None,
user_agent: str | None, user_agent: str | None,
expiry: datetime, expiry: datetime,
) -> None: ) -> str:
"""Update user/credential on login and create session in a single transaction. """Update user/credential on login and create session in a single transaction.
Updates: Updates:
@@ -969,6 +999,7 @@ def login(
- new session - new session
Actor is set to the user UUID being logged in. Actor is set to the user UUID being logged in.
Returns the generated session token.
""" """
if isinstance(user_uuid, str): if isinstance(user_uuid, str):
user_uuid = UUID(user_uuid) user_uuid = UUID(user_uuid)
@@ -977,9 +1008,8 @@ def login(
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")
session_key = _create_token()
actor = str(user_uuid) actor = str(user_uuid)
with _db.transaction(actor): with _db.transaction(actor):
# Update user # Update user
@@ -997,19 +1027,19 @@ def login(
user_agent=user_agent, user_agent=user_agent,
expiry=expiry, expiry=expiry,
) )
return session_key
def create_credential_session( def create_credential_session(
user_uuid: UUID, user_uuid: UUID,
credential: Credential, credential: Credential,
session_key: bytes,
host: str | None, host: str | None,
ip: str | None, ip: str | None,
user_agent: str | None, user_agent: str | None,
display_name: str | None = None, display_name: str | None = None,
reset_key: bytes | None = None, reset_key: bytes | None = None,
actor: str = "system", actor: str = "system",
) -> None: ) -> str:
"""Create a credential and session together, optionally consuming a reset token. """Create a credential and session together, optionally consuming a reset token.
Used during registration to atomically: Used during registration to atomically:
@@ -1017,11 +1047,14 @@ def create_credential_session(
2. Create the credential 2. Create the credential
3. Create the session 3. Create the session
4. Delete the reset token if provided 4. Delete the reset token if provided
Returns the generated session token.
""" """
from paskia.config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expiry = now + SESSION_LIFETIME expiry = now + SESSION_LIFETIME
session_key = _create_token()
if user_uuid not in _db._data.users: if user_uuid not in _db._data.users:
raise ValueError(f"User {user_uuid} not found") raise ValueError(f"User {user_uuid} not found")
@@ -1057,3 +1090,4 @@ def create_credential_session(
if reset_key: if reset_key:
if reset_key in _db._data.reset_tokens: if reset_key in _db._data.reset_tokens:
del _db._data.reset_tokens[reset_key] del _db._data.reset_tokens[reset_key]
return session_key
+2 -2
View File
@@ -47,7 +47,7 @@ class Credential(msgspec.Struct):
class Session(msgspec.Struct): class Session(msgspec.Struct):
key: bytes key: str
user_uuid: UUID user_uuid: UUID
credential_uuid: UUID credential_uuid: UUID
host: str | None host: str | None
@@ -143,6 +143,6 @@ class _DatabaseData(msgspec.Struct, omit_defaults=True):
roles: dict[UUID, _RoleData] roles: dict[UUID, _RoleData]
users: dict[UUID, _UserData] users: dict[UUID, _UserData]
credentials: dict[UUID, _CredentialData] credentials: dict[UUID, _CredentialData]
sessions: dict[bytes, _SessionData] sessions: dict[str, _SessionData]
reset_tokens: dict[bytes, _ResetTokenData] reset_tokens: dict[bytes, _ResetTokenData]
v: int = 0 v: int = 0
+15 -18
View File
@@ -15,10 +15,8 @@ from paskia.util import (
passphrase, passphrase,
permutil, permutil,
querysafe, querysafe,
tokens,
useragent, useragent,
) )
from paskia.util.tokens import encode_session_key, session_key
app = FastAPI() app = FastAPI()
@@ -217,7 +215,9 @@ async def admin_add_org_permission(
ctx = await authz.verify( ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
) )
db.add_permission_to_organization(str(org_uuid), permission_id, actor=str(ctx.user.uuid)) db.add_permission_to_organization(
str(org_uuid), permission_id, actor=str(ctx.user.uuid)
)
return {"status": "ok"} return {"status": "ok"}
@@ -241,7 +241,9 @@ async def admin_remove_org_permission(
"This would lock you out of admin access." "This would lock you out of admin access."
) )
db.remove_permission_from_organization(str(org_uuid), permission_id, actor=str(ctx.user.uuid)) db.remove_permission_from_organization(
str(org_uuid), permission_id, actor=str(ctx.user.uuid)
)
return {"status": "ok"} return {"status": "ok"}
@@ -543,7 +545,7 @@ async def admin_create_user_registration_link(
expiry = reset_expires() expiry = reset_expires()
db.create_reset_token( db.create_reset_token(
user_uuid=user_uuid, user_uuid=user_uuid,
key=tokens.reset_key(token), passphrase=token,
expiry=expiry, expiry=expiry,
token_type=token_type, token_type=token_type,
actor=str(ctx.user.uuid), actor=str(ctx.user.uuid),
@@ -640,13 +642,13 @@ async def admin_get_user_detail(
# Get sessions for the user # Get sessions for the user
normalized_request_host = hostutil.normalize_host(request.headers.get("host")) normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
session_records = db.list_sessions_for_user(user_uuid) session_records = db.list_sessions_for_user(user_uuid)
current_session_key = session_key(auth) current_session_key = auth
sessions_payload: list[dict] = [] sessions_payload: list[dict] = []
for entry in session_records: for entry in session_records:
renewed = entry.expiry - EXPIRES renewed = entry.expiry - EXPIRES
sessions_payload.append( sessions_payload.append(
{ {
"id": encode_session_key(entry.key), "id": entry.key,
"credential_uuid": str(entry.credential_uuid), "credential_uuid": str(entry.credential_uuid),
"host": entry.host, "host": entry.host,
"ip": entry.ip, "ip": entry.ip,
@@ -787,21 +789,14 @@ async def admin_delete_user_session(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
try: target_session = db.get_session(session_id)
target_key = tokens.decode_session_key(session_id)
except ValueError as exc:
raise HTTPException(
status_code=400, detail="Invalid session identifier"
) from exc
target_session = db.get_session(target_key)
if not target_session or target_session.user_uuid != user_uuid: if not target_session or target_session.user_uuid != 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, actor=str(ctx.user.uuid)) db.delete_session(session_id, actor=str(ctx.user.uuid))
# Check if admin terminated their own session # Check if admin terminated their own session
current_terminated = target_key == session_key(auth) current_terminated = session_id == auth
return {"status": "ok", "current_session_terminated": current_terminated} return {"status": "ok", "current_session_terminated": current_terminated}
@@ -1047,7 +1042,9 @@ async def admin_rename_permission(
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host")) _check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
# All current backends support rename_permission # All current backends support rename_permission
db.rename_permission(old_scope, new_scope, display_name, domain_value, actor=str(ctx.user.uuid)) db.rename_permission(
old_scope, new_scope, display_name, domain_value, actor=str(ctx.user.uuid)
)
return {"status": "ok"} return {"status": "ok"}
+1 -2
View File
@@ -24,7 +24,6 @@ from paskia.fastapi import authz, session, user
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
from paskia.globals import passkey as global_passkey from paskia.globals import passkey as global_passkey
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
from paskia.util.tokens import session_key
bearer_auth = HTTPBearer(auto_error=True) bearer_auth = HTTPBearer(auto_error=True)
@@ -293,7 +292,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
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), actor=str(s.user_uuid)) db.delete_session(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"}
+5 -9
View File
@@ -342,25 +342,23 @@ 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 hostutil, tokens from paskia.util import hostutil
token_str = passphrase.generate() token_str = passphrase.generate()
expiry = expires() expiry = expires()
db.create_reset_token( db.create_reset_token(
user_uuid=stored_cred.user_uuid, user_uuid=stored_cred.user_uuid,
key=tokens.reset_key(token_str), passphrase=token_str,
expiry=expiry, expiry=expiry,
token_type="device addition", token_type="device addition",
actor=str(stored_cred.user_uuid), 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
session_token = passphrase.generate()
normalized_host = hostutil.normalize_host(request.host) normalized_host = hostutil.normalize_host(request.host)
db.login( session_token = db.login(
user_uuid=stored_cred.user_uuid, user_uuid=stored_cred.user_uuid,
credential=stored_cred, credential=stored_cred,
session_key=tokens.session_key(session_token),
host=normalized_host, host=normalized_host,
ip=request.ip, ip=request.ip,
user_agent=request.user_agent, user_agent=request.user_agent,
@@ -369,14 +367,12 @@ async def websocket_remote_auth_permit(ws: WebSocket):
else: else:
# Default login action # Default login action
from paskia.authsession import expires from paskia.authsession import expires
from paskia.util import hostutil, tokens from paskia.util import hostutil
session_token = passphrase.generate()
normalized_host = hostutil.normalize_host(request.host) normalized_host = hostutil.normalize_host(request.host)
db.login( session_token = db.login(
user_uuid=stored_cred.user_uuid, user_uuid=stored_cred.user_uuid,
credential=stored_cred, credential=stored_cred,
session_key=tokens.session_key(session_token),
host=normalized_host, host=normalized_host,
ip=request.ip, ip=request.ip,
user_agent=request.user_agent, user_agent=request.user_agent,
+1 -2
View File
@@ -18,7 +18,6 @@ from uuid import UUID
from paskia import authsession as _authsession from paskia import authsession as _authsession
from paskia import db as _db from paskia import db as _db
from paskia.util import hostutil, passphrase from paskia.util import hostutil, passphrase
from paskia.util import tokens as _tokens
async def _resolve_targets(query: str | None): async def _resolve_targets(query: str | None):
@@ -65,7 +64,7 @@ async def _create_reset(user, role_name: str):
token = passphrase.generate() token = passphrase.generate()
expiry = _authsession.reset_expires() expiry = _authsession.reset_expires()
_db.create_reset_token( _db.create_reset_token(
key=_tokens.reset_key(token), passphrase=token,
user_uuid=user.uuid, user_uuid=user.uuid,
expiry=expiry, expiry=expiry,
token_type="manual reset", token_type="manual reset",
+5 -13
View File
@@ -18,8 +18,7 @@ from paskia.authsession import (
) )
from paskia.fastapi import authz, session from paskia.fastapi import authz, session
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, tokens from paskia.util import hostutil, passphrase
from paskia.util.tokens import decode_session_key, session_key
app = FastAPI() app = FastAPI()
@@ -92,19 +91,12 @@ async def api_delete_session(
status_code=401, detail="Session expired", mode="login" status_code=401, detail="Session expired", mode="login"
) from exc ) from exc
try: target_session = db.get_session(session_id)
target_key = decode_session_key(session_id)
except ValueError as exc:
raise HTTPException(
status_code=400, detail="Invalid session identifier"
) from exc
target_session = db.get_session(target_key)
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, actor=str(current_session.user_uuid)) db.delete_session(session_id, actor=str(current_session.user_uuid))
current_terminated = target_key == session_key(auth) current_terminated = session_id == auth
if current_terminated: if current_terminated:
session.clear_session_cookie(response) # explicit because 200 session.clear_session_cookie(response) # explicit because 200
return {"status": "ok", "current_session_terminated": current_terminated} return {"status": "ok", "current_session_terminated": current_terminated}
@@ -146,7 +138,7 @@ async def api_create_link(
expiry = expires() expiry = expires()
db.create_reset_token( db.create_reset_token(
user_uuid=s.user_uuid, user_uuid=s.user_uuid,
key=tokens.reset_key(token), passphrase=token,
expiry=expiry, expiry=expiry,
token_type="device addition", token_type="device addition",
actor=str(s.user_uuid), actor=str(s.user_uuid),
+2 -7
View File
@@ -9,7 +9,6 @@ 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 hostutil, passphrase from paskia.util import hostutil, passphrase
from paskia.util.tokens import create_token, session_key
# Create a FastAPI subapp for WebSocket endpoints # Create a FastAPI subapp for WebSocket endpoints
app = FastAPI() app = FastAPI()
@@ -78,13 +77,11 @@ async def websocket_register_add(
credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids) credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
# Create a new session and store everything in database # Create a new session and store everything in database
token = create_token()
metadata = infodict(ws, "authenticated") metadata = infodict(ws, "authenticated")
db.create_credential_session( # type: ignore[attr-defined] token = db.create_credential_session( # type: ignore[attr-defined]
user_uuid=user_uuid, user_uuid=user_uuid,
credential=credential, credential=credential,
reset_key=(s.key if reset is not None else None), reset_key=(s.key if reset is not None else None),
session_key=session_key(token),
display_name=user_name, display_name=user_name,
host=host, host=host,
ip=metadata.get("ip"), ip=metadata.get("ip"),
@@ -145,7 +142,6 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
# Create session and update user/credential in a single transaction # 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 = create_token()
normalized_host = hostutil.normalize_host(host) normalized_host = hostutil.normalize_host(host)
if not normalized_host: if not normalized_host:
raise ValueError("Host required for session creation") raise ValueError("Host required for session creation")
@@ -154,10 +150,9 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
if not (hostname == rp_id or hostname.endswith(f".{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}") raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
db.login( token = db.login(
user_uuid=stored_cred.user_uuid, user_uuid=stored_cred.user_uuid,
credential=stored_cred, credential=stored_cred,
session_key=session_key(token),
host=normalized_host, 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 "",
+18 -2
View File
@@ -15,6 +15,8 @@ import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
import base64url
from paskia.authsession import EXPIRES from paskia.authsession import EXPIRES
from .sql import ( from .sql import (
@@ -194,12 +196,19 @@ async def migrate_from_sql(
print(f" Migrated {len(cred_models)} credentials") print(f" Migrated {len(cred_models)} credentials")
# Migrate sessions # Migrate sessions
# Old format: b"sess" + 12 bytes -> New format: base64url string (16 chars)
async with sql_db.session() as session: async with sql_db.session() as session:
result = await session.execute(select(SessionModel)) result = await session.execute(select(SessionModel))
session_models = result.scalars().all() session_models = result.scalars().all()
for sm in session_models: for sm in session_models:
sess = sm.as_dataclass() sess = sm.as_dataclass()
session_key: bytes = sess.key old_key: bytes = sess.key
# Strip b"sess" prefix and encode remaining 12 bytes as base64url
if old_key.startswith(b"sess"):
session_key = base64url.enc(old_key[4:])
else:
# Already in new format or unknown - try to use as-is
session_key = base64url.enc(old_key[:12])
json_db._data.sessions[session_key] = _SessionData( json_db._data.sessions[session_key] = _SessionData(
user=sess.user_uuid, user=sess.user_uuid,
credential=sess.credential_uuid, credential=sess.credential_uuid,
@@ -211,12 +220,19 @@ async def migrate_from_sql(
print(f" Migrated {len(session_models)} sessions") print(f" Migrated {len(session_models)} sessions")
# Migrate reset tokens # Migrate reset tokens
# Old format: b"rset" + 16 bytes hash -> New format: 9 bytes (truncated hash)
async with sql_db.session() as session: async with sql_db.session() as session:
result = await session.execute(select(ResetTokenModel)) result = await session.execute(select(ResetTokenModel))
token_models = result.scalars().all() token_models = result.scalars().all()
for tm in token_models: for tm in token_models:
token = tm.as_dataclass() token = tm.as_dataclass()
token_key: bytes = token.key old_key: bytes = token.key
# Strip b"rset" prefix and take first 9 bytes of hash
if old_key.startswith(b"rset"):
token_key = old_key[4:13] # 9 bytes after prefix
else:
# Already in new format or unknown - truncate to 9 bytes
token_key = old_key[:9]
json_db._data.reset_tokens[token_key] = _ResetTokenData( json_db._data.reset_tokens[token_key] = _ResetTokenData(
user=token.user_uuid, user=token.user_uuid,
expiry=token.expiry, expiry=token.expiry,
+1 -2
View File
@@ -5,7 +5,6 @@ from fnmatch import fnmatchcase
from paskia import db from paskia import db
from paskia.util.hostutil import normalize_host from paskia.util.hostutil import normalize_host
from paskia.util.tokens import session_key
__all__ = ["has_any", "has_all", "session_context"] __all__ = ["has_any", "has_all", "session_context"]
@@ -41,4 +40,4 @@ async def session_context(auth: str | None, host: str | None = None):
if not auth: if not auth:
return None return None
normalized_host = normalize_host(host) if host else None normalized_host = normalize_host(host) if host else None
return db.get_session_context(session_key(auth), normalized_host) return db.get_session_context(auth, normalized_host)
-44
View File
@@ -1,44 +0,0 @@
import hashlib
import secrets
import base64url
from paskia.util.passphrase import is_well_formed
def create_token() -> str:
return secrets.token_urlsafe(12) # 16 characters Base64
def session_key(token: str) -> bytes:
if len(token) != 16:
raise ValueError("Session token must be exactly 16 characters long")
return b"sess" + base64url.dec(token)
def encode_session_key(key: bytes) -> str:
"""Encode an opaque session key for external representation."""
return base64url.enc(key)
def decode_session_key(encoded: str) -> bytes:
"""Decode an opaque session key from its public representation."""
if not encoded:
raise ValueError("Invalid session identifier")
try:
raw = base64url.dec(encoded)
except Exception as exc: # pragma: no cover - defensive
raise ValueError("Invalid session identifier") from exc
if not raw.startswith(b"sess"):
raise ValueError("Invalid session identifier")
return raw
def reset_key(passphrase: str) -> bytes:
if not is_well_formed(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return b"rset" + hashlib.sha512(passphrase.encode()).digest()[:12]
+4 -4
View File
@@ -3,8 +3,8 @@
from datetime import timezone from datetime import timezone
from paskia import aaguid, db from paskia import aaguid, db
from paskia.authsession import EXPIRES, session_key from paskia.authsession import EXPIRES
from paskia.util import hostutil, permutil, tokens, useragent from paskia.util import hostutil, permutil, useragent
def _format_datetime(dt): def _format_datetime(dt):
@@ -87,13 +87,13 @@ async def format_user_info(
# Format sessions # Format sessions
normalized_request_host = hostutil.normalize_host(request_host) normalized_request_host = hostutil.normalize_host(request_host)
session_records = db.list_sessions_for_user(user_uuid) session_records = db.list_sessions_for_user(user_uuid)
current_session_key = session_key(auth) current_session_key = auth
sessions_payload: list[dict] = [] sessions_payload: list[dict] = []
for entry in session_records: for entry in session_records:
sessions_payload.append( sessions_payload.append(
{ {
"id": tokens.encode_session_key(entry.key), "id": entry.key,
"credential_uuid": str(entry.credential_uuid), "credential_uuid": str(entry.credential_uuid),
"host": entry.host, "host": entry.host,
"ip": entry.ip, "ip": entry.ip,