Simplify session and reset token formats; removes the token utility functions entirely.
This commit is contained in:
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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 "",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user