test(auth): require durable sessions and safe remote setup
This commit is contained in:
@@ -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="/")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user