test(auth): require durable sessions and safe remote setup
This commit is contained in:
@@ -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")
|
||||||
@@ -17,7 +17,15 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|||||||
from backup_tool.cli import build_alembic_config
|
from backup_tool.cli import build_alembic_config
|
||||||
from backup_tool.config import Settings
|
from backup_tool.config import Settings
|
||||||
from backup_tool.db.engine import SchemaNotCurrentError, assert_schema_current, create_engine
|
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.repository import RepositoryError, initialize, inspect_repository
|
||||||
from backup_tool.security.auth import (
|
from backup_tool.security.auth import (
|
||||||
hash_password,
|
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.sessions = async_sessionmaker(app.state.engine, expire_on_commit=False)
|
||||||
app.state.cipher = EnvelopeCipher.from_file(settings.master_key_file)
|
app.state.cipher = EnvelopeCipher.from_file(settings.master_key_file)
|
||||||
app.state.setup_lock = asyncio.Lock()
|
app.state.setup_lock = asyncio.Lock()
|
||||||
app.state.revoked_sessions = set()
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def request_id_middleware(request: Request, call_next: Any) -> Response:
|
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
|
return user, set(token.scopes), False
|
||||||
encoded = request.cookies.get("backup_tool_session")
|
encoded = request.cookies.get("backup_tool_session")
|
||||||
data = verify_session(encoded, settings.master_key_file) if encoded else None
|
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.")
|
raise Problem(401, "authentication_required", "Authentication is required.")
|
||||||
user = await db.get(User, data["sub"])
|
user = await db.get(User, data["sub"])
|
||||||
if user is None or user.state != "active":
|
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()
|
csrf = new_csrf_token()
|
||||||
ttl = settings.session_ttl_seconds
|
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(
|
response.set_cookie(
|
||||||
"backup_tool_session",
|
"backup_tool_session",
|
||||||
sign_session(
|
sign_session(
|
||||||
user_id,
|
user_id,
|
||||||
csrf,
|
csrf,
|
||||||
settings.master_key_file,
|
settings.master_key_file,
|
||||||
expires_at=datetime.now(UTC) + timedelta(seconds=ttl),
|
expires_at=expires_at,
|
||||||
|
session_id=persisted.id,
|
||||||
),
|
),
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=True,
|
||||||
@@ -262,9 +282,9 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
db: Annotated[AsyncSession, Depends(session)],
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
async with app.state.setup_lock:
|
async with app.state.setup_lock:
|
||||||
if (
|
if settings.setup_requires_bootstrap and (
|
||||||
settings.bootstrap_secret is not None
|
settings.bootstrap_secret is None
|
||||||
and input_.bootstrap_secret != settings.bootstrap_secret
|
or input_.bootstrap_secret != settings.bootstrap_secret
|
||||||
):
|
):
|
||||||
raise Problem(403, "bootstrap_required", "Bootstrap credentials are required.")
|
raise Problem(403, "bootstrap_required", "Bootstrap credentials are required.")
|
||||||
if await db.scalar(select(User.id).limit(1)) is not None:
|
if await db.scalar(select(User.id).limit(1)) is not None:
|
||||||
@@ -280,7 +300,7 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
) from error
|
) from error
|
||||||
await audit(db, request, "setup", "user", user.id, "success", user.id)
|
await audit(db, request, "setup", "user", user.id, "success", user.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
set_session(response, user.id)
|
await set_session(db, response, user.id)
|
||||||
return {"id": user.id, "username": user.username}
|
return {"id": user.id, "username": user.username}
|
||||||
|
|
||||||
@app.post("/api/v2/auth/login")
|
@app.post("/api/v2/auth/login")
|
||||||
@@ -301,19 +321,23 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
raise Problem(401, "authentication_failed", "Invalid credentials.")
|
raise Problem(401, "authentication_failed", "Invalid credentials.")
|
||||||
await audit(db, request, "login", "user", user.id, "success", user.id)
|
await audit(db, request, "login", "user", user.id, "success", user.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
set_session(response, user.id)
|
await set_session(db, response, user.id)
|
||||||
return {"id": user.id, "username": user.username}
|
return {"id": user.id, "username": user.username}
|
||||||
|
|
||||||
@app.post("/api/v2/auth/logout", status_code=204)
|
@app.post("/api/v2/auth/logout", status_code=204)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||||
) -> None:
|
) -> None:
|
||||||
encoded = request.cookies.get("backup_tool_session")
|
encoded = request.cookies.get("backup_tool_session")
|
||||||
data = verify_session(encoded, settings.master_key_file) if encoded else None
|
data = verify_session(encoded, settings.master_key_file) if encoded else None
|
||||||
if data is not 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_session", path="/")
|
||||||
response.delete_cookie("backup_tool_csrf", path="/")
|
response.delete_cookie("backup_tool_csrf", path="/")
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ class Settings(BaseSettings):
|
|||||||
raise ValueError("master key file must contain at least 32 bytes")
|
raise ValueError("master key file must contain at least 32 bytes")
|
||||||
return self
|
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
|
@property
|
||||||
def database_path(self) -> Path:
|
def database_path(self) -> Path:
|
||||||
database = make_url(self.database_url).database
|
database = make_url(self.database_url).database
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ class ApiToken(IdentityMixin, TimestampMixin, Base):
|
|||||||
__table_args__ = (Index("ix_api_tokens_owner_id", "owner_id"),)
|
__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):
|
class Secret(IdentityMixin, TimestampMixin, Base):
|
||||||
__tablename__ = "secrets"
|
__tablename__ = "secrets"
|
||||||
ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||||
|
|||||||
@@ -55,6 +55,38 @@ async def test_remote_setup_requires_bootstrap_secret(tmp_path) -> None:
|
|||||||
await app.state.engine.dispose()
|
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:
|
def test_expired_session_is_rejected(tmp_path) -> None:
|
||||||
auth = importlib.import_module("backup_tool.security.auth")
|
auth = importlib.import_module("backup_tool.security.auth")
|
||||||
key = tmp_path / "master.key"
|
key = tmp_path / "master.key"
|
||||||
@@ -67,3 +99,45 @@ def test_expired_session_is_rejected(tmp_path) -> None:
|
|||||||
session_id="session",
|
session_id="session",
|
||||||
)
|
)
|
||||||
assert auth.verify_session(expired, key) is None
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user