Skip to content

[BUG][AUTH]: Account lockout issues - counter persists after expiry, no user notification, no admin unlock capability #2628

@crivetimihai

Description

@crivetimihai

🐞 Bug Summary

Multiple issues with the account lockout implementation create poor user experience and potential admin lockout scenarios:

  1. Counter persists after lock expiry - Users can be immediately re-locked after a single mistyped password
  2. No user notification of lockout - Generic "Invalid email or password" gives no indication the account is locked
  3. No admin unlock capability - No UI or API endpoint to unlock users; requires direct database access
  4. 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) returns error=invalid_credentials
  • mcpgateway/templates/login.html:541-555 - No lockout-specific error handling in UI
  • mcpgateway/schemas.py:5281-5321 - EmailUserResponse lacks 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 counter

Problem: 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:

  1. User fails 5 times → locked for 30 minutes (counter = 5)
  2. User waits 30 minutes → lock expires
  3. User mistypes password once → counter becomes 6, exceeds threshold
  4. 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 password

API login response (email_auth.py:266-267):

if not user:
    raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
                        detail="Invalid email or password")  # Generic message

Admin 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 case

Note: 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:

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 functionality
  • mcpgateway/templates/users_partial.html - No lockout status display
  • mcpgateway/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:

  1. Single admin deployment (common in dev/small teams)
  2. Admin mistypes password 5 times (or attacker brute-forces)
  3. Admin account locked for 30 minutes
  4. No way to recover without direct database access
  5. 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):

  1. Set max_failed_login_attempts=3 for faster testing
  2. Fail login 3 times → account locks
  3. Wait for lockout to expire (or set short duration)
  4. Attempt login with wrong password once
  5. Result: Immediately re-locked

Issue 2 (No notification):

  1. Lock an account (fail login max times)
  2. Attempt login again
  3. Result: See "Invalid email or password" - no indication of lockout

Issue 3 (No unlock):

  1. Lock a user account
  2. Try to find unlock option in Admin UI → Users
  3. Result: No unlock button or lockout status visible

Issue 4 (Admin lockout):

  1. Single-admin deployment
  2. Lock the admin account
  3. 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 True

Fix 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_attempts when lockout expires in is_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}/unlock endpoint
  • Update EmailUserResponse schema to include failed_login_attempts and locked_until fields
  • Update /auth/email/admin/users endpoint 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


📓 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.

Metadata

Metadata

Assignees

Labels

MUSTP1: Non-negotiable, critical requirements without which the product is non-functional or unsafebugSomething isn't workingpythonPython / backend development (FastAPI)securityImproves security

Type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions