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
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:
@@ -114,6 +114,7 @@ def create_app(config_name: str = 'development') -> Flask:
|
||||
from .models.recovery_challenge import RecoveryChallenge
|
||||
from .models.totp_used_code import TotpUsedCode
|
||||
from .models.webauthn_credential import WebAuthnCredential
|
||||
from .models.login_attempt import LoginAttempt
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
@@ -204,9 +205,11 @@ def create_app(config_name: str = 'development') -> Flask:
|
||||
from app.models.recovery_challenge import RecoveryChallenge
|
||||
from app.models.totp_used_code import TotpUsedCode
|
||||
from app.models.shared_item import SharedItem
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
TokenBlacklist.cleanup_expired()
|
||||
RecoveryChallenge.cleanup_expired()
|
||||
TotpUsedCode.cleanup_expired()
|
||||
LoginAttempt.cleanup_expired()
|
||||
# Delete expired unaccepted shares.
|
||||
from datetime import datetime, timezone
|
||||
SharedItem.query.filter(
|
||||
|
||||
@@ -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
@@ -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}>'
|
||||
|
||||
@@ -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
|
||||
|
||||
+81
-66
@@ -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:
|
||||
|
||||
@@ -4640,11 +4640,15 @@ const Vault = (() => {
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
// " and ' are both required: templates in this file use a mix
|
||||
// of double- and single-quoted attributes, and an unescaped quote of
|
||||
// either kind lets injected text break out of an attribute.
|
||||
return String(str ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function itemIcon(type) {
|
||||
|
||||
Reference in New Issue
Block a user