Skip to content

feat(auth): add account lockout + lastLoginAt tracking #3217

@PierreBrisorgueil

Description

@PierreBrisorgueil

Context

The auth module has no protection against brute-force login attempts and no way to know when a user last logged in.

1. Account lockout

Current behavior

Failed login attempts have no consequence. An attacker can try unlimited passwords (only rate-limited by express-rate-limit at 10-20 req/15min, which is per-IP not per-account).

Expected behavior

Track failed attempts per account:

failedLoginAttempts: { type: Number, default: 0 }
lockUntil: { type: Date, default: null }
  • Each failed signin → increment failedLoginAttempts
  • After N failures (configurable, default: 5) → set lockUntil = now + 30min
  • Locked account returns 423 Locked with message "Account temporarily locked, try again later"
  • Successful login → reset failedLoginAttempts to 0, clear lockUntil
  • Lock expires automatically (check lockUntil < now)

Config

lockout: {
  enabled: true,
  maxAttempts: 5,
  lockDuration: 1800, // 30 minutes in seconds
}

2. lastLoginAt tracking

Expected behavior

On every successful authentication (local signin, OAuth callback, token refresh excluded):

lastLoginAt: { type: Date, default: null }

Update user.lastLoginAt = new Date() in the signin and OAuth callback controllers.

Why

  • Simple activity indicator without external tooling
  • Useful for admin dashboards ("last seen")
  • Useful for cleanup (inactive accounts)
  • No need for full audit logs here — detailed login history (IP, device, geo) belongs in PostHog/Datadog, not in the user model

Implementation notes

Both features touch the same signin flow, so they should be implemented together:

// In signin controller (pseudo-code)
const user = await findByEmail(email);

if (user.lockUntil && user.lockUntil > Date.now()) {
  return res.status(423).json({ message: 'Account temporarily locked' });
}

if (!passwordMatch) {
  user.failedLoginAttempts += 1;
  if (user.failedLoginAttempts >= config.lockout.maxAttempts) {
    user.lockUntil = new Date(Date.now() + config.lockout.lockDuration * 1000);
  }
  await user.save();
  return res.status(401);
}

// Success
user.failedLoginAttempts = 0;
user.lockUntil = null;
user.lastLoginAt = new Date();
await user.save();

User model additions

failedLoginAttempts: { type: Number, default: 0 }
lockUntil: { type: Date, default: null }
lastLoginAt: { type: Date, default: null }

3 fields, low effort, high value.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important — depends on P1phase:2-orgsPhase 2: Multi-tenancy & Organizations

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions