-
Notifications
You must be signed in to change notification settings - Fork 614
[BUG][AUTH]: Account lockout issues - counter persists after expiry, no user notification, no admin unlock capability #2628
Description
🐞 Bug Summary
Multiple issues with the account lockout implementation create poor user experience and potential admin lockout scenarios:
- Counter persists after lock expiry - Users can be immediately re-locked after a single mistyped password
- No user notification of lockout - Generic "Invalid email or password" gives no indication the account is locked
- No admin unlock capability - No UI or API endpoint to unlock users; requires direct database access
- Administrators subject to same lockout - No protection against locking out the only admin account
🧩 Affected Components
-
mcpgateway/db.py-EmailUser.is_account_locked(),increment_failed_attempts(),reset_failed_attempts() -
mcpgateway/services/email_auth_service.py-authenticate_user() -
mcpgateway/routers/email_auth.py:262-267- API login endpoint returns generic 401 -
mcpgateway/admin.py:3570-3636- Admin UI login (/admin/login) returnserror=invalid_credentials -
mcpgateway/templates/login.html:541-555- No lockout-specific error handling in UI -
mcpgateway/schemas.py:5281-5321-EmailUserResponselacks lockout fields (failed_login_attempts,locked_until)
🔍 Deep Dive Analysis
Issue 1: Counter Persists After Lock Expiry
Current behavior (db.py:1096-1113):
def is_account_locked(self) -> bool:
if self.locked_until is None:
return False
return utc_now() < self.locked_until # Only checks time, doesn't reset counterProblem: When lockout expires, failed_login_attempts remains at max (e.g., 5). The counter is ONLY reset on successful login (reset_failed_attempts() at line 1136).
Scenario:
- User fails 5 times → locked for 30 minutes (counter = 5)
- User waits 30 minutes → lock expires
- User mistypes password once → counter becomes 6, exceeds threshold
- User immediately re-locked for another 30 minutes
Expected: Counter should reset when lock expires, giving users a fresh set of attempts.
Issue 2: No User Notification of Lockout
Current behavior (email_auth_service.py:428-431):
if user.is_account_locked():
failure_reason = "Account is locked"
logger.info(f"Authentication failed for {email}: account locked")
return None # Returns None, same as invalid passwordAPI login response (email_auth.py:266-267):
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password") # Generic messageAdmin UI login (admin.py:3634-3636):
# Returns same generic error via redirect
return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials", status_code=303)Login UI error handling (login.html:552-553):
case "invalid_credentials":
errorMessage = "Invalid email or password"; // No lockout caseNote: Both API (/auth/email/login) and Admin UI (/admin/login) use the same EmailAuthService.authenticate_user() method, so the lockout behavior is identical across both flows.
Problem: Users see "Invalid email or password" when locked out. They:
- Don't know they're locked (might think they forgot password)
- Don't know how long to wait
- May keep trying, extending lockout duration in other systems
- May trigger account recovery unnecessarily
Security Tradeoff - Account Enumeration Risk:
Revealing lockout status to unauthenticated users creates an account enumeration vector - attackers can confirm valid accounts by observing different responses. This is why many systems intentionally return generic errors.
Recommended approach:
- Default behavior: Keep generic "Invalid email or password" response (prevents enumeration)
- Logging: Already logs lockout internally for admin visibility
- Admin notification: Admins should be able to see locked accounts in UI
- User notification: Handle via out-of-band email notification (see Follow-up Epic below)
Follow-up Epic: Implement lockout email notifications - when an account is locked, send an email to the user with:
- Notification that their account was locked due to failed attempts
- Time until automatic unlock
- Link to password reset ([FEATURE][AUTH]: Self-Service Password Reset Workflow (Forgot Password) #2542) if they didn't cause the lockouts
- Security advice if this was unexpected (potential attack)
This approach maintains security (no enumeration) while still informing legitimate users through a verified channel.
Issue 3: No Admin Unlock Capability
Current state: No endpoint or UI exists to unlock a user account.
Searched locations with no results:
mcpgateway/admin.py- No unlock functionalitymcpgateway/templates/users_partial.html- No lockout status displaymcpgateway/routers/email_auth.py- No admin unlock endpoint
Current workaround (requires DB access):
UPDATE email_users
SET failed_login_attempts = 0, locked_until = NULL
WHERE email = 'user@example.com';Expected:
- Admin UI: "Unlock Account" button in user management
- API endpoint:
POST /auth/email/admin/users/{email}/unlock - Display lockout status in user list (locked until, failed attempts count)
Issue 4: Administrators Subject to Same Lockout
Current behavior (email_auth_service.py:428-448): No admin check before lockout.
Risk scenario:
- Single admin deployment (common in dev/small teams)
- Admin mistypes password 5 times (or attacker brute-forces)
- Admin account locked for 30 minutes
- No way to recover without direct database access
- In Kubernetes: requires kubectl exec, database credentials, SQL knowledge
Configuration (config.py:408-409):
max_failed_login_attempts: int = Field(default=5, ...)
account_lockout_duration_minutes: int = Field(default=30, ...)No admin-specific settings exist.
Expected (options):
- Option A: Higher threshold for admins (e.g., 10 attempts)
- Option B: Longer lockout for admins but with unlock notification
- Option C: Prevent locking the last active admin account
- Option D: Emergency unlock via environment variable on restart
🔁 Steps to Reproduce
Issue 1 (Counter persistence):
- Set
max_failed_login_attempts=3for faster testing - Fail login 3 times → account locks
- Wait for lockout to expire (or set short duration)
- Attempt login with wrong password once
- Result: Immediately re-locked
Issue 2 (No notification):
- Lock an account (fail login max times)
- Attempt login again
- Result: See "Invalid email or password" - no indication of lockout
Issue 3 (No unlock):
- Lock a user account
- Try to find unlock option in Admin UI → Users
- Result: No unlock button or lockout status visible
Issue 4 (Admin lockout):
- Single-admin deployment
- Lock the admin account
- Result: Cannot access system without DB intervention
🛠️ Proposed Solutions
Fix 1: Reset Counter on Lock Expiry
# In db.py - is_account_locked() or authenticate_user()
def is_account_locked(self) -> bool:
if self.locked_until is None:
return False
if utc_now() >= self.locked_until:
# Lock expired - reset counter for fresh attempts
self.failed_login_attempts = 0
self.locked_until = None
return False
return TrueFix 2: Lockout Notification (Out-of-Band)
To avoid account enumeration, keep the generic 401 response but notify users via email:
# In email_auth_service.py - send email when account becomes locked
if is_locked:
logger.warning(f"Account locked for {email} after {max_attempts} failed attempts")
# Trigger async email notification
await send_lockout_notification_email(
email=email,
locked_until=user.locked_until,
ip_address=ip_address,
password_reset_url=f"{settings.base_url}/auth/forgot-password"
)Email template should include:
- "Your account has been temporarily locked due to multiple failed login attempts"
- "Access will be restored at {locked_until}"
- "If this wasn't you, please reset your password immediately: {reset_link}"
- "Contact support if you need immediate assistance"
Note: This depends on email infrastructure. See #2542 for email service implementation.
Fix 3: Admin Unlock Endpoint
# New endpoint in email_auth.py
@email_auth_router.post("/admin/users/{user_email}/unlock")
@require_permission("admin.user_management")
async def unlock_user(user_email: str, ...):
user = await auth_service.get_user_by_email(user_email)
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
return {"message": f"Account {user_email} unlocked"}Fix 4: Admin Protection
# Option: Prevent locking last admin
def increment_failed_attempts(self, max_attempts, lockout_duration):
self.failed_login_attempts += 1
if self.failed_login_attempts >= max_attempts:
# Check if this would lock the last admin
if self.is_admin and is_last_active_admin(self.email):
logger.warning(f"Refusing to lock last admin account: {self.email}")
return False # Don't lock, but keep counter high
self.locked_until = utc_now() + timedelta(minutes=lockout_duration)
return True
return False📋 Tasks
Phase 1: Core Fixes
- Reset
failed_login_attemptswhen lockout expires inis_account_locked() - Keep generic 401 response (prevent account enumeration)
- Add internal logging/metrics for lockout events
- Display locked accounts in Admin UI for visibility
Phase 2: Admin Capabilities
- Add
POST /auth/email/admin/users/{email}/unlockendpoint - Update
EmailUserResponseschema to includefailed_login_attemptsandlocked_untilfields - Update
/auth/email/admin/usersendpoint to return lockout status - Add "Unlock Account" button in Admin UI user management
- Display lockout status (locked until, failed attempts) in user list
- Add unlock capability to user edit modal
Phase 3: Admin Protection
- Add config:
admin_lockout_threshold(default: 10, higher than regular users) - Add config:
protect_last_admin_from_lockout(default: true) - Log warning when admin account approaches lockout threshold
- Consider emergency unlock via env var
EMERGENCY_ADMIN_UNLOCK=true
Phase 4: Testing & Documentation
- Unit tests for counter reset on expiry
- Integration tests for lockout notification
- Test admin unlock endpoint
- Update docs with lockout behavior
- Document emergency recovery procedures
🔮 Proposed Follow-up Epic
[EPIC][AUTH]: Account Lockout Email Notifications
Once email infrastructure is in place (see #2542), implement out-of-band lockout notifications:
- Send email when account is locked (with unlock time, password reset link)
- Send email when account is unlocked by admin
- Optional: Send warning email at N-1 failed attempts ("One more attempt will lock your account")
- Include IP address/location info for security awareness
- Rate-limit notification emails to prevent spam
This maintains security (no account enumeration via API responses) while keeping users informed through verified channels.
🔗 Related Issues
- [FEATURE][AUTH]: Self-Service Password Reset Workflow (Forgot Password) #2542 - [FEATURE][AUTH]: Self-Service Password Reset Workflow (Forgot Password) - prerequisite for email notifications
- [DOCS][AUTH]: Administrator Password Reset & Recovery Guide #2543 - [DOCS][AUTH]: Administrator Password Reset & Recovery Guide
📓 Current Operational Workaround
Until fixed, unlock accounts via direct database access:
-- Unlock specific user (works on both SQLite and PostgreSQL)
UPDATE email_users
SET failed_login_attempts = 0,
locked_until = NULL
WHERE email = 'user@example.com';
-- View all locked accounts (PostgreSQL)
SELECT email, failed_login_attempts, locked_until, is_admin
FROM email_users
WHERE locked_until IS NOT NULL AND locked_until > NOW();
-- View all locked accounts (SQLite - default database)
SELECT email, failed_login_attempts, locked_until, is_admin
FROM email_users
WHERE locked_until IS NOT NULL AND locked_until > datetime('now');Note: Default installation uses SQLite (DATABASE_URL=sqlite:///./mcp.db). PostgreSQL uses NOW(), SQLite uses datetime('now') or CURRENT_TIMESTAMP.
For Kubernetes deployments, see #2543 for kubectl commands.