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
+81 -66
View File
@@ -209,12 +209,9 @@ def register():
@auth_bp.route('/login', methods=['POST'])
@limiter.limit('10 per minute')
def login():
from datetime import datetime, timezone, timedelta
from sqlalchemy.exc import OperationalError
# Number of consecutive failures before a temporary lockout is applied.
MAX_FAILED_LOGINS = 5
LOCKOUT_MINUTES = 15
from datetime import datetime, timezone
from sqlalchemy.exc import OperationalError, ProgrammingError
from app.models.login_attempt import LoginAttempt
data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower()
@@ -225,73 +222,91 @@ def login():
if not email or not auth_hash:
return jsonify({'error': 'Email and auth_hash are required'}), 400
# ── One response for every failure mode ─────────────────────────────────
#
# Unknown account, wrong password, and locked-out must be indistinguishable.
# The lockout branch used to answer 429 "Account temporarily locked. Try
# again in N minute(s)", which confirmed the address had an account — the
# same disclosure /register was just fixed for.
#
# The trailing hint is shown for ALL of these, so it explains a lockout to
# the legitimate owner without revealing anything to someone probing.
def _reject():
return jsonify({
'error': (
'Invalid email or password. If you have made several failed '
'attempts, wait a few minutes and try again.'
)
}), 401
ip = client_ip()
user = User.query.filter_by(email=email).first()
# Per-account lockout check.
# Guarded with try/except so that a deployment where the migration has not
# yet been run (columns missing) degrades gracefully instead of returning
# an HTML 500 page that breaks JSON parsing in the extension.
try:
if user and user.locked_until:
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user.locked_until > now:
remaining = int((user.locked_until - now).total_seconds() // 60) + 1
AuditLog.log(
user_id=user.id,
action='auth.login_blocked',
resource_type='user',
resource_id=user.id,
detail=f'Login blocked — account locked for {remaining} more minute(s)',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'error': f'Account temporarily locked. Try again in {remaining} minute(s).'
}), 429
else:
# Lockout has expired — reset the counter.
user.failed_login_count = 0
user.locked_until = None
except OperationalError:
# Columns do not exist yet — migration pending. Skip lockout check.
db.session.rollback()
# Lockout is scoped to (account, IP) — see app/models/login_attempt.py.
# Guarded so a deployment where the migration has not yet run degrades to
# "no lockout" rather than returning an HTML 500 that breaks JSON parsing
# in the extension.
locked = False
if user:
try:
locked = LoginAttempt.is_locked(user.id, ip)
db.session.commit()
except (OperationalError, ProgrammingError):
db.session.rollback() # table missing — migration pending
if locked:
# Do the Argon2 work anyway. Returning early would make the locked
# branch measurably faster than a wrong password and reinstate the
# existence oracle through timing.
verify_auth_token(auth_hash, user.master_hash)
AuditLog.log(
user_id=user.id,
action='auth.login_blocked',
resource_type='user',
resource_id=user.id,
detail='Login blocked — this IP is temporarily locked out',
ip_address=ip,
)
db.session.commit()
return _reject()
if not user or not verify_auth_token(auth_hash, user.master_hash, user=user):
if user:
try:
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= MAX_FAILED_LOGINS:
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=LOCKOUT_MINUTES)
AuditLog.log(
user_id=user.id,
action='auth.account_locked',
resource_type='user',
resource_id=user.id,
detail=f'Account locked for {LOCKOUT_MINUTES} minutes after {user.failed_login_count} failed attempts',
ip_address=client_ip(),
)
else:
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})',
ip_address=client_ip(),
)
db.session.commit()
except OperationalError:
triggered = LoginAttempt.record_failure(user.id, ip)
except (OperationalError, ProgrammingError):
db.session.rollback()
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail='Failed login attempt — invalid password',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Invalid email or password'}), 401
triggered = False
# users.failed_login_count / locked_until are kept up to date purely
# as an aggregate signal for the audit log and security dashboard.
# They no longer gate authentication.
try:
user.failed_login_count = (user.failed_login_count or 0) + 1
except (OperationalError, ProgrammingError):
db.session.rollback()
AuditLog.log(
user_id=user.id,
action='auth.account_locked' if triggered else 'auth.login_failed',
resource_type='user',
resource_id=user.id,
detail=(
f'This IP locked out for {LoginAttempt.LOCKOUT_MINUTES} minutes '
f'after {LoginAttempt.MAX_FAILED} failed attempts'
if triggered else
'Failed login attempt — invalid password'
),
ip_address=ip,
)
db.session.commit()
return _reject()
# Successful authentication — clear this IP's failure history.
try:
LoginAttempt.clear(user.id, ip)
except (OperationalError, ProgrammingError):
db.session.rollback()
# Successful authentication — reset lockout state.
try: