Files
saas_backend/alembic/versions/e3b5d7a9c609_mfa_and_lockout.py
T

115 lines
4.6 KiB
Python
Raw Normal View History

2026-08-31 20:04:12 -04:00
"""A second factor, and a limit on guessing.
Two additions to signing in, kept in one migration because they are the same
concern: whether the person at the keyboard is who the password says.
**MFA (TOTP).** A password is a secret that gets reused, phished and breached
elsewhere. A time-based code is not, because it is worth six seconds.
**Lockout.** Rate limiting (finding 1.8) throttles a *source*; it does nothing
about an attacker spreading attempts across addresses, and it keeps no record
against the account being attacked. These columns are per account, so the
hundredth guess against one person is refused whoever is making it.
Revision ID: e3b5d7a9c609
Revises: d1a3c5e7f508
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "e3b5d7a9c609"
down_revision: Union[str, Sequence[str], None] = "d1a3c5e7f508"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCOPED = ("user_mfa", "mfa_recovery_codes")
def upgrade() -> None:
op.add_column(
"users",
sa.Column("failed_login_attempts", sa.Integer(), nullable=False,
server_default="0"),
)
op.add_column(
"users",
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"users",
sa.Column("last_failed_login_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_users_locked_until", "users", ["locked_until"],
postgresql_where=sa.text("locked_until IS NOT NULL"),
)
op.create_table(
"user_mfa",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("secret_enc", sa.Text(), nullable=False),
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_counter", sa.BigInteger(), nullable=True),
sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.UniqueConstraint("user_id", name="uq_user_mfa_user"),
)
op.create_index("ix_user_mfa_user", "user_mfa", ["user_id"])
op.create_table(
"mfa_recovery_codes",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("code_hash", sa.String(255), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index("ix_mfa_recovery_user", "mfa_recovery_codes", ["user_id"])
for table in SCOPED:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(
f"""
CREATE POLICY tenant_isolation ON {table}
USING (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id IS NULL
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id IS NULL
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
"""
)
def downgrade() -> None:
for table in SCOPED:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.drop_index("ix_mfa_recovery_user", table_name="mfa_recovery_codes")
op.drop_table("mfa_recovery_codes")
op.drop_index("ix_user_mfa_user", table_name="user_mfa")
op.drop_table("user_mfa")
op.drop_index("ix_users_locked_until", table_name="users")
op.drop_column("users", "last_failed_login_at")
op.drop_column("users", "locked_until")
op.drop_column("users", "failed_login_attempts")