Skip to content

Commit 099d7b3

Browse files
committed
Rate-limit login attempts to 5/minute/account
1 parent cd8b942 commit 099d7b3

4 files changed

Lines changed: 178 additions & 1 deletion

File tree

user-manager/service/src/main/java/com/peterphi/usermanager/rest/impl/UserManagerOAuthServiceImpl.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import com.peterphi.usermanager.rest.iface.oauth2server.types.OAuth2TokenResponse;
3434
import com.peterphi.usermanager.rest.marshaller.UserMarshaller;
3535
import com.peterphi.usermanager.rest.type.UserManagerUser;
36+
import com.peterphi.usermanager.service.LoginRateLimiter;
3637
import com.peterphi.usermanager.util.UserManagerBearerToken;
3738
import org.apache.commons.lang.StringUtils;
3839
import org.jboss.resteasy.util.BasicAuthHelper;
@@ -110,6 +111,9 @@ public class UserManagerOAuthServiceImpl implements UserManagerOAuthService
110111
@Inject
111112
UserMarshaller marshaller;
112113

114+
@Inject
115+
LoginRateLimiter loginRateLimiter;
116+
113117

114118
@Override
115119
@AuthConstraint(id = "oauth2server_auth", role = "authenticated", comment = "Must be logged in to the User Manager to initiate a service login")
@@ -455,10 +459,18 @@ public String getToken(final String grantType,
455459
{
456460
// N.B. Don't expect the clientSecret from this call
457461

462+
// Enforce per-account failed-login rate limit (defaults to 5 failures per 60s window)
463+
loginRateLimiter.checkAllowed(username);
464+
458465
final UserEntity user = userDao.login(username, password);
459466

460467
if (user == null)
468+
{
469+
loginRateLimiter.recordFailure(username);
461470
throw new IllegalArgumentException("Incorrect username/password combination");
471+
}
472+
473+
loginRateLimiter.recordSuccess(username);
462474

463475
// Accept the use of the service and create a new session
464476
session = createSession(user.getId(), clientId, redirectUri, "password-to-token", true);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.peterphi.usermanager.service;
2+
3+
import javax.ws.rs.WebApplicationException;
4+
import javax.ws.rs.core.MediaType;
5+
import javax.ws.rs.core.Response;
6+
7+
/**
8+
* Thrown when a login attempt is refused because the account has exceeded the permitted number of
9+
* failed login attempts inside the rolling window.
10+
*
11+
* <p>Extends {@link WebApplicationException} and carries a pre-built 429 Too Many Requests response, so
12+
* that when this exception is thrown from a JAX-RS resource method without being caught, the framework
13+
* automatically returns a 429 to the caller rather than a generic 500. Callers that want to render a
14+
* custom body (e.g. the interactive login UI) can still catch this exception explicitly before it
15+
* reaches the JAX-RS layer.</p>
16+
*/
17+
public class LoginRateLimitedException extends WebApplicationException
18+
{
19+
public LoginRateLimitedException(final String message)
20+
{
21+
super(message, Response.status(429).type(MediaType.TEXT_PLAIN).entity(message).build());
22+
}
23+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package com.peterphi.usermanager.service;
2+
3+
import com.google.inject.Inject;
4+
import com.google.inject.Singleton;
5+
import com.google.inject.name.Named;
6+
import com.peterphi.std.annotation.Doc;
7+
import org.apache.commons.lang.StringUtils;
8+
import org.slf4j.Logger;
9+
import org.slf4j.LoggerFactory;
10+
11+
import java.util.ArrayDeque;
12+
import java.util.Deque;
13+
import java.util.HashMap;
14+
import java.util.Map;
15+
16+
/**
17+
* In-memory per-account sliding window rate limiter for login attempts. After {@link #maxFailures} failed
18+
* attempts inside the trailing {@link #windowSeconds} window, further attempts for that account are
19+
* refused by throwing {@link LoginRateLimitedException} until enough time has passed for the oldest
20+
* recorded failures to age out of the window.
21+
*
22+
* <p>Successful logins immediately clear the record for that account. Account keys are normalised to
23+
* lower-case so that variations in letter-case cannot bypass the limit.</p>
24+
*/
25+
@Singleton
26+
public class LoginRateLimiter
27+
{
28+
private static final Logger log = LoggerFactory.getLogger(LoginRateLimiter.class);
29+
30+
@Inject(optional = true)
31+
@Named("auth.login.rate-limit.max-failures")
32+
@Doc("Maximum number of failed login attempts permitted per account inside the rolling window (default 5)")
33+
int maxFailures = 5;
34+
35+
@Inject(optional = true)
36+
@Named("auth.login.rate-limit.window-seconds")
37+
@Doc("Rolling window, in seconds, over which failed login attempts are counted (default 60)")
38+
int windowSeconds = 60;
39+
40+
private final Map<String, Deque<Long>> failures = new HashMap<>();
41+
42+
43+
/**
44+
* Throws {@link LoginRateLimitedException} if the account has exceeded {@link #maxFailures} failures
45+
* inside the trailing {@link #windowSeconds} window. Otherwise returns silently.
46+
*/
47+
public synchronized void checkAllowed(final String account)
48+
{
49+
if (StringUtils.isBlank(account))
50+
return;
51+
52+
final String key = key(account);
53+
final Deque<Long> timestamps = failures.get(key);
54+
55+
if (timestamps == null)
56+
return;
57+
58+
expire(timestamps);
59+
60+
if (timestamps.isEmpty())
61+
{
62+
failures.remove(key);
63+
return;
64+
}
65+
66+
if (timestamps.size() >= maxFailures)
67+
{
68+
log.warn("Login rate limit exceeded for account '{}' ({} failures inside {}s window)",
69+
key,
70+
timestamps.size(),
71+
windowSeconds);
72+
73+
throw new LoginRateLimitedException("Too many failed login attempts for this account. Please wait a minute before trying again.");
74+
}
75+
}
76+
77+
78+
/**
79+
* Record a failed login attempt for an account.
80+
*/
81+
public synchronized void recordFailure(final String account)
82+
{
83+
if (StringUtils.isBlank(account))
84+
return;
85+
86+
final String key = key(account);
87+
final Deque<Long> timestamps = failures.computeIfAbsent(key, k -> new ArrayDeque<>());
88+
89+
expire(timestamps);
90+
91+
timestamps.addLast(System.currentTimeMillis());
92+
}
93+
94+
95+
/**
96+
* Clear the failure record for an account (call after a successful login).
97+
*/
98+
public synchronized void recordSuccess(final String account)
99+
{
100+
if (StringUtils.isBlank(account))
101+
return;
102+
103+
failures.remove(key(account));
104+
}
105+
106+
107+
private void expire(final Deque<Long> timestamps)
108+
{
109+
final long cutoff = System.currentTimeMillis() - (windowSeconds * 1000L);
110+
111+
while (!timestamps.isEmpty() && timestamps.peekFirst() < cutoff)
112+
timestamps.removeFirst();
113+
}
114+
115+
116+
private static String key(final String account)
117+
{
118+
return StringUtils.lowerCase(StringUtils.trimToEmpty(account));
119+
}
120+
}

user-manager/service/src/main/java/com/peterphi/usermanager/ui/impl/LoginUIServiceImpl.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import com.peterphi.usermanager.guice.authentication.UserAuthenticationService;
1616
import com.peterphi.usermanager.guice.authentication.UserLogin;
1717
import com.peterphi.usermanager.guice.token.CSRFTokenStore;
18+
import com.peterphi.usermanager.service.LoginRateLimitedException;
19+
import com.peterphi.usermanager.service.LoginRateLimiter;
1820
import com.peterphi.usermanager.service.PasswordResetService;
1921
import com.peterphi.usermanager.service.RedirectValidatorService;
2022
import com.peterphi.usermanager.ui.api.LoginUIService;
@@ -70,6 +72,9 @@ public class LoginUIServiceImpl implements LoginUIService
7072
@Inject
7173
RedirectValidatorService redirectValidator;
7274

75+
@Inject
76+
LoginRateLimiter loginRateLimiter;
77+
7378

7479
@Override
7580
@AuthConstraint(skip = true, comment = "login page")
@@ -121,11 +126,25 @@ else if (!isTokenValid)
121126
"An unexpected browser security error occurred. Please try closing your browser window and enter the system again.");
122127
}
123128

129+
// Enforce per-account failed-login rate limit (defaults to 5 failures per 60s window)
130+
try
131+
{
132+
loginRateLimiter.checkAllowed(user);
133+
}
134+
catch (LoginRateLimitedException e)
135+
{
136+
final String page = getLogin(returnTo, e.getMessage());
137+
138+
return Response.status(429).entity(page).build();
139+
}
140+
124141
final UserEntity account = authenticationService.authenticate(user, password, false);
125142

126143
if (account != null)
127144
{
128-
// Successful login
145+
// Successful login - clear any recorded failures for this account
146+
loginRateLimiter.recordSuccess(user);
147+
129148
login.reload(account);
130149

131150
final Response.ResponseBuilder builder;
@@ -154,6 +173,9 @@ else if (!isTokenValid)
154173
}
155174
else
156175
{
176+
// Record the failure against the per-account rate limiter
177+
loginRateLimiter.recordFailure(user);
178+
157179
// Send the user back to the login page
158180
final String page = getLogin(returnTo, "E-mail/password incorrect");
159181

0 commit comments

Comments
 (0)