Overview
This vulnerability occurs when an application fails to limit the number of repeated authentication attempts from a single user or IP address within a short period. Without such restrictions, attackers can use automated tools to rapidly submit thousands or millions of password guesses (brute-force attack) or common passwords (dictionary attack) against valid usernames. This significantly increases the likelihood of an attacker guessing a correct password and gaining unauthorized access. 🔑➡️💥Business Impact
Failure to restrict excessive authentication attempts directly leads to:- Account Takeover: Attackers can systematically guess weak or common passwords, compromising user accounts.
- Denial of Service (Account Lockout): If the only defense is permanent account lockout after a few tries, attackers can intentionally lock out legitimate users. A better approach often involves temporary lockouts or CAPTCHAs.
- Resource Consumption: Brute-force attacks can consume server CPU and network bandwidth.
- Detection Evasion: Slow, distributed brute-force attacks might go unnoticed if basic attempt logging is the only defense.
Reference Details
CWE ID: CWE-307
Related CWEs: CWE-799 (Improper Control of Interaction Frequency), CWE-1216 (Lockout Errors)
OWASP Top 10 (2021): A07:2021 - Identification and Authentication Failures
Severity: High
Framework-Specific Analysis and Remediation
Brute-force protection is usually implemented via rate limiting (temporary blocking) or account lockout (temporary or requiring admin intervention). This often involves tracking failed login attempts per username and/or IP address in a cache (like Redis) or database. Many frameworks have plugins or built-in features, but custom logic is also common. Key Remediation Principles:- Limit Attempts per IP: Track failed login attempts by IP address and temporarily block IPs with excessive failures (e.g., block for 5 minutes after 20 attempts).
- Limit Attempts per Username: Track failed login attempts per username and implement stronger protections like temporary account lockout (e.g., lock for 30 minutes after 5 failed attempts) or requiring a CAPTCHA.
- Use Exponential Backoff: Increase the lockout duration after repeated lockout events for the same account or IP.
- Secure Lockout Mechanism: Ensure the lockout mechanism itself cannot be abused for Denial of Service (e.g., avoid permanent lockouts triggered solely by failed attempts).
- Logging and Monitoring: Log failed login attempts and lockout events to detect attacks. Alert administrators to high rates of failures.
- Python
- Java
- .NET(C#)
- PHP
- Node.js
- Ruby
Framework Context
Using libraries likedjango-ratelimit, django-axes, Flask-Limiter to apply limits specifically to login views.Vulnerable Scenario 1: No Login Attempt Limit (Django)
Copy
# accounts/views.py
from django.contrib.auth.views import LoginView
class UserLoginView(LoginView):
template_name = 'accounts/login.html'
# DANGEROUS: No mechanism to prevent an attacker from submitting
# thousands of password guesses per minute for a given username.
Vulnerable Scenario 2: Unprotected API Endpoint (Flask)
Copy
# app.py (Flask)
@app.route('/api/search', methods=['GET'])
def api_search():
query = request.args.get('q')
# DANGEROUS: This search might be resource-intensive.
# An attacker can flood this endpoint, causing DoS.
# No rate limiting is applied.
results = perform_complex_search(query)
return jsonify(results)
Mitigation and Best Practices
Integrate a rate-limiting library. Apply decorators or middleware to the specific views/routes that need protection. Use Redis or Memcached for distributed rate limiting in multi-server environments.Secure Code Example
Copy
# views.py (Django with django-axes - Configuration in settings.py)
# settings.py:
# INSTALLED_APPS = [..., 'axes', ...]
# AUTHENTICATION_BACKENDS = ['axes.backends.AxesStandaloneBackend', ...] # Or AxesModelBackend
# AXES_FAILURE_LIMIT = 5 # Lockout after 5 attempts
# AXES_COOLOFF_TIME = timedelta(minutes=15) # Lockout duration
# AXES_LOCKOUT_TEMPLATE = 'accounts/lockout.html' # Custom lockout page
# AXES_HANDLER = 'axes.handlers.database.AxesDatabaseHandler' # Store attempts in DB
# --- views.py needs no specific axes code if using backend ---
# LoginView will automatically use the Axes backend if configured.
# views.py (Django with django-ratelimit)
from django.contrib.auth.views import LoginView
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
class UserLoginViewSecureRL(LoginView):
template_name = 'accounts/login.html'
# SECURE: Rate limit based on username in POST data + IP
@method_decorator(ratelimit(key='post:username', rate='5/m', block=True, method='POST'), name='dispatch')
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
Copy
# app.py (Flask with Flask-Limiter)
from flask import Flask, request, jsonify # Added request, jsonify
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
app = Flask(__name__) # Assume app exists
limiter = Limiter(
get_remote_address, # Use IP address for tracking
app=app,
default_limits=["200 per day", "50 per hour"],
storage_uri="memory://" # Use "redis://localhost:6379" etc. for production
)
@app.route('/api/search-secure')
@limiter.limit("10 per minute") # SECURE: Specific limit for this endpoint
def api_search_secure():
query = request.args.get('q')
results = perform_complex_search(query) # Assume this exists
return jsonify(results)
@app.route('/login-secure', methods=['GET', 'POST'])
# SECURE: Limit POST requests based on IP AND username form field
@limiter.limit("5 per minute", key_func=lambda: f"{get_remote_address()}:{request.form.get('username')}", methods=['POST'])
def login_secure():
if request.method == 'POST':
# ... login logic ...
if login_successful: # Assume this variable is set
limiter.reset() # Optional: Reset limit on success for this key
# ...
else:
# Limit applied automatically by decorator
flash('Invalid credentials') # Assume flash exists
return render_template('login.html') # Assume render_template exists
Testing Strategy
Use automated tools (likewfuzz, ffuf, Burp Intruder) or simple scripts (e.g., curl in a loop) to send rapid, repeated requests to login endpoints, password reset forms, and resource-intensive API endpoints. Verify that after exceeding the configured limit, the server responds with an appropriate error (e.g., 429 Too Many Requests) and blocks further requests for a period. Test different keys (IP vs. user vs. form field).Framework Context
Implementing custom listeners or providers in Spring Security to track failed attempts, or using libraries like Bucket4j for rate limiting on the login endpoint.Vulnerable Scenario 1: Default Spring Security Form Login
The basicformLogin() configuration doesn’t include brute-force protection out of the box.Copy
// config/SecurityConfig.java
// Assume WebSecurityConfigurerAdapter extension
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().anyRequest().authenticated().and()
// DANGEROUS: No protection against repeated failed login attempts.
.formLogin().permitAll();
}
Vulnerable Scenario 2: Custom Authentication Logic without Tracking
A custom authentication method doesn’t track or limit failures.Copy
// service/AuthService.java - Assume UserDetails, UserDetailsService, PasswordEncoder exist
public boolean authenticate(String username, String password) {
UserDetails user = userDetailsService.loadUserByUsername(username);
// DANGEROUS: Failure count is not tracked or limited.
if (user != null && passwordEncoder.matches(password, user.getPassword())) {
// Success logic
return true;
} else {
// Failure logic - no tracking/blocking
return false;
}
}
Mitigation and Best Practices
- Spring Security: Implement an
AuthenticationFailureHandlerand/orAuthenticationSuccessHandlerto track failed/successful attempts per username/IP (e.g., storing counts in a cache like Redis or Guava). Before attempting authentication, check the failure count and throw aLockedExceptionor similar if the threshold is exceeded. - Rate Limiting: Apply a rate-limiting filter (like the Bucket4j example in
CWE-799) specifically to the login processing URL, keyed by IP address and potentially the username parameter.
Secure Code Example
Copy
// config/LoginAttemptService.java (Example using Guava Cache)
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import org.springframework.stereotype.Service;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
@Service
public class LoginAttemptService {
private final int MAX_ATTEMPTS = 5;
private LoadingCache<String, Integer> attemptsCache; // Key = IP Address or Username
public LoginAttemptService() {
attemptsCache = CacheBuilder.newBuilder()
.expireAfterWrite(15, TimeUnit.MINUTES) // Cache expiry / lockout duration
.build(new CacheLoader<String, Integer>() {
public Integer load(String key) { return 0; } // Default attempts = 0
});
}
public void loginFailed(String key) {
int attempts = 0;
try {
attempts = attemptsCache.get(key);
} catch (ExecutionException e) { /* Handle cache error */ }
attempts++;
attemptsCache.put(key, attempts);
}
public void loginSucceeded(String key) {
attemptsCache.invalidate(key);
}
public boolean isBlocked(String key) {
try {
return attemptsCache.get(key) >= MAX_ATTEMPTS;
} catch (ExecutionException e) { return false; /* Fail open on cache error? Or block? */ }
}
}
// config/AuthenticationFailureListener.java (Example Listener)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest; // Need request for IP
@Component
public class AuthenticationFailureListener
implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> {
@Autowired(required = false) // Make request optional if not always in context
private HttpServletRequest request; // Inject request for IP
@Autowired private LoginAttemptService loginAttemptService;
@Override
public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent e) {
String key = "unknown_ip"; // Default
if (request != null) {
final String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null) {
key = request.getRemoteAddr();
} else {
key = xfHeader.split(",")[0]; // Get client IP from proxy header
}
}
// Could also extract username: e.getAuthentication().getName();
loginAttemptService.loginFailed(key); // Track by IP
}
}
// Need a similar AuthenticationSuccess Listener to call loginSucceeded.
// Need to check isBlocked() *before* attempting auth (e.g., in a custom filter or provider).
Testing Strategy
Use automated tools to send repeated invalid login attempts. Monitor failure counts (if exposed via logs or an endpoint). Verify that after the threshold, login attempts from that IP/username are blocked with an appropriate error message or status code (e.g., 401/403 with lockout message, or 429 if using rate limiting) for the specified duration.Framework Context
ASP.NET Core Identity includes account lockout features configurable inStartup.cs. Rate limiting can be added via AspNetCoreRateLimit.Vulnerable Scenario 1: Account Lockout Disabled
The default Identity configuration might have lockout disabled or set with very high thresholds.Copy
// Startup.cs (ConfigureServices)
using Microsoft.AspNetCore.Identity; // Added namespace
using Microsoft.Extensions.DependencyInjection; // Added namespace
// Assume ApplicationDbContext, IdentityUser exist
public void ConfigureServices(IServiceCollection services) // Example signature
{
services.AddDefaultIdentity<IdentityUser>(options => {
// ... other options ...
// DANGEROUS: Lockout might be disabled by default or configured weakly.
options.Lockout.AllowedForNewUsers = false; // Example: Disabled
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(1); // Too short?
options.Lockout.MaxFailedAccessAttempts = 100; // Too high!
})
.AddEntityFrameworkStores<ApplicationDbContext>();
// ...
}
Vulnerable Scenario 2: No General Rate Limiting
Even with account lockout, attackers can still try many usernames from one IP or target APIs without lockout. Missing general rate limiting (likeAspNetCoreRateLimit) allows this. (See CWE-799 example).Mitigation and Best Practices
- Enable Identity Lockout: Configure
IdentityOptions.Lockoutwith sensible values (e.g.,MaxFailedAccessAttempts = 5,DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15)). EnsureAllowedForNewUsers = true. - Implement Rate Limiting: Use
AspNetCoreRateLimit(seeCWE-799) to throttle requests to the login endpoint based on IP address as a first line of defense before Identity’s user-specific lockout engages.
Secure Code Example
Copy
// Startup.cs (ConfigureServices - Secure Identity Lockout)
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using System; // Added for TimeSpan
// ...
public void ConfigureServices(IServiceCollection services) // Example signature
{
services.AddDefaultIdentity<IdentityUser>(options => {
options.SignIn.RequireConfirmedAccount = true; // Good practice
// SECURE: Configure sensible lockout settings.
options.Lockout.AllowedForNewUsers = true;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); // 15-minute lockout
options.Lockout.MaxFailedAccessAttempts = 5; // Lock after 5 failed attempts
})
.AddEntityFrameworkStores<ApplicationDbContext>(); // Assuming ApplicationDbContext
// ...
// Add AspNetCoreRateLimit configuration as shown in CWE-799 example,
// ensuring the login endpoint has appropriate IP-based rules, e.g.:
// { "Endpoint": "*:/Account/Login", "Period": "1m", "Limit": 10 } // Limit per IP before user lockout
}
// Controllers/AccountController.cs (Ensure lockoutOnFailure is true)
// Assume _signInManager, _logger, LoginViewModel exist
public async Task<IActionResult> Login(LoginViewModel model)
{
// ... model state check ...
// SECURE: Ensure lockoutOnFailure is true (default is often false).
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe,
lockoutOnFailure: true);
if (result.Succeeded) { /* ... */ }
if (result.IsLockedOut) {
_logger.LogWarning("User account locked out: {Email}", model.Email);
return RedirectToAction("Lockout"); // Show lockout page
}
// ... handle other failures ...
}
Testing Strategy
Submit multiple (e.g., 6) incorrect passwords for a valid user. Verify the account becomes locked (user cannot log in even with correct password) and theLockoutEnd date is set in the AspNetUsers table. Check the lockout duration. Use tools to send rapid requests from one IP to the login endpoint and verify rate limiting (429 responses) occurs before account lockout if both are configured.Framework Context
Laravel provides login throttling out of the box (Illuminate\Foundation\Auth\ThrottlesLogins trait in LoginController). Plain PHP requires manual implementation.Vulnerable Scenario 1: Login Throttling Disabled (Laravel)
A developer removes or overrides theThrottlesLogins trait or related methods in app/Http/Controllers/Auth/LoginController.php.Copy
// app/Http/Controllers/Auth/LoginController.php (Modified)
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; // Added base controller
use Illuminate\Foundation\Auth\AuthenticatesUsers; // Added trait
// ... use statements ...
class LoginController extends Controller
{
// DANGEROUS: The ThrottlesLogins trait is removed or commented out.
// use ThrottlesLogins;
use AuthenticatesUsers; // Only has auth logic, not throttling
// ... constructor, redirectTo etc. ...
// If the trait is missing, the default login action won't throttle.
}
Vulnerable Scenario 2: Plain PHP without Attempt Tracking
Copy
<?php
// login.php (Plain PHP)
session_start();
// ... get username, password from POST ...
// Assume get_password_hash_from_db, get_user_id exist
$stored_hash = get_password_hash_from_db($username);
// DANGEROUS: No tracking of failed attempts.
if ($stored_hash && password_verify($password, $stored_hash)) {
session_regenerate_id(true);
$_SESSION['user_id'] = get_user_id($username);
header('Location: /dashboard.php');
exit;
} else {
// Failure, but no limit implemented
header('Location: /login.php?error=1');
exit;
}
?>
Mitigation and Best Practices
- Laravel: Ensure the
ThrottlesLoginstrait is used in yourLoginController. Configure the number of attempts (maxAttempts) and decay minutes (decayMinutes) via methods in the controller if you need to override defaults (defaults are usually reasonable: 5 attempts, 1 minute decay). - Plain PHP: Implement failed attempt tracking. Store failure counts and timestamps per username and/or IP address (e.g., in session, database, or cache like Redis/Memcached). Before checking the password, check the attempt count/timestamp; if exceeded, display an error and deny the attempt until the lockout period expires.
Secure Code Example
Copy
// app/Http/Controllers/Auth/LoginController.php (Laravel - Secure Default)
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
// SECURE: Include the ThrottlesLogins trait.
use Illuminate\Foundation\Auth\ThrottlesLogins;
class LoginController extends Controller
{
// Use AuthenticatesUsers for standard login logic
use AuthenticatesUsers, ThrottlesLogins; // Ensure ThrottlesLogins is present
// Optional: Override defaults if needed
// protected $maxAttempts = 3; // Default is 5
// protected $decayMinutes = 2; // Default is 1
protected $redirectTo = '/home'; // Define redirect path
public function __construct() {
$this->middleware('guest')->except('logout');
}
// Override username() if not using 'email'
// public function username() { return 'username'; }
}
Copy
<?php
// login_secure.php (Plain PHP - Conceptual Example)
session_start();
define('MAX_LOGIN_ATTEMPTS', 5);
define('LOCKOUT_TIME_SECONDS', 15 * 60); // 15 minutes
// Assume helper functions using a persistent store (DB, Redis) exist:
// get_failed_attempts($key), is_lockout_expired($key),
// reset_failed_attempts($key), record_failed_attempt($key)
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$ip_address = $_SERVER['REMOTE_ADDR'];
$ip_key = "login_attempt_ip:$ip_address";
$user_key = "login_attempt_user:$username";
// Check lockout
if (get_failed_attempts($ip_key) >= (MAX_LOGIN_ATTEMPTS * 10) || // Stricter IP limit
($username && get_failed_attempts($user_key) >= MAX_LOGIN_ATTEMPTS)) {
// Check if lockout time has passed (needs timestamp storage)
if ($username && !is_lockout_expired($user_key)) { // Check user lockout first
die("Too many failed attempts for this user. Please try again later.");
} else if (!is_lockout_expired($ip_key)) {
die("Too many failed attempts from this IP. Please try again later.");
} else {
// Reset if expired
if ($username) reset_failed_attempts($user_key);
reset_failed_attempts($ip_key);
}
}
$stored_hash = get_password_hash_from_db($username);
if ($stored_hash && password_verify($password, $stored_hash)) {
reset_failed_attempts($ip_key);
if ($username) reset_failed_attempts($user_key);
session_regenerate_id(true);
$_SESSION['user_id'] = get_user_id($username);
header('Location: /dashboard.php');
exit;
} else {
// Record failed attempt
record_failed_attempt($ip_key);
if ($username) record_failed_attempt($user_key);
header('Location: /login.php?error=1');
exit;
}
?>
Testing Strategy
Use automated tools (Burp Intruder, Hydra) to send multiple invalid password attempts for a valid username. Verify that after the threshold (e.g., 5 in Laravel default), subsequent attempts are blocked with a specific error message and a429 Too Many Requests status code. Check the lockout duration. Test attacks against different users from the same IP and the same user from different IPs.Framework Context
Using rate-limiting middleware (express-rate-limit, rate-limiter-flexible) specifically configured for the login route, potentially keyed by IP and username.Vulnerable Scenario 1: Login Route without Rate Limiting
Copy
// app.js
const express = require('express'); // Added imports for context
const app = express();
// Assume User model, bcrypt, session middleware exist
app.use(express.urlencoded({ extended: true })); // Parse form body
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// DANGEROUS: No limit on login attempts.
const user = await User.findOne({ username });
if (user && await bcrypt.compare(password, user.passwordHash)) {
// ... login success ...
req.session.userId = user._id;
res.redirect('/dashboard');
} else {
// Failure, but no tracking/limiting
res.status(401).send('Invalid credentials');
}
});
app.listen(3000);
Vulnerable Scenario 2: Rate Limiter Only By IP
While better than nothing, only limiting by IP allows an attacker to brute-force many different usernames from the same IP address without hitting the limit for any single user.Copy
// app.js
const rateLimit = require('express-rate-limit');
// ... setup app ...
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
// DANGEROUS: Doesn't consider username. Attacker tries 100 different users.
});
app.post('/login-ip-limited', loginLimiter, async (req, res) => { /* ... */ });
Mitigation and Best Practices
Apply rate-limiting middleware (likeexpress-rate-limit) to the login route. Configure it with a strict limit (max) and reasonable window (windowMs). Crucially, use a keyGenerator function that includes both the IP address and the username from the request body to prevent attackers from cycling through usernames.Secure Code Example
Copy
// app.js (Secure Login Limiter)
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
app.use(express.json()); // Middleware to parse JSON body
app.use(express.urlencoded({ extended: true })); // Middleware to parse form body
app.set('trust proxy', 1); // Trust proxy if behind one for req.ip
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // SECURE: Limit each user/IP combination to 5 attempts per window
message: 'Too many login attempts from this IP/user, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
// SECURE: Key includes both IP and username from form body
keyGenerator: (req, res) => {
// Ensure username exists
const username = req.body.username || 'unknown_user';
// Use req.ip (requires `app.set('trust proxy', 1)` if behind proxy)
return req.ip + ':' + username.toLowerCase();
},
// Optional: Handler to log blocked requests
handler: (req, res, next, options) => {
// Assume logger exists
// logger.warn(`Rate limit exceeded for key: ${options.keyGenerator(req, res)}`);
res.status(options.statusCode).send(options.message);
}
});
// Apply the limiter ONLY to the login POST route
app.post('/login-secure', loginLimiter, async (req, res) => {
const { username, password } = req.body;
// ... authentication logic (find user, bcrypt.compare) ...
if (login_successful) { // Assume variable exists
// Optional: Reset limiter count on success (requires external store like Redis)
// await loginLimiter.resetKey(req.ip + ':' + username.toLowerCase());
// ... login success (regenerate session etc.) ...
} else {
// Rate limiter handles blocking if needed (or increments count)
res.status(401).send('Invalid credentials');
}
});
// ... other routes ...
Testing Strategy
Use automated tools to send rapid, invalid password attempts for a single valid username. Verify that requests are blocked (429 response) after themax limit. Wait for windowMs and verify login is possible again. Repeat the test, but cycle through different valid usernames from the same IP address. Verify the IP-based limit (if separate) or the combined key limit functions correctly.Framework Context
Using gems likerack-attack as middleware. Devise has some built-in tracking (:lockable module), but rack-attack is often used for IP/request-based throttling.Vulnerable Scenario 1: Devise Login Without rack-attack or :lockable
A standard Devise setup without rack-attack configured and without the :lockable module enabled on the User model.Copy
# config/routes.rb
devise_for :users # Standard Devise routes
# DANGEROUS: No rack-attack initializer configured.
# app/models/user.rb
class User < ApplicationRecord
# DANGEROUS: :lockable module is not included.
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
end
Vulnerable Scenario 2: rack-attack Configured but Missing Login Path
Copy
# config/initializers/rack_attack.rb
class Rack::Attack
# DANGEROUS: General request limit exists, but no specific,
# stricter limit for the sensitive POST /users/sign_in path.
throttle('req/ip', limit: 100, period: 5.minutes, &:ip)
# Missing: throttle('logins/ip', ...)
end
Mitigation and Best Practices
- Devise
:lockable: Add the:lockablemodule to your User model in Devise. Configure it inconfig/initializers/devise.rb(e.g.,config.maximum_attempts = 5,config.lock_strategy = :failed_attempts,config.unlock_strategy = :time,config.unlock_in = 15.minutes). This provides user-specific lockout. rack-attack: Implementrack-attack(as a first line of defense) to throttle login attempts by IP and/or email parameter to prevent user enumeration and protect against distributed attacks.
Secure Code Example
Copy
# app/models/user.rb (Secure - Devise :lockable)
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
# SECURE: Enable lockable module for user-specific lockout
:lockable
end
# config/initializers/devise.rb (Secure - Configure :lockable)
Devise.setup do |config|
# ...
# ==> Configuration for :lockable
config.lock_strategy = :failed_attempts # Lock based on failed attempts
config.maximum_attempts = 5 # SECURE: Lock after 5 attempts
config.unlock_strategy = :time # Unlock after a time period
config.unlock_in = 15.minutes # SECURE: Lockout duration
config.lock_strategy = :both # Or track by both IP and email
# ...
end
# config/initializers/rack_attack.rb (Secure - IP/Email Throttling)
class Rack::Attack
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new # Use Redis in prod
# Throttle POST requests to /users/sign_in by IP address
# Limit: 5 requests per minute per IP (stricter than general)
throttle('logins/ip', limit: 5, period: 60.seconds) do |req|
if (req.path == '/users/sign_in' || req.path == '/login') && req.post?
req.ip
end
end
# Throttle POST requests to /users/sign_in by email param
# Limit: 5 requests per 15 minutes per email address
throttle('logins/email', limit: 5, period: 15.minutes) do |req|
if (req.path == '/users/sign_in' || req.path == '/login') && req.post?
# Assumes Devise params: req.params['user']['email']
# Adjust key if params structure is different
req.params['user']['email'].to_s.downcase.strip.presence if req.params['user'].is_a?(Hash)
end
end
# Customize throttled response
self.throttled_response = lambda do |env|
retry_after = (env['rack.attack.match_data'] || {})[:period]
headers = { 'Content-Type' => 'text/html', 'Retry-After' => retry_after.to_s }
[ 429, headers, ["Too Many Requests. Retry later.\n"] ] # Example response
end
end
# config/application.rb (Ensure middleware is used)
# config.middleware.use Rack::Attack
Testing Strategy
Test both lockout (:lockable) and throttling (rack-attack):- Lockout: Submit 6 invalid passwords for one user. Verify the account is locked (DB flag
locked_atset, login fails even with correct password). Wait 15 minutes and verify login succeeds. - Throttling: Submit 6 invalid passwords (e.g., for different users) from one IP within a minute. Verify the 6th+ requests receive a
429 Too Many Requestsresponse.

