Replace session.renewed with .expiry for consistency with other expiring items. Fix migration script.
This commit is contained in:
+5
-12
@@ -31,12 +31,6 @@ def reset_expires() -> datetime:
|
||||
return datetime.now(timezone.utc) + RESET_LIFETIME
|
||||
|
||||
|
||||
def session_expiry(session: Session) -> datetime:
|
||||
"""Calculate the expiration timestamp for a session (UTC aware)."""
|
||||
# After migration all renewed timestamps are timezone-aware UTC
|
||||
return session.renewed + EXPIRES
|
||||
|
||||
|
||||
async def create_session(
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
@@ -54,7 +48,6 @@ async def create_session(
|
||||
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()
|
||||
now = datetime.now(timezone.utc)
|
||||
db.create_session(
|
||||
user_uuid=user_uuid,
|
||||
credential_uuid=credential_uuid,
|
||||
@@ -62,15 +55,15 @@ async def create_session(
|
||||
host=normalized_host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=now,
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_reset(token: str) -> ResetToken:
|
||||
"""Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
|
||||
"""Validate a credential reset token."""
|
||||
record = db.get_reset_token(reset_key(token))
|
||||
if record and record.expiry >= datetime.now(timezone.utc):
|
||||
if record:
|
||||
return record
|
||||
raise ValueError("This authentication link is no longer valid.")
|
||||
|
||||
@@ -81,7 +74,7 @@ async def get_session(token: str, host: str | None = None) -> Session:
|
||||
if not host:
|
||||
raise ValueError("Invalid host")
|
||||
session = db.get_session(session_key(token))
|
||||
if session and session_expiry(session) >= datetime.now(timezone.utc):
|
||||
if session:
|
||||
if session.host is None:
|
||||
# First time binding: store exact host:port (or IPv6 form) now.
|
||||
db.set_session_host(session.key, host)
|
||||
@@ -101,7 +94,7 @@ async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
||||
session_key(token),
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=datetime.now(timezone.utc),
|
||||
expiry=expires(),
|
||||
)
|
||||
if not updated:
|
||||
raise ValueError("Session not found or expired")
|
||||
|
||||
+14
-17
@@ -99,14 +99,14 @@ class Session(msgspec.Struct):
|
||||
host: str | None
|
||||
ip: str | None
|
||||
user_agent: str | None
|
||||
renewed: datetime
|
||||
expiry: datetime
|
||||
|
||||
def metadata(self) -> dict:
|
||||
"""Return session metadata for backwards compatibility."""
|
||||
return {
|
||||
"ip": self.ip,
|
||||
"user_agent": self.user_agent,
|
||||
"renewed": self.renewed.isoformat(),
|
||||
"expiry": self.expiry.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ class _SessionData(msgspec.Struct):
|
||||
host: str | None
|
||||
ip: str | None
|
||||
user_agent: str | None
|
||||
renewed: datetime
|
||||
expiry: datetime
|
||||
|
||||
|
||||
class _ResetTokenData(msgspec.Struct):
|
||||
@@ -519,7 +519,7 @@ class DB:
|
||||
host=s.host,
|
||||
ip=s.ip,
|
||||
user_agent=s.user_agent,
|
||||
renewed=s.renewed,
|
||||
expiry=s.expiry,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -664,7 +664,7 @@ class DB:
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
expiry: datetime,
|
||||
actor: str = "system",
|
||||
) -> None:
|
||||
with self.session(actor):
|
||||
@@ -675,7 +675,7 @@ class DB:
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=renewed,
|
||||
expiry=expiry,
|
||||
)
|
||||
|
||||
def get_session(self, key: bytes) -> Session | None:
|
||||
@@ -697,7 +697,7 @@ class DB:
|
||||
*,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
expiry: datetime,
|
||||
actor: str = "system",
|
||||
) -> Session | None:
|
||||
with self.session(actor):
|
||||
@@ -707,7 +707,7 @@ class DB:
|
||||
s = self._data.sessions[key_b64]
|
||||
s.ip = ip
|
||||
s.user_agent = user_agent
|
||||
s.renewed = renewed
|
||||
s.expiry = expiry
|
||||
return self._build_session(key_b64)
|
||||
|
||||
def set_session_host(self, key: bytes, host: str, actor: str = "system") -> None:
|
||||
@@ -727,8 +727,8 @@ class DB:
|
||||
key_bytes = _str_to_bytes(key_b64)
|
||||
if key_bytes and key_bytes.startswith(b"sess"):
|
||||
sessions.append(self._build_session(key_b64))
|
||||
# Sort by renewed desc
|
||||
sessions.sort(key=lambda x: x.renewed, reverse=True)
|
||||
# Sort by expiry desc (most recent expiry first)
|
||||
sessions.sort(key=lambda x: x.expiry, reverse=True)
|
||||
return sessions
|
||||
|
||||
def delete_sessions_for_user(self, user_uuid: UUID, actor: str = "system") -> None:
|
||||
@@ -1186,7 +1186,7 @@ class DB:
|
||||
if display_name and user_key in self._data.users:
|
||||
self._data.users[user_key].display_name = display_name
|
||||
|
||||
# New session
|
||||
# New session - compute expiry from credential.last_used
|
||||
sess_key_b64 = _bytes_to_str(session_key)
|
||||
self._data.sessions[sess_key_b64] = _SessionData(
|
||||
user=user_key,
|
||||
@@ -1194,7 +1194,7 @@ class DB:
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=credential.last_used,
|
||||
expiry=credential.last_used + SESSION_LIFETIME,
|
||||
)
|
||||
|
||||
# Login side-effects
|
||||
@@ -1208,13 +1208,11 @@ class DB:
|
||||
"""Remove expired sessions and reset tokens."""
|
||||
with self.session("expiry"):
|
||||
current_time = datetime.now(timezone.utc)
|
||||
session_threshold = current_time - SESSION_LIFETIME
|
||||
|
||||
# Clean expired sessions
|
||||
to_delete_sessions = []
|
||||
for k, s in self._data.sessions.items():
|
||||
renewed = s.renewed
|
||||
if renewed and renewed < session_threshold:
|
||||
if s.expiry < current_time:
|
||||
to_delete_sessions.append(k)
|
||||
for k in to_delete_sessions:
|
||||
del self._data.sessions[k]
|
||||
@@ -1222,8 +1220,7 @@ class DB:
|
||||
# Clean expired reset tokens
|
||||
to_delete_tokens = []
|
||||
for k, t in self._data.reset_tokens.items():
|
||||
expiry = t.expiry
|
||||
if expiry and expiry < current_time:
|
||||
if t.expiry < current_time:
|
||||
to_delete_tokens.append(k)
|
||||
for k in to_delete_tokens:
|
||||
del self._data.reset_tokens[k]
|
||||
|
||||
@@ -5,7 +5,7 @@ from uuid import UUID, uuid4
|
||||
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.authsession import EXPIRES, reset_expires
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia import db
|
||||
@@ -556,6 +556,7 @@ async def admin_get_user_detail(
|
||||
current_session_key = session_key(auth)
|
||||
sessions_payload: list[dict] = []
|
||||
for entry in session_records:
|
||||
renewed = entry.expiry - EXPIRES
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": encode_session_key(entry.key),
|
||||
@@ -564,11 +565,11 @@ async def admin_get_user_detail(
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": (
|
||||
entry.renewed.astimezone(timezone.utc)
|
||||
renewed.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if entry.renewed.tzinfo
|
||||
else entry.renewed.replace(tzinfo=timezone.utc)
|
||||
if renewed.tzinfo
|
||||
else renewed.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
|
||||
@@ -18,7 +18,6 @@ from paskia.authsession import (
|
||||
get_reset,
|
||||
get_session,
|
||||
refresh_session_token,
|
||||
session_expiry,
|
||||
)
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
||||
@@ -98,8 +97,7 @@ async def validate_token(
|
||||
raise
|
||||
renewed = False
|
||||
if auth:
|
||||
current_expiry = session_expiry(ctx.session)
|
||||
consumed = EXPIRES - (current_expiry - datetime.now(timezone.utc))
|
||||
consumed = EXPIRES - (ctx.session.expiry - datetime.now(timezone.utc))
|
||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||
try:
|
||||
await refresh_session_token(
|
||||
@@ -160,12 +158,12 @@ async def forward_authentication(
|
||||
"Remote-Role": str(ctx.role.uuid),
|
||||
"Remote-Role-Name": ctx.role.display_name,
|
||||
"Remote-Session-Expires": (
|
||||
session_expiry(ctx.session)
|
||||
ctx.session.expiry
|
||||
.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if session_expiry(ctx.session).tzinfo
|
||||
else session_expiry(ctx.session)
|
||||
if ctx.session.expiry.tzinfo
|
||||
else ctx.session.expiry
|
||||
.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
|
||||
+23
-23
@@ -16,6 +16,8 @@ from datetime import datetime, timezone
|
||||
|
||||
import base64url
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
|
||||
from .sql import (
|
||||
DB as SQLDB,
|
||||
)
|
||||
@@ -56,15 +58,13 @@ async def migrate_from_sql(
|
||||
|
||||
from paskia.db.json import (
|
||||
DB as JSONDB,
|
||||
)
|
||||
from paskia.db.json import (
|
||||
CredentialData,
|
||||
OrgData,
|
||||
PermissionData,
|
||||
ResetTokenData,
|
||||
RoleData,
|
||||
SessionData,
|
||||
UserData,
|
||||
_CredentialData,
|
||||
_OrgData,
|
||||
_PermissionData,
|
||||
_ResetTokenData,
|
||||
_RoleData,
|
||||
_SessionData,
|
||||
_UserData,
|
||||
)
|
||||
|
||||
# Initialize source SQL database
|
||||
@@ -73,16 +73,16 @@ async def migrate_from_sql(
|
||||
|
||||
# Initialize destination JSON database
|
||||
json_db = JSONDB(json_db_path)
|
||||
await json_db.init_db()
|
||||
json_db.load()
|
||||
|
||||
print(f"Migrating from {sql_db_path} to {json_db_path}...")
|
||||
|
||||
# Build all data directly without saving (we'll save once at the end)
|
||||
async with json_db._lock:
|
||||
with json_db._lock:
|
||||
# Migrate permissions
|
||||
permissions = await sql_db.list_permissions()
|
||||
for perm in permissions:
|
||||
json_db._data.permissions[perm.id] = PermissionData(
|
||||
json_db._data.permissions[perm.id] = _PermissionData(
|
||||
display_name=perm.display_name,
|
||||
orgs={},
|
||||
)
|
||||
@@ -92,7 +92,7 @@ async def migrate_from_sql(
|
||||
orgs = await sql_db.list_organizations()
|
||||
for org in orgs:
|
||||
key = str(org.uuid)
|
||||
json_db._data.orgs[key] = OrgData(
|
||||
json_db._data.orgs[key] = _OrgData(
|
||||
display_name=org.display_name,
|
||||
)
|
||||
# Update permissions to allow this org to grant them
|
||||
@@ -106,7 +106,7 @@ async def migrate_from_sql(
|
||||
for org in orgs:
|
||||
for role in org.roles:
|
||||
key = str(role.uuid)
|
||||
json_db._data.roles[key] = RoleData(
|
||||
json_db._data.roles[key] = _RoleData(
|
||||
org=str(role.org_uuid),
|
||||
display_name=role.display_name,
|
||||
permissions={p: True for p in role.permissions}
|
||||
@@ -123,7 +123,7 @@ async def migrate_from_sql(
|
||||
for um in user_models:
|
||||
user = um.as_dataclass()
|
||||
key = str(user.uuid)
|
||||
json_db._data.users[key] = UserData(
|
||||
json_db._data.users[key] = _UserData(
|
||||
display_name=user.display_name,
|
||||
role=str(user.role_uuid),
|
||||
created_at=user.created_at or datetime.now(timezone.utc),
|
||||
@@ -139,7 +139,7 @@ async def migrate_from_sql(
|
||||
for cm in cred_models:
|
||||
cred = cm.as_dataclass()
|
||||
key = str(cred.uuid)
|
||||
json_db._data.credentials[key] = CredentialData(
|
||||
json_db._data.credentials[key] = _CredentialData(
|
||||
credential_id=cred.credential_id,
|
||||
user=str(cred.user_uuid),
|
||||
aaguid=str(cred.aaguid),
|
||||
@@ -158,13 +158,13 @@ async def migrate_from_sql(
|
||||
for sm in session_models:
|
||||
sess = sm.as_dataclass()
|
||||
key_b64 = _bytes_to_str(sess.key)
|
||||
json_db._data.sessions[key_b64] = SessionData(
|
||||
json_db._data.sessions[key_b64] = _SessionData(
|
||||
user=str(sess.user_uuid),
|
||||
credential=str(sess.credential_uuid),
|
||||
host=sess.host,
|
||||
ip=sess.ip,
|
||||
user_agent=sess.user_agent,
|
||||
renewed=sess.renewed,
|
||||
expiry=sess.renewed + EXPIRES, # Convert renewed to expiry
|
||||
)
|
||||
print(f" Migrated {len(session_models)} sessions")
|
||||
|
||||
@@ -175,17 +175,17 @@ async def migrate_from_sql(
|
||||
for tm in token_models:
|
||||
token = tm.as_dataclass()
|
||||
key_b64 = _bytes_to_str(token.key)
|
||||
json_db._data.reset_tokens[key_b64] = ResetTokenData(
|
||||
json_db._data.reset_tokens[key_b64] = _ResetTokenData(
|
||||
user=str(token.user_uuid),
|
||||
expiry=token.expiry,
|
||||
token_type=token.token_type,
|
||||
)
|
||||
print(f" Migrated {len(token_models)} reset tokens")
|
||||
|
||||
# Save all changes as a single diff with actor "migrate"
|
||||
# Start from empty {} so diff shows pure insertions
|
||||
json_db._previous_builtins = {}
|
||||
await json_db._save(actor="migrate")
|
||||
# Queue and flush all changes with actor "migrate"
|
||||
json_db._current_actor = "migrate"
|
||||
json_db._queue_change()
|
||||
json_db.flush()
|
||||
|
||||
print("Migration complete!")
|
||||
|
||||
|
||||
+16
-3
@@ -8,6 +8,7 @@ DO NOT use this module for new code. Use paskia.db.json instead.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
@@ -30,13 +31,25 @@ from paskia.db import (
|
||||
Permission,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
User,
|
||||
)
|
||||
|
||||
DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
|
||||
|
||||
|
||||
# Local Session class for SQL schema (uses 'renewed' not 'expiry')
|
||||
@dataclass
|
||||
class _SqlSession:
|
||||
"""Session as stored in the old SQL schema with renewed timestamp."""
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
credential_uuid: UUID
|
||||
host: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
renewed: datetime
|
||||
|
||||
|
||||
def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -187,7 +200,7 @@ class SessionModel(Base):
|
||||
)
|
||||
|
||||
def as_dataclass(self):
|
||||
return Session(
|
||||
return _SqlSession(
|
||||
key=self.key,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
credential_uuid=UUID(bytes=self.credential_uuid),
|
||||
@@ -198,7 +211,7 @@ class SessionModel(Base):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(session: Session):
|
||||
def from_dataclass(session: _SqlSession):
|
||||
return SessionModel(
|
||||
key=session.key,
|
||||
user_uuid=session.user_uuid.bytes,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import SessionContext
|
||||
from paskia.util.timeutil import parse_duration
|
||||
|
||||
@@ -27,11 +28,11 @@ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
||||
|
||||
max_age_delta = parse_duration(max_age)
|
||||
|
||||
# Use credential's last_used time if available, fall back to session renewed
|
||||
# Use credential's last_used time if available, fall back to session renewed time
|
||||
if ctx.credential and ctx.credential.last_used:
|
||||
auth_time = ctx.credential.last_used
|
||||
else:
|
||||
auth_time = ctx.session.renewed
|
||||
auth_time = ctx.session.expiry - EXPIRES
|
||||
|
||||
time_since_auth = datetime.now(timezone.utc) - auth_time
|
||||
return time_since_auth <= max_age_delta
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timezone
|
||||
|
||||
from paskia import aaguid
|
||||
from paskia.authsession import session_key
|
||||
from paskia.authsession import EXPIRES, session_key
|
||||
from paskia import db
|
||||
from paskia.util import hostutil, permutil, tokens, useragent
|
||||
|
||||
@@ -110,7 +110,7 @@ async def format_user_info(
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": _format_datetime(entry.renewed),
|
||||
"last_renewed": _format_datetime(entry.expiry - EXPIRES),
|
||||
"is_current": entry.key == current_session_key,
|
||||
"is_current_host": bool(
|
||||
normalized_request_host
|
||||
|
||||
+4
-2
@@ -18,6 +18,8 @@ from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from paskia.authsession import expires
|
||||
import pytest_asyncio
|
||||
import uuid7
|
||||
|
||||
@@ -190,7 +192,7 @@ async def session_token(
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
renewed=datetime.now(timezone.utc),
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
@@ -208,7 +210,7 @@ async def regular_session_token(
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
renewed=datetime.now(timezone.utc),
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
+4
-3
@@ -19,6 +19,7 @@ import pytest
|
||||
import pytest_asyncio
|
||||
import uuid7
|
||||
|
||||
from paskia.authsession import expires
|
||||
from paskia.db import Credential, Org, Permission, Role, User
|
||||
from paskia.db.json import DB
|
||||
from paskia.util.tokens import create_token, encode_session_key, session_key
|
||||
@@ -101,7 +102,7 @@ async def second_org_session_token(
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
renewed=datetime.now(timezone.utc),
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
@@ -167,7 +168,7 @@ async def org_admin_session_token(
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
renewed=datetime.now(timezone.utc),
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
@@ -1181,7 +1182,7 @@ class TestAdminSessions:
|
||||
host="other.host:4401",
|
||||
ip="192.168.1.1",
|
||||
user_agent="other-agent",
|
||||
renewed=datetime.now(timezone.utc),
|
||||
expiry=expires(),
|
||||
)
|
||||
|
||||
encoded_key = encode_session_key(extra_key)
|
||||
|
||||
+4
-3
@@ -520,11 +520,12 @@ class TestValidateSessionRefresh:
|
||||
"""Validate should return 401 if session disappears during refresh."""
|
||||
from datetime import timedelta
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.util.tokens import create_token, session_key
|
||||
|
||||
# Create a session with an old renewed time to trigger refresh
|
||||
# Create a session with an old expiry time to trigger refresh
|
||||
token = create_token()
|
||||
old_time = datetime.now(timezone.utc) - timedelta(minutes=10)
|
||||
old_expiry = datetime.now(timezone.utc) + EXPIRES - timedelta(minutes=10)
|
||||
test_db.create_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
@@ -532,7 +533,7 @@ class TestValidateSessionRefresh:
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
renewed=old_time,
|
||||
expiry=old_expiry,
|
||||
)
|
||||
|
||||
# Delete the session right before validate tries to refresh
|
||||
|
||||
Reference in New Issue
Block a user