Files
PassKeeper/migrations/versions/m3n4o5p6q7r8_add_login_attempts.py
T
nngo 0d7d9c1403
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
Aug 26 - Update password detect against off field 2
2026-08-26 15:05:48 -04:00

53 lines
2.0 KiB
Python

"""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')