"""add login_attempts table (per-IP lockout) Revision ID: m3n4o5p6q7r8 Revises: l2m3n4o5p6q7 Create Date: 2026-08-26 00:00:00.000000 Moves failed-login lockout from a global per-account counter to per (account, IP). The old design was a denial-of-service primitive: anyone who knew an email address could send five wrong passwords and lock the real owner out for 15 minutes, repeatedly and indefinitely, at near-zero cost. Locking someone out of their password manager is a serious harm in itself. Scoping by IP means an attacker locks out only their own address. The legitimate owner signing in from their own IP is unaffected, and a distributed attacker still faces Flask-Limiter (10/min per IP on /login) plus the Nginx auth_limit zone on every address they rotate through. users.failed_login_count / users.locked_until are left in place and still maintained as an aggregate signal for the audit log, but no longer gate authentication. """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.mysql import INTEGER revision = 'm3n4o5p6q7r8' down_revision = 'l2m3n4o5p6q7' branch_labels = None depends_on = None def upgrade(): op.create_table( 'login_attempts', sa.Column('id', INTEGER(unsigned=True), autoincrement=True, primary_key=True), sa.Column('user_id', INTEGER(unsigned=True), nullable=False), sa.Column('ip_address', sa.String(45), nullable=False, server_default=''), sa.Column('failed_count', sa.Integer, nullable=False, server_default='0'), sa.Column('locked_until', sa.DateTime, nullable=True), sa.Column('updated_at', sa.DateTime, nullable=False), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.UniqueConstraint('user_id', 'ip_address', name='uq_login_attempt_user_ip'), ) # The cleanup job sweeps by updated_at. op.create_index('ix_login_attempts_updated_at', 'login_attempts', ['updated_at']) def downgrade(): op.drop_index('ix_login_attempts_updated_at', table_name='login_attempts') op.drop_table('login_attempts')