M15: synchronize contracts and acceptance
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""persist revoked sessions
|
||||
|
||||
Revision ID: d1f83bc64170
|
||||
Revises: b7c7b536df85
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "d1f83bc64170"
|
||||
down_revision = "b7c7b536df85"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"revoked_sessions",
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_revoked_sessions_expires_at", "revoked_sessions", ["expires_at"])
|
||||
op.create_index(
|
||||
"ix_revoked_sessions_token_hash",
|
||||
"revoked_sessions",
|
||||
["token_hash"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_revoked_sessions_token_hash", table_name="revoked_sessions")
|
||||
op.drop_index("ix_revoked_sessions_expires_at", table_name="revoked_sessions")
|
||||
op.drop_table("revoked_sessions")
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Generate the checked-in API contract from the FastAPI application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
schema = app.openapi()
|
||||
schema["info"]["description"] = (
|
||||
"Generated contract for Fleet Ops. The visible product name is Fleet Ops; "
|
||||
"MobilityOps remains the technical repository and service identifier."
|
||||
)
|
||||
schema["servers"] = [{"url": "http://localhost:8128"}]
|
||||
output = ROOT / "contracts" / "openapi.yaml"
|
||||
output.write_text(
|
||||
yaml.safe_dump(schema, sort_keys=False, allow_unicode=True, width=100),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -62,6 +62,34 @@ def test_logout_invalidates_session(ops_client):
|
||||
assert after.status_code == 401
|
||||
|
||||
|
||||
def test_logout_rejects_a_cookie_even_if_the_browser_retains_it(ops_client):
|
||||
from app.core.config import get_settings
|
||||
|
||||
cookie_name = get_settings().session_cookie_name
|
||||
stolen_token = ops_client.cookies.get(cookie_name)
|
||||
assert stolen_token
|
||||
|
||||
logout = ops_client.post("/api/v1/auth/logout")
|
||||
assert logout.status_code == 200
|
||||
|
||||
# Simulate the browser cookie race (or a copied cookie): server-side revocation is
|
||||
# authoritative and must reject the original signed token independently of deletion.
|
||||
ops_client.cookies.set(cookie_name, stolen_token)
|
||||
assert ops_client.get("/api/v1/auth/session").status_code == 401
|
||||
|
||||
# Remove the deliberately injected hostless cookie before exercising a normal browser
|
||||
# login. Otherwise httpx sends it alongside the real testserver cookie, which is not a
|
||||
# state a browser can create for the same origin/path pair.
|
||||
ops_client.cookies.clear()
|
||||
|
||||
# A fresh login in the same second receives a distinct signed token and remains valid.
|
||||
fresh_login = ops_client.post(
|
||||
"/api/v1/demo/login", json={"role": "operations_manager"}
|
||||
)
|
||||
assert fresh_login.status_code == 200
|
||||
assert ops_client.get("/api/v1/auth/session").status_code == 200
|
||||
|
||||
|
||||
def test_logout_without_a_session_is_safe(client):
|
||||
response = client.post("/api/v1/demo/logout")
|
||||
assert response.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user