115 lines
4.6 KiB
Python
115 lines
4.6 KiB
Python
"""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")
|