Store Session.validated rather than Session.expires. Already handled in db migration that drops old sessions.

This commit is contained in:
Leo Vasanko
2026-02-18 01:57:01 +00:00
parent 205e7afde2
commit 22b72b2d50
11 changed files with 26 additions and 42 deletions
+3 -1
View File
@@ -7,6 +7,7 @@ import os
from datetime import UTC, datetime
import paskia.db.operations as _ops
from paskia.authsession import EXPIRES
_logger = logging.getLogger(__name__)
@@ -27,7 +28,8 @@ def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now]
limit = now - EXPIRES
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
if expired_sessions:
from paskia import oidc_notify # noqa: PLC0415
+5 -5
View File
@@ -447,7 +447,7 @@ def update_session(
host: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
expiry: datetime | None = None,
validated: datetime | None = None,
*,
ctx: SessionContext | None = None,
) -> None:
@@ -462,8 +462,8 @@ def update_session(
s.ip = ip
if user_agent is not None:
s.user_agent = user_agent
if expiry is not None:
s.expiry = expiry
if validated is not None:
s.validated = validated
def set_session_host(
@@ -593,7 +593,7 @@ def login(
host=host,
ip=ip,
user_agent=user_agent,
expiry=now + duration,
validated=now,
)
user_str = str(user_uuid)
with _db.transaction("login", user=user_str):
@@ -666,7 +666,7 @@ def create_credential_session(
host=host,
ip=ip,
user_agent=user_agent,
expiry=now + SESSION_LIFETIME,
validated=now,
)
user_str = str(user_uuid)
with _db.transaction("create_credential_session", user=user_str):
+5 -5
View File
@@ -364,7 +364,7 @@ class Credential(msgspec.Struct, dict=True):
class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Session data structure.
Mutable fields: expiry (updated on session refresh)
Mutable fields: validated (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
key is the hashed db_key, stored in the dict key, not in the struct.
@@ -379,7 +379,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
host: str
ip: str
user_agent: str
expiry: datetime
validated: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
def __post_init__(self):
@@ -401,7 +401,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
return {
"ip": self.ip,
"user_agent": self.user_agent,
"expiry": self.expiry.isoformat(),
"validated": self.validated.isoformat(),
}
def store(self, last_seen: datetime) -> None:
@@ -428,7 +428,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
host: str,
ip: str,
user_agent: str,
expiry: datetime,
validated: datetime,
client: UUID | None = None,
) -> Session:
"""Create a new Session with the provided key.
@@ -451,7 +451,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
validated=validated,
client_uuid=client,
)
session.key = key
+1 -2
View File
@@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse
from paskia import aaguid as aaguid_mod
from paskia import db
from paskia.authsession import EXPIRES, reset_expires
from paskia.authsession import reset_expires
from paskia.db import Org as OrgDC
from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC
@@ -545,7 +545,6 @@ async def admin_get_user_detail(
s,
current_key=ctx.session.key,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
}
+4 -8
View File
@@ -15,7 +15,7 @@ from fastapi.security import HTTPBearer
from paskia import authcode, db
from paskia._version import __version__
from paskia.authsession import EXPIRES, expires, get_reset
from paskia.authsession import EXPIRES, get_reset
from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
@@ -89,13 +89,13 @@ async def validate_token(
raise
renewed = False
if auth:
consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
consumed = datetime.now(UTC) - ctx.session.validated
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
db.update_session(
ctx.session.key,
ip=get_client_ip(request),
user_agent=request.headers.get("user-agent") or "",
expiry=expires(),
validated=datetime.now(UTC),
ctx=ctx,
)
session.set_session_cookie(response, auth)
@@ -152,11 +152,7 @@ async def forward_authentication(
"Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": (
ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
if ctx.session.expiry.tzinfo
else ctx.session.expiry.replace(tzinfo=UTC)
.isoformat()
.replace("+00:00", "Z")
(ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
),
"Remote-Credential": str(ctx.session.credential),
}
+2 -10
View File
@@ -21,7 +21,6 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import authcode, db
from paskia.config import SESSION_LIFETIME
from paskia.db.structs import Session
from paskia.util import oidjwt
from paskia.util.crypto import hash_secret
@@ -280,14 +279,6 @@ async def _handle_refresh_token(
status_code=400,
)
# Check session not expired
now = datetime.now(UTC)
if session.expiry < now:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Refresh token expired"},
status_code=400,
)
# Get user
user = db.data().users.get(session.user_uuid)
if not user:
@@ -297,6 +288,7 @@ async def _handle_refresh_token(
)
# Refresh the session - extend expiry and record IP/user_agent
now = datetime.now(UTC)
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not ip:
ip = request.client.host if request.client else ""
@@ -306,7 +298,7 @@ async def _handle_refresh_token(
session.key,
ip=ip,
user_agent=user_agent,
expiry=now + SESSION_LIFETIME,
validated=now,
)
_logger.info("OIDC session refreshed: %s", session.key)
+1 -2
View File
@@ -8,7 +8,6 @@ from fastapi import FastAPI, WebSocket
from paskia import authcode, db
from paskia.authcode import CookieCode, OIDCCode
from paskia.authsession import get_reset
from paskia.config import SESSION_LIFETIME
from paskia.db.structs import Session
from paskia.fastapi import authz, remote
from paskia.fastapi.session import AUTH_COOKIE, infodict
@@ -222,7 +221,7 @@ async def websocket_authenticate(
host=normalized_host,
ip=metadata["ip"],
user_agent=metadata["user_agent"],
expiry=now + SESSION_LIFETIME,
validated=now,
client=oidc_client.uuid,
)
db.oidc_login(
+3 -4
View File
@@ -99,7 +99,7 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True):
host: str
ip: str
user_agent: str
expiry: datetime
validated: datetime
last_renewed: datetime
is_current: bool = False
is_current_host: bool = False
@@ -113,7 +113,6 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True):
*,
current_key: str,
normalized_host: str | None,
expires_delta, # timedelta
) -> ApiUserSession:
client_name = None
if s.client_uuid:
@@ -124,8 +123,8 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True):
host=s.host,
ip=s.ip,
user_agent=useragent.compact_user_agent(s.user_agent),
expiry=s.expiry,
last_renewed=s.expiry - expires_delta,
validated=s.validated,
last_renewed=s.validated,
is_current=s.key == current_key,
is_current_host=not s.client_uuid
and bool(normalized_host and s.host and s.host == normalized_host),
+1 -2
View File
@@ -2,7 +2,6 @@
from datetime import UTC, datetime
from paskia.authsession import EXPIRES
from paskia.db import SessionContext
from paskia.util.timeutil import parse_duration
@@ -32,7 +31,7 @@ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
if ctx.credential and ctx.credential.last_used:
auth_time = ctx.credential.last_used
else:
auth_time = ctx.session.expiry - EXPIRES
auth_time = ctx.session.validated
time_since_auth = datetime.now(UTC) - auth_time
return time_since_auth <= max_age_delta
-2
View File
@@ -1,7 +1,6 @@
"""User information formatting and retrieval logic."""
from paskia import aaguid, db
from paskia.authsession import EXPIRES
from paskia.db import SessionContext
from paskia.util import hostutil
from paskia.util.apistructs import (
@@ -50,7 +49,6 @@ async def build_user_info(
s,
current_key=session_key,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
}
+1 -1
View File
@@ -277,7 +277,7 @@ def create_test_session(
host=host,
ip=ip,
user_agent=user_agent,
expiry=now + duration,
validated=now,
)
if session.key in ops_db._db.sessions:
raise ValueError("Session already exists")