diff --git a/backend/alembic/versions/0002_sessions.py b/backend/alembic/versions/0002_sessions.py new file mode 100644 index 0000000..c165514 --- /dev/null +++ b/backend/alembic/versions/0002_sessions.py @@ -0,0 +1,51 @@ +"""persist authenticated sessions + +Revision ID: 0002_sessions +Revises: 0001_v2_baseline +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "0002_sessions" +down_revision: str | Sequence[str] | None = "0001_v2_baseline" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "sessions", + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("fk_sessions_user_id_users"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_sessions")), + ) + op.create_index(op.f("ix_sessions_user_id"), "sessions", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_sessions_user_id"), table_name="sessions") + op.drop_table("sessions") diff --git a/backend/src/backup_tool/api/app.py b/backend/src/backup_tool/api/app.py index 5bf471b..f8697b5 100644 --- a/backend/src/backup_tool/api/app.py +++ b/backend/src/backup_tool/api/app.py @@ -17,7 +17,15 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from backup_tool.cli import build_alembic_config from backup_tool.config import Settings from backup_tool.db.engine import SchemaNotCurrentError, assert_schema_current, create_engine -from backup_tool.db.models import ApiToken, AuditEvent, IdempotencyRecord, Repository, Secret, User +from backup_tool.db.models import ( + ApiToken, + AuditEvent, + IdempotencyRecord, + Repository, + Secret, + Session, + User, +) from backup_tool.repository import RepositoryError, initialize, inspect_repository from backup_tool.security.auth import ( hash_password, @@ -128,7 +136,6 @@ def create_app(settings: Settings) -> FastAPI: app.state.sessions = async_sessionmaker(app.state.engine, expire_on_commit=False) app.state.cipher = EnvelopeCipher.from_file(settings.master_key_file) app.state.setup_lock = asyncio.Lock() - app.state.revoked_sessions = set() @app.middleware("http") async def request_id_middleware(request: Request, call_next: Any) -> Response: @@ -169,7 +176,15 @@ def create_app(settings: Settings) -> FastAPI: return user, set(token.scopes), False encoded = request.cookies.get("backup_tool_session") data = verify_session(encoded, settings.master_key_file) if encoded else None - if data is None or data["sid"] in app.state.revoked_sessions: + if data is None: + raise Problem(401, "authentication_required", "Authentication is required.") + active_session = await db.get(Session, data["sid"]) + if ( + active_session is None + or active_session.user_id != data["sub"] + or active_session.revoked_at is not None + or active_session.expires_at <= datetime.now(UTC) + ): raise Problem(401, "authentication_required", "Authentication is required.") user = await db.get(User, data["sub"]) if user is None or user.state != "active": @@ -213,16 +228,21 @@ def create_app(settings: Settings) -> FastAPI: ) ) - def set_session(response: Response, user_id: str) -> None: + async def set_session(db: AsyncSession, response: Response, user_id: str) -> None: csrf = new_csrf_token() ttl = settings.session_ttl_seconds + expires_at = datetime.now(UTC) + timedelta(seconds=ttl) + persisted = Session(user_id=user_id, expires_at=expires_at) + db.add(persisted) + await db.commit() response.set_cookie( "backup_tool_session", sign_session( user_id, csrf, settings.master_key_file, - expires_at=datetime.now(UTC) + timedelta(seconds=ttl), + expires_at=expires_at, + session_id=persisted.id, ), httponly=True, secure=True, @@ -262,9 +282,9 @@ def create_app(settings: Settings) -> FastAPI: db: Annotated[AsyncSession, Depends(session)], ) -> dict[str, str]: async with app.state.setup_lock: - if ( - settings.bootstrap_secret is not None - and input_.bootstrap_secret != settings.bootstrap_secret + if settings.setup_requires_bootstrap and ( + settings.bootstrap_secret is None + or input_.bootstrap_secret != settings.bootstrap_secret ): raise Problem(403, "bootstrap_required", "Bootstrap credentials are required.") if await db.scalar(select(User.id).limit(1)) is not None: @@ -280,7 +300,7 @@ def create_app(settings: Settings) -> FastAPI: ) from error await audit(db, request, "setup", "user", user.id, "success", user.id) await db.commit() - set_session(response, user.id) + await set_session(db, response, user.id) return {"id": user.id, "username": user.username} @app.post("/api/v2/auth/login") @@ -301,19 +321,23 @@ def create_app(settings: Settings) -> FastAPI: raise Problem(401, "authentication_failed", "Invalid credentials.") await audit(db, request, "login", "user", user.id, "success", user.id) await db.commit() - set_session(response, user.id) + await set_session(db, response, user.id) return {"id": user.id, "username": user.username} @app.post("/api/v2/auth/logout", status_code=204) async def logout( request: Request, response: Response, + db: Annotated[AsyncSession, Depends(session)], _: Annotated[tuple[User, set[str], bool], Depends(require)], ) -> None: encoded = request.cookies.get("backup_tool_session") data = verify_session(encoded, settings.master_key_file) if encoded else None if data is not None: - app.state.revoked_sessions.add(data["sid"]) + persisted = await db.get(Session, data["sid"]) + if persisted is not None: + persisted.revoked_at = datetime.now(UTC) + await db.commit() response.delete_cookie("backup_tool_session", path="/") response.delete_cookie("backup_tool_csrf", path="/") diff --git a/backend/src/backup_tool/config.py b/backend/src/backup_tool/config.py index 1072502..4a8b1c9 100644 --- a/backend/src/backup_tool/config.py +++ b/backend/src/backup_tool/config.py @@ -102,6 +102,11 @@ class Settings(BaseSettings): raise ValueError("master key file must contain at least 32 bytes") return self + @property + def setup_requires_bootstrap(self) -> bool: + hostname = urlsplit(self.public_base_url).hostname + return hostname not in {"127.0.0.1", "::1", "localhost"} + @property def database_path(self) -> Path: database = make_url(self.database_url).database diff --git a/backend/src/backup_tool/db/models.py b/backend/src/backup_tool/db/models.py index a9c0ff7..f3a15e3 100644 --- a/backend/src/backup_tool/db/models.py +++ b/backend/src/backup_tool/db/models.py @@ -71,6 +71,14 @@ class ApiToken(IdentityMixin, TimestampMixin, Base): __table_args__ = (Index("ix_api_tokens_owner_id", "owner_id"),) +class Session(IdentityMixin, TimestampMixin, Base): + __tablename__ = "sessions" + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) + expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime()) + __table_args__ = (Index("ix_sessions_user_id", "user_id"),) + + class Secret(IdentityMixin, TimestampMixin, Base): __tablename__ = "secrets" ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) diff --git a/tests/security/test_session_security.py b/tests/security/test_session_security.py index 151e801..b17790e 100644 --- a/tests/security/test_session_security.py +++ b/tests/security/test_session_security.py @@ -55,6 +55,38 @@ async def test_remote_setup_requires_bootstrap_secret(tmp_path) -> None: await app.state.engine.dispose() +@pytest.mark.asyncio +async def test_remote_setup_without_configured_bootstrap_fails_closed(tmp_path) -> None: + config = importlib.import_module("backup_tool.config") + app_module = importlib.import_module("backup_tool.api.app") + cli = importlib.import_module("backup_tool.cli") + from alembic import command + from httpx import ASGITransport, AsyncClient + + key = tmp_path / "master.key" + key.write_bytes(b"m2-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + roots = [tmp_path / name for name in ("data", "repositories", "sources", "restores")] + for root in roots: + root.mkdir() + settings = config.Settings( + data_dir=roots[0], + database_url=f"sqlite+aiosqlite:///{roots[0] / 'metadata.db'}", + repository_roots=(roots[1],), + local_source_roots=(roots[2],), + restore_roots=(roots[3],), + master_key_file=key, + public_base_url="https://backup.example.test", + ) + command.upgrade(cli.build_alembic_config(settings), "head") + app = app_module.create_app(settings) + async with AsyncClient(transport=ASGITransport(app=app), base_url=settings.public_base_url) as client: + response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert response.status_code == 403 + assert response.json()["code"] == "bootstrap_required" + await app.state.engine.dispose() + + def test_expired_session_is_rejected(tmp_path) -> None: auth = importlib.import_module("backup_tool.security.auth") key = tmp_path / "master.key" @@ -67,3 +99,45 @@ def test_expired_session_is_rejected(tmp_path) -> None: session_id="session", ) assert auth.verify_session(expired, key) is None + + +@pytest.mark.asyncio +async def test_logout_revocation_survives_app_restart(tmp_path) -> None: + config = importlib.import_module("backup_tool.config") + app_module = importlib.import_module("backup_tool.api.app") + cli = importlib.import_module("backup_tool.cli") + from alembic import command + from httpx import ASGITransport, AsyncClient + + key = tmp_path / "master.key" + key.write_bytes(b"m2-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + roots = [tmp_path / name for name in ("data", "repositories", "sources", "restores")] + for root in roots: + root.mkdir() + settings = config.Settings( + data_dir=roots[0], + database_url=f"sqlite+aiosqlite:///{roots[0] / 'metadata.db'}", + repository_roots=(roots[1],), + local_source_roots=(roots[2],), + restore_roots=(roots[3],), + master_key_file=key, + ) + command.upgrade(cli.build_alembic_config(settings), "head") + app = app_module.create_app(settings) + async with AsyncClient(transport=ASGITransport(app=app), base_url=settings.public_base_url) as client: + setup = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) + assert setup.status_code == 201 + copied_cookie = client.cookies.get("backup_tool_session") + csrf = client.cookies.get("backup_tool_csrf") + assert copied_cookie is not None + assert csrf is not None + logout = await client.post("/api/v2/auth/logout", headers={"X-CSRF-Token": csrf}) + assert logout.status_code == 204 + await app.state.engine.dispose() + + restarted = app_module.create_app(settings) + async with AsyncClient(transport=ASGITransport(restarted), base_url=settings.public_base_url) as client: + rejected = await client.get("/api/v2/auth/session", headers={"Cookie": f"backup_tool_session={copied_cookie}"}) + assert rejected.status_code == 401 + await restarted.state.engine.dispose()