Aug 26 - Update password detect against off field 2
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

This commit is contained in:
2026-08-26 15:05:48 -04:00
parent 2f1afb143c
commit 0d7d9c1403
12 changed files with 677 additions and 99 deletions
+112
View File
@@ -0,0 +1,112 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy.dialects.mysql import INTEGER
from app import db
class LoginAttempt(db.Model):
"""
Failed-login tracking scoped to (account, source IP).
The lockout used to live on the users table as a single global counter, which
made it a denial-of-service primitive: anyone who knew an address could send
five wrong passwords and lock the real owner out for 15 minutes, repeatedly
and indefinitely. Locking someone out of their password manager is a serious
harm on its own — it can mean losing access to everything at the worst
possible moment — and it cost an attacker almost nothing.
Scoping by IP means an attacker locks out only themselves. The victim signing
in from their own address is unaffected. A distributed attacker has to rotate
IPs, and each one is independently capped by Flask-Limiter (10/min on
/login) plus the Nginx auth_limit zone.
users.failed_login_count / users.locked_until still exist and are still
maintained, but ONLY as an aggregate signal for the audit log and security
dashboard. They no longer gate authentication — enforcement is here.
"""
__tablename__ = 'login_attempts'
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
user_id = db.Column(
INTEGER(unsigned=True),
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
)
# 45 chars covers IPv6; may be empty when the proxy supplies no address.
ip_address = db.Column(db.String(45), nullable=False, default='')
failed_count = db.Column(db.Integer, nullable=False, default=0, server_default='0')
locked_until = db.Column(db.DateTime, nullable=True)
updated_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
)
__table_args__ = (
db.UniqueConstraint('user_id', 'ip_address', name='uq_login_attempt_user_ip'),
)
MAX_FAILED = 5
LOCKOUT_MINUTES = 15
# Rows older than this carry no information and are pruned by the scheduler.
RETENTION_HOURS = 24
@staticmethod
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
@classmethod
def get(cls, user_id: int, ip_address: str):
return cls.query.filter_by(
user_id=user_id, ip_address=ip_address or ''
).first()
@classmethod
def is_locked(cls, user_id: int, ip_address: str) -> bool:
"""True if this IP is currently locked out of this account."""
row = cls.get(user_id, ip_address)
if not row or not row.locked_until:
return False
if row.locked_until > cls._now():
return True
# Expired — reset so the next failure starts a fresh count.
row.failed_count = 0
row.locked_until = None
row.updated_at = cls._now()
return False
@classmethod
def record_failure(cls, user_id: int, ip_address: str) -> bool:
"""
Count a failed attempt. Returns True if this attempt triggered a lockout.
Caller commits.
"""
now = cls._now()
row = cls.get(user_id, ip_address)
if row is None:
row = cls(user_id=user_id, ip_address=ip_address or '', failed_count=0)
db.session.add(row)
row.failed_count = (row.failed_count or 0) + 1
row.updated_at = now
if row.failed_count >= cls.MAX_FAILED:
row.locked_until = now + timedelta(minutes=cls.LOCKOUT_MINUTES)
return True
return False
@classmethod
def clear(cls, user_id: int, ip_address: str) -> None:
"""Successful authentication — drop this IP's failure history."""
row = cls.get(user_id, ip_address)
if row is not None:
db.session.delete(row)
@classmethod
def cleanup_expired(cls) -> int:
"""Delete rows untouched for RETENTION_HOURS. Called by the scheduler."""
cutoff = cls._now() - timedelta(hours=cls.RETENTION_HOURS)
return cls.query.filter(cls.updated_at <= cutoff).delete()
def __repr__(self):
return f'<LoginAttempt user={self.user_id} ip={self.ip_address} n={self.failed_count}>'
+8 -8
View File
@@ -1,7 +1,5 @@
from datetime import datetime, timezone
from flask_login import UserMixin
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
from sqlalchemy.dialects.mysql import INTEGER
from app import db
@@ -70,12 +68,14 @@ class User(db.Model, UserMixin):
folders = db.relationship('Folder', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
vault_items = db.relationship('VaultItem', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
def check_password(self, auth_hash: str) -> bool:
ph = PasswordHasher()
try:
return ph.verify(self.master_hash, auth_hash)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
# NOTE: there is intentionally no check_password() here.
#
# It existed, was called from nowhere, and used default Argon2 parameters
# with no rehash-on-login handling — so any caller that found it would have
# silently bypassed the transparent parameter upgrade in
# auth_service.verify_auth_token(). Verification goes through
# verify_auth_token(auth_hash, user.master_hash, user=user) so the stored
# hash is upgraded when ARGON2_* settings change.
def __repr__(self):
return f'<User {self.email}>'
+5 -3
View File
@@ -23,9 +23,11 @@ class VaultItem(db.Model):
user_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
folder_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('folders.id', ondelete='SET NULL'), nullable=True)
item_type = db.Column(db.String(20), nullable=False, default=ItemType.PASSWORD.value)
# name is stored in plaintext for display in the vault list.
# All other sensitive fields (username, password, URL, notes, etc.)
# are inside enc_data and are encrypted client-side with AES-256-GCM.
# NOT the user-visible name — that lives encrypted in enc_name/iv_name below.
# This column holds the item TYPE string only (the same value as item_type),
# kept because the column is NOT NULL and predates enc_name. Writing a real
# item name here would hand the server plaintext the zero-knowledge model
# promises it never sees.
name = db.Column(db.String(255), nullable=False)
enc_data = db.Column(db.Text, nullable=False) # base64-encoded AES-256-GCM ciphertext
iv = db.Column(db.String(64), nullable=False) # base64-encoded 12-byte GCM nonce