M15: synchronize contracts and acceptance
This commit is contained in:
@@ -13,6 +13,7 @@ from app.core.db import SessionLocal
|
||||
from app.core.security import SessionPayload, read_session_token
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, Role
|
||||
from app.services.sessions import is_session_revoked
|
||||
|
||||
settings = get_settings()
|
||||
_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined]
|
||||
@@ -29,7 +30,8 @@ def get_db() -> Generator[Session, None, None]:
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload: SessionPayload | None = read_session_token(token) if token else None
|
||||
if payload is None or payload.role not in _VALID_ROLES:
|
||||
revoked = token is not None and is_session_revoked(db, token)
|
||||
if payload is None or payload.role not in _VALID_ROLES or revoked:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
# Demo reset deliberately rebuilds the deterministic users table. Retaining the
|
||||
# signed demo session until the reset endpoint clears its cookie keeps existing demo
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.core.security import (
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, PasswordLoginRequest
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.sessions import revoke_session
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
@@ -37,6 +38,7 @@ def _set_session(response: Response, user: User) -> None:
|
||||
SessionPayload(
|
||||
user_id=str(user.id), public_ref=user.public_ref, role=user.role,
|
||||
display_name=user.display_name, issued_at=int(time.time()),
|
||||
session_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
response.set_cookie(
|
||||
@@ -114,7 +116,8 @@ def get_session(response: Response, user: CurrentUser = Depends(get_current_user
|
||||
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload = read_session_token(token) if token else None
|
||||
if payload is not None:
|
||||
if payload is not None and token is not None:
|
||||
revoke_session(db, token, payload)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
|
||||
from app.services.sessions import revoke_session
|
||||
|
||||
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||
settings = get_settings()
|
||||
@@ -48,6 +49,7 @@ def demo_login(
|
||||
role=user.role,
|
||||
display_name=user.display_name,
|
||||
issued_at=int(time.time()),
|
||||
session_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
response.set_cookie(
|
||||
@@ -85,7 +87,8 @@ def get_session(
|
||||
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload = read_session_token(token) if token else None
|
||||
if payload is not None:
|
||||
if payload is not None and token is not None:
|
||||
revoke_session(db, token, payload)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
|
||||
@@ -20,6 +20,7 @@ class SessionPayload:
|
||||
role: str
|
||||
display_name: str
|
||||
issued_at: int
|
||||
session_id: str = ""
|
||||
|
||||
|
||||
def _sign(data: bytes) -> str:
|
||||
@@ -27,6 +28,11 @@ def _sign(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
|
||||
|
||||
def session_token_hash(token: str) -> str:
|
||||
"""Return a non-reversible identifier safe to persist for token revocation."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_session_token(payload: SessionPayload) -> str:
|
||||
body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
|
||||
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.models.idempotency import IdempotencyRecord
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.revoked_session import RevokedSession
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
@@ -20,6 +21,7 @@ __all__ = [
|
||||
"Inspection",
|
||||
"MaintenanceRecord",
|
||||
"OutboxEvent",
|
||||
"RevokedSession",
|
||||
"User",
|
||||
"Vehicle",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class RevokedSession(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "revoked_sessions"
|
||||
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
@@ -19,6 +19,7 @@ from app.models.idempotency import IdempotencyRecord
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||
from app.models.revoked_session import RevokedSession
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.services.audit import record_audit_event
|
||||
@@ -92,6 +93,7 @@ _PERSISTENT_TELEMETRY_ACTIONS = (
|
||||
|
||||
def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None:
|
||||
for model in (
|
||||
RevokedSession,
|
||||
OutboxEvent,
|
||||
IdempotencyRecord,
|
||||
DataQualityIssue,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import SessionPayload, session_token_hash
|
||||
from app.models.revoked_session import RevokedSession
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def is_session_revoked(db: Session, token: str) -> bool:
|
||||
token_hash = session_token_hash(token)
|
||||
revoked_id = db.scalar(
|
||||
select(RevokedSession.id).where(RevokedSession.token_hash == token_hash)
|
||||
)
|
||||
return revoked_id is not None
|
||||
|
||||
|
||||
def revoke_session(db: Session, token: str, payload: SessionPayload) -> None:
|
||||
db.execute(delete(RevokedSession).where(RevokedSession.expires_at < datetime.now(UTC)))
|
||||
token_hash = session_token_hash(token)
|
||||
existing = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
|
||||
if existing is not None:
|
||||
return
|
||||
db.add(
|
||||
RevokedSession(
|
||||
token_hash=token_hash,
|
||||
expires_at=datetime.fromtimestamp(
|
||||
payload.issued_at + settings.session_ttl_seconds, UTC
|
||||
),
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user