Skip to content

Commit 47132ab

Browse files
committed
Support account lockout on repeated incorrect password entries
1 parent c12d0e7 commit 47132ab

File tree

6 files changed

+506
-2
lines changed

6 files changed

+506
-2
lines changed

gefapi/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,7 @@ def check_if_token_in_blocklist(jwt_header, jwt_payload):
857857
return is_token_in_blocklist(jti)
858858

859859

860+
from gefapi.errors import AccountLockedError # noqa:E402
860861
from gefapi.models import User # noqa:E402
861862
from gefapi.services import UserService # noqa:E402
862863

@@ -878,6 +879,9 @@ def create_token():
878879

879880
try:
880881
user = UserService.authenticate_user(email, password)
882+
except AccountLockedError as e:
883+
logger.warning(f"[JWT]: Account locked for {email}")
884+
return jsonify(e.serialize), 401
881885
except Exception as e:
882886
logger.error(f"[JWT]: Error during authentication: {str(e)}")
883887
return jsonify({"msg": "Authentication failed"}), 500

gefapi/errors.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,26 @@ class EmailError(Error):
5252

5353
class PasswordValidationError(Error):
5454
pass
55+
56+
57+
class AccountLockedError(Error):
58+
"""Raised when a user account is locked due to too many failed login attempts."""
59+
60+
def __init__(
61+
self,
62+
message: str,
63+
minutes_remaining: int | None = None,
64+
requires_password_reset: bool = False,
65+
):
66+
super().__init__(message)
67+
self.minutes_remaining = minutes_remaining
68+
self.requires_password_reset = requires_password_reset
69+
70+
@property
71+
def serialize(self):
72+
return {
73+
"message": self.message,
74+
"error_code": "account_locked",
75+
"minutes_remaining": self.minutes_remaining,
76+
"requires_password_reset": self.requires_password_reset,
77+
}

gefapi/models/user.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ class User(db.Model):
8888
email_verified = db.Column(db.Boolean(), default=False, nullable=True)
8989
email_verified_at = db.Column(db.DateTime(), nullable=True)
9090

91+
# Account lockout fields for brute force protection
92+
# failed_login_count: Number of consecutive failed login attempts
93+
# locked_until: When the account will be auto-unlocked (NULL = not locked)
94+
failed_login_count = db.Column(db.Integer(), default=0, nullable=False)
95+
locked_until = db.Column(db.DateTime(), nullable=True, index=True)
96+
9197
def __init__(self, email, password, name, country, institution, role="USER"):
9298
self.email = email
9399
self.password = self.set_password(password)
@@ -102,6 +108,9 @@ def __init__(self, email, password, name, country, institution, role="USER"):
102108
self.last_activity_at = None
103109
self.email_verified = False
104110
self.email_verified_at = None
111+
# Initialize account lockout fields
112+
self.failed_login_count = 0
113+
self.locked_until = None
105114

106115
def __repr__(self):
107116
return f"<User {self.email!r}>"
@@ -204,6 +213,81 @@ def get_token(self):
204213
"""Generate JWT token"""
205214
return create_access_token(identity=self.id)
206215

216+
# -------------------------------------------------------------------------
217+
# Account Lockout Methods
218+
# -------------------------------------------------------------------------
219+
# Lockout thresholds and durations
220+
LOCKOUT_THRESHOLDS = [
221+
(5, 15), # After 5 failures: lock for 15 minutes
222+
(10, 60), # After 10 failures: lock for 60 minutes
223+
(20, None), # After 20 failures: lock until password reset
224+
]
225+
226+
def is_locked(self) -> bool:
227+
"""Check if the account is currently locked.
228+
229+
Returns:
230+
True if account is locked, False otherwise
231+
"""
232+
if self.locked_until is None:
233+
return False
234+
now = datetime.datetime.utcnow()
235+
# Return True if lock hasn't expired yet
236+
return self.locked_until > now
237+
238+
def get_lockout_minutes_remaining(self) -> int | None:
239+
"""Get minutes remaining until account unlocks.
240+
241+
Returns:
242+
Minutes remaining, or None if not locked or permanently locked
243+
"""
244+
if not self.is_locked():
245+
return 0
246+
if self.locked_until is None:
247+
return None # Shouldn't happen, but be safe
248+
now = datetime.datetime.utcnow()
249+
remaining = self.locked_until - now
250+
return max(1, int(remaining.total_seconds() / 60))
251+
252+
def record_failed_login(self) -> tuple[bool, int | None]:
253+
"""Record a failed login attempt and apply lockout if needed.
254+
255+
Returns:
256+
Tuple of (is_now_locked, lockout_minutes or None for permanent)
257+
"""
258+
self.failed_login_count = (self.failed_login_count or 0) + 1
259+
count = self.failed_login_count
260+
261+
# Determine lockout duration based on failure count
262+
lockout_minutes = None
263+
for threshold, minutes in self.LOCKOUT_THRESHOLDS:
264+
if count >= threshold:
265+
lockout_minutes = minutes
266+
267+
if lockout_minutes is not None:
268+
self.locked_until = datetime.datetime.utcnow() + datetime.timedelta(
269+
minutes=lockout_minutes
270+
)
271+
return True, lockout_minutes
272+
273+
if count >= self.LOCKOUT_THRESHOLDS[-1][0]:
274+
# Permanent lock (until password reset)
275+
# Set to far future date
276+
self.locked_until = datetime.datetime.utcnow() + datetime.timedelta(
277+
days=365 * 100
278+
)
279+
return True, None
280+
281+
return False, None
282+
283+
def clear_failed_logins(self) -> None:
284+
"""Clear failed login counter and unlock account.
285+
286+
Called on successful login or password reset.
287+
"""
288+
self.failed_login_count = 0
289+
self.locked_until = None
290+
207291
@staticmethod
208292
@lru_cache(maxsize=1)
209293
def _get_encryption_key() -> bytes:

gefapi/services/user_service.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,8 @@ def change_password(user, old_password, new_password):
490490
)
491491
_validate_password_strength(new_password)
492492
user.password = user.set_password(new_password)
493+
# Clear any account lockout on password change
494+
user.clear_failed_logins()
493495
try:
494496
db.session.add(user)
495497
db.session.commit()
@@ -529,6 +531,8 @@ def admin_change_password(user, new_password):
529531
logger.info(f"[SERVICE]: Admin changing password for user {user.email}")
530532
_validate_password_strength(new_password)
531533
user.password = user.set_password(new_password)
534+
# Clear any account lockout on admin password change
535+
user.clear_failed_logins()
532536
try:
533537
db.session.add(user)
534538
db.session.commit()
@@ -619,6 +623,8 @@ def _recover_password_legacy(user):
619623
"""
620624
password = _generate_secure_password()
621625
user.password = user.set_password(password=password)
626+
# Clear any account lockout on password recovery
627+
user.clear_failed_logins()
622628
try:
623629
logger.info("[DB]: ADD")
624630
db.session.add(user)
@@ -744,6 +750,9 @@ def reset_password_with_token(token_string, new_password):
744750
# Set new password
745751
user.password = user.set_password(password=new_password)
746752

753+
# Clear any account lockout - password reset unlocks the account
754+
user.clear_failed_logins()
755+
747756
# Mark token as used
748757
reset_token.mark_used()
749758

@@ -967,6 +976,17 @@ def delete_user(
967976

968977
@staticmethod
969978
def authenticate_user(email, password):
979+
"""Authenticate a user by email and password.
980+
981+
Returns:
982+
User object on successful authentication, None if user not found
983+
or password is invalid.
984+
985+
Raises:
986+
AccountLockedError: If the account is locked due to failed attempts.
987+
"""
988+
from gefapi.errors import AccountLockedError
989+
970990
logger.info(f"[AUTH]: Authentication attempt for {email}")
971991
user = User.query.filter_by(email=email).first()
972992

@@ -975,14 +995,98 @@ def authenticate_user(email, password):
975995
log_authentication_event(False, email, "user_not_found")
976996
return None
977997

998+
# Check if account is locked
999+
if user.is_locked():
1000+
minutes_remaining = user.get_lockout_minutes_remaining()
1001+
if minutes_remaining is None:
1002+
logger.warning(f"[AUTH]: Account locked until password reset: {email}")
1003+
log_authentication_event(False, email, "account_locked_permanent")
1004+
raise AccountLockedError(
1005+
"Your account is locked due to too many failed login attempts. "
1006+
"Please reset your password to regain access.",
1007+
minutes_remaining=None,
1008+
requires_password_reset=True,
1009+
)
1010+
logger.warning(
1011+
f"[AUTH]: Account temporarily locked for {email}, "
1012+
f"{minutes_remaining} minutes remaining"
1013+
)
1014+
log_authentication_event(
1015+
False, email, f"account_locked_{minutes_remaining}m"
1016+
)
1017+
raise AccountLockedError(
1018+
f"Your account is temporarily locked. "
1019+
f"Please try again in {minutes_remaining} minute(s).",
1020+
minutes_remaining=minutes_remaining,
1021+
requires_password_reset=False,
1022+
)
1023+
9781024
if not user.check_password(password):
9791025
logger.warning(f"[AUTH]: Failed login - invalid password: {email}")
980-
log_authentication_event(False, email, "invalid_password")
1026+
1027+
# Record failed attempt and apply lockout if threshold reached
1028+
try:
1029+
is_locked, lockout_minutes = user.record_failed_login()
1030+
db.session.add(user)
1031+
db.session.commit()
1032+
1033+
if is_locked:
1034+
if lockout_minutes is None:
1035+
logger.warning(
1036+
f"[AUTH]: Account locked until password reset after "
1037+
f"{user.failed_login_count} failures: {email}"
1038+
)
1039+
log_authentication_event(
1040+
False, email, "account_locked_permanent"
1041+
)
1042+
# Log security event for account lockout
1043+
from gefapi.utils.security_events import log_security_event
1044+
1045+
log_security_event(
1046+
"ACCOUNT_LOCKED",
1047+
user_email=email,
1048+
details={
1049+
"reason": "max_failed_attempts",
1050+
"failed_count": user.failed_login_count,
1051+
"unlock_method": "password_reset_required",
1052+
},
1053+
level="warning",
1054+
)
1055+
raise AccountLockedError(
1056+
"Your account has been locked due to too many failed "
1057+
"login attempts. Please reset your password to unlock.",
1058+
minutes_remaining=None,
1059+
requires_password_reset=True,
1060+
)
1061+
logger.warning(
1062+
f"[AUTH]: Account locked for {lockout_minutes} minutes "
1063+
f"after {user.failed_login_count} failures: {email}"
1064+
)
1065+
log_authentication_event(
1066+
False, email, f"account_locked_{lockout_minutes}m"
1067+
)
1068+
raise AccountLockedError(
1069+
f"Your account has been temporarily locked after "
1070+
f"{user.failed_login_count} failed login attempts. "
1071+
f"Please try again in {lockout_minutes} minute(s).",
1072+
minutes_remaining=lockout_minutes,
1073+
requires_password_reset=False,
1074+
)
1075+
log_authentication_event(False, email, "invalid_password")
1076+
except AccountLockedError:
1077+
# Re-raise AccountLockedError so it propagates to the route handler
1078+
raise
1079+
except Exception as e:
1080+
logger.warning(f"[AUTH]: Failed to record failed login: {e}")
1081+
db.session.rollback()
1082+
log_authentication_event(False, email, "invalid_password")
1083+
9811084
return None
9821085

983-
# Successful authentication - update login and activity timestamps
1086+
# Successful authentication - clear failed login count and update timestamps
9841087
try:
9851088
now = datetime.datetime.utcnow()
1089+
user.clear_failed_logins() # Reset lockout state
9861090
user.last_login_at = now
9871091
user.last_activity_at = now
9881092
db.session.add(user)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""add account lockout fields to user
2+
3+
Revision ID: e3f4a5b6c7d8
4+
Revises: d2e3f4a5b6c7
5+
Create Date: 2026-02-05 10:00:00.000000
6+
7+
This migration adds fields to support account lockout after failed login attempts:
8+
- failed_login_count: Number of consecutive failed login attempts
9+
- locked_until: When the account will be automatically unlocked (NULL = not locked)
10+
11+
SECURITY BENEFITS:
12+
1. Prevents slow brute force attacks that bypass rate limiting
13+
2. Forces users with wrong credentials to reset password
14+
3. Reduces Rollbar noise from repeated failed logins
15+
16+
LOCKOUT POLICY:
17+
- After 5 failed attempts: Lock for 15 minutes
18+
- After 10 failed attempts: Lock for 1 hour
19+
- After 20 failed attempts: Lock until password reset
20+
- Successful login or password reset clears the counter
21+
22+
EXISTING USER HANDLING:
23+
All existing users start with failed_login_count = 0 and locked_until = NULL
24+
"""
25+
26+
from alembic import op
27+
import sqlalchemy as sa
28+
29+
# revision identifiers, used by Alembic.
30+
revision = "e3f4a5b6c7d8"
31+
down_revision = "d2e3f4a5b6c7"
32+
branch_labels = None
33+
depends_on = None
34+
35+
36+
def upgrade():
37+
with op.batch_alter_table("user", schema=None) as batch_op:
38+
batch_op.add_column(
39+
sa.Column(
40+
"failed_login_count",
41+
sa.Integer(),
42+
nullable=False,
43+
server_default="0",
44+
)
45+
)
46+
batch_op.add_column(
47+
sa.Column("locked_until", sa.DateTime(), nullable=True)
48+
)
49+
# Index for efficient queries on locked accounts
50+
batch_op.create_index(
51+
"ix_user_locked_until",
52+
["locked_until"],
53+
unique=False,
54+
)
55+
56+
57+
def downgrade():
58+
with op.batch_alter_table("user", schema=None) as batch_op:
59+
batch_op.drop_index("ix_user_locked_until")
60+
batch_op.drop_column("locked_until")
61+
batch_op.drop_column("failed_login_count")

0 commit comments

Comments
 (0)