05/02/2026 updated code for security 2c

This commit is contained in:
2026-05-02 18:58:31 -04:00
parent 413a0aaa05
commit 86dbd8ac8e
2 changed files with 266 additions and 42 deletions
+63 -42
View File
@@ -70,7 +70,8 @@ def register():
@auth_bp.route('/login', methods=['POST'])
@limiter.limit('10 per minute')
def login():
from datetime import datetime, timezone
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
@@ -87,58 +88,79 @@ def login():
user = User.query.filter_by(email=email).first()
# Per-account lockout check — evaluated before password verification so the
# check itself doesn't leak whether the account exists via timing.
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
# 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()
if not user or not verify_auth_token(auth_hash, user.master_hash):
if user:
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= MAX_FAILED_LOGINS:
from datetime import timedelta
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:
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:
db.session.rollback()
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})',
detail='Failed login attempt — invalid password',
ip_address=_client_ip(),
)
db.session.commit()
db.session.commit()
return jsonify({'error': 'Invalid email or password'}), 401
# Successful authentication — reset lockout state.
user.failed_login_count = 0
user.locked_until = None
try:
user.failed_login_count = 0
user.locked_until = None
except OperationalError:
db.session.rollback()
user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
AuditLog.log(
@@ -905,5 +927,4 @@ def recovery_items():
{'id': item.id, 'enc_data': item.enc_data, 'iv': item.iv}
for item in items
]
}), 200
}), 200