Stop brute force counter increase on no user/pass

Also removed RequestInternal from use.

Affects issues:
- Fixed #1393
- Fixes random issue where 403 is shown after
  one bad password input and successful login.
This commit is contained in:
Risto Lahtela 2020-04-13 17:53:30 +03:00
parent d6a7a43428
commit 90064d3f33
6 changed files with 176 additions and 224 deletions

View File

@ -0,0 +1,83 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.delivery.webserver;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
/**
* Guards against password brute-force break attempts.
*
* @author Rsl1122
*/
public class PassBruteForceGuard {
private static final int ATTEMPT_LIMIT = 3;
private final Cache<String, Integer> failedLoginAttempts = Caffeine.newBuilder()
.expireAfterWrite(90, TimeUnit.SECONDS)
.build();
public boolean shouldPreventRequest(String accessor) {
Integer attempts = failedLoginAttempts.getIfPresent(accessor);
if (attempts == null) return false;
// Too many attempts, forbid further attempts.
return attempts >= ATTEMPT_LIMIT;
}
// Don't call on first connection.
public void increaseAttemptCountOnFailedLogin(String accessor) {
// Authentication was attempted, but failed so new attempt is going to be given if not forbidden
failedLoginAttempts.cleanUp();
Integer attempts = failedLoginAttempts.getIfPresent(accessor);
if (attempts == null) {
attempts = 0;
}
// Too many attempts, forbid further attempts.
if (attempts >= ATTEMPT_LIMIT) {
// Attempts only increased if less than ATTEMPT_LIMIT attempts to prevent frustration from the cache timer resetting.
return;
}
failedLoginAttempts.put(accessor, attempts + 1);
}
public void resetAttemptCount(String accessor) {
// Successful login
failedLoginAttempts.cleanUp();
failedLoginAttempts.invalidate(accessor);
}
public static class Disabled extends PassBruteForceGuard {
@Override
public boolean shouldPreventRequest(String accessor) {
return false;
}
@Override
public void increaseAttemptCountOnFailedLogin(String accessor) {
}
@Override
public void resetAttemptCount(String accessor) {
}
}
}

View File

@ -17,29 +17,34 @@
package com.djrapitops.plan.delivery.webserver;
import com.djrapitops.plan.delivery.web.resolver.Response;
import com.djrapitops.plan.delivery.web.resolver.request.Request;
import com.djrapitops.plan.delivery.web.resolver.request.URIPath;
import com.djrapitops.plan.delivery.web.resolver.request.URIQuery;
import com.djrapitops.plan.delivery.web.resolver.request.WebUser;
import com.djrapitops.plan.delivery.webserver.auth.Authentication;
import com.djrapitops.plan.delivery.webserver.auth.BasicAuthentication;
import com.djrapitops.plan.delivery.webserver.auth.FailReason;
import com.djrapitops.plan.exceptions.WebUserAuthException;
import com.djrapitops.plan.settings.config.PlanConfig;
import com.djrapitops.plan.settings.config.paths.PluginSettings;
import com.djrapitops.plan.settings.config.paths.WebserverSettings;
import com.djrapitops.plan.settings.locale.Locale;
import com.djrapitops.plan.storage.database.DBSystem;
import com.djrapitops.plugin.logging.L;
import com.djrapitops.plugin.logging.console.PluginLogger;
import com.djrapitops.plugin.logging.error.ErrorHandler;
import com.djrapitops.plugin.utilities.Verify;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.TextStringBuilder;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
/**
* HttpHandler for WebServer request management.
@ -49,7 +54,6 @@ import java.util.concurrent.TimeUnit;
@Singleton
public class RequestHandler implements HttpHandler {
private final Locale locale;
private final PlanConfig config;
private final DBSystem dbSystem;
private final Addresses addresses;
@ -58,13 +62,10 @@ public class RequestHandler implements HttpHandler {
private final PluginLogger logger;
private final ErrorHandler errorHandler;
private final Cache<String, Integer> failedLoginAttempts = Caffeine.newBuilder()
.expireAfterWrite(90, TimeUnit.SECONDS)
.build();
private PassBruteForceGuard bruteForceGuard;
@Inject
RequestHandler(
Locale locale,
PlanConfig config,
DBSystem dbSystem,
Addresses addresses,
@ -73,7 +74,6 @@ public class RequestHandler implements HttpHandler {
PluginLogger logger,
ErrorHandler errorHandler
) {
this.locale = locale;
this.config = config;
this.dbSystem = dbSystem;
this.addresses = addresses;
@ -81,33 +81,16 @@ public class RequestHandler implements HttpHandler {
this.responseFactory = responseFactory;
this.logger = logger;
this.errorHandler = errorHandler;
bruteForceGuard = new PassBruteForceGuard();
}
@Override
public void handle(HttpExchange exchange) {
Headers requestHeaders = exchange.getRequestHeaders();
RequestInternal request = new RequestInternal(exchange, locale);
request.setAuth(getAuthorization(requestHeaders));
try {
Response response = shouldPreventRequest(request.getRemoteAddress()) // Forbidden response (Optional)
.orElseGet(() -> responseResolver.getResponse(request)); // Or the actual requested response
// Increase attempt count and block if too high
Optional<Response> forbid = handlePasswordBruteForceAttempts(request, response);
if (forbid.isPresent()) {
response = forbid.get();
}
// Authentication failed, but was not blocked
// if (response.getCode() == 401) {
//// responseHeaders.set("WWW-Authenticate", Optional.ofNullable(response.getHeaders().get("WWW-Authenticate")).orElse("Basic realm=\"Plan WebUser (/plan register)\""));
//// }
Response response = getResponse(exchange);
response.getHeaders().putIfAbsent("Access-Control-Allow-Origin", config.get(WebserverSettings.CORS_ALLOW_ORIGIN));
response.getHeaders().putIfAbsent("Access-Control-Allow-Methods", "GET, OPTIONS");
ResponseSender sender = new ResponseSender(addresses, exchange, response);
sender.send();
} catch (Exception e) {
@ -120,63 +103,71 @@ public class RequestHandler implements HttpHandler {
}
}
private Optional<Response> shouldPreventRequest(String accessor) {
Integer attempts = failedLoginAttempts.getIfPresent(accessor);
if (attempts == null) {
attempts = 0;
}
// Too many attempts, forbid further attempts.
if (attempts >= 5) {
return createForbiddenResponse();
}
return Optional.empty();
}
private Optional<Response> handlePasswordBruteForceAttempts(RequestInternal request, Response response) {
if (request.getAuth().isPresent() && response.getCode() == 401) {
// Authentication was attempted, but failed so new attempt is going to be given if not forbidden
failedLoginAttempts.cleanUp();
String accessor = request.getRemoteAddress();
Integer attempts = failedLoginAttempts.getIfPresent(accessor);
if (attempts == null) {
attempts = 0;
public Response getResponse(HttpExchange exchange) {
String accessor = exchange.getRemoteAddress().getAddress().getHostAddress();
Request request;
Response response;
try {
request = buildRequest(exchange);
if (bruteForceGuard.shouldPreventRequest(accessor)) {
response = responseFactory.failedLoginAttempts403();
} else {
response = responseResolver.getResponse(request);
}
// Too many attempts, forbid further attempts.
if (attempts >= 5) {
logger.warn(accessor + " failed to login 5 times. Their access is blocked for 90 seconds.");
return createForbiddenResponse();
} catch (WebUserAuthException thrownByAuthentication) {
if (thrownByAuthentication.getFailReason() != FailReason.USER_AND_PASS_NOT_SPECIFIED) {
bruteForceGuard.increaseAttemptCountOnFailedLogin(accessor);
}
// Attempts only increased if less than 5 attempts to prevent frustration from the cache value not
// getting removed.
failedLoginAttempts.put(accessor, attempts + 1);
} else if (response.getCode() != 401 && response.getCode() != 403) {
// Successful login
failedLoginAttempts.invalidate(request.getRemoteAddress());
response = responseFactory.basicAuthFail(thrownByAuthentication);
}
// First connection, no authentication headers present.
return Optional.empty();
if (bruteForceGuard.shouldPreventRequest(accessor)) {
response = responseFactory.failedLoginAttempts403();
}
if (response.getCode() != 401 && response.getCode() != 403) {
bruteForceGuard.resetAttemptCount(accessor);
}
return response;
}
private Optional<Response> createForbiddenResponse() {
return Optional.of(responseFactory.forbidden403("You have too many failed login attempts. Please wait 2 minutes until attempting again."));
private Request buildRequest(HttpExchange exchange) {
String requestMethod = exchange.getRequestMethod();
URIPath path = new URIPath(exchange.getRequestURI().getPath());
URIQuery query = new URIQuery(exchange.getRequestURI().getQuery());
WebUser user = getWebUser(exchange);
Map<String, String> headers = getRequestHeaders(exchange);
return new Request(requestMethod, path, query, user, headers);
}
private Authentication getAuthorization(Headers requestHeaders) {
private WebUser getWebUser(HttpExchange exchange) {
return getAuthorization(exchange.getRequestHeaders())
.map(Authentication::getWebUser) // Can throw WebUserAuthException
.map(com.djrapitops.plan.delivery.domain.WebUser::toNewWebUser)
.orElse(null);
}
private Map<String, String> getRequestHeaders(HttpExchange exchange) {
Map<String, String> headers = new HashMap<>();
for (Map.Entry<String, List<String>> e : exchange.getResponseHeaders().entrySet()) {
List<String> value = e.getValue();
headers.put(e.getKey(), new TextStringBuilder().appendWithSeparators(value, ";").build());
}
return headers;
}
private Optional<Authentication> getAuthorization(Headers requestHeaders) {
if (config.isTrue(WebserverSettings.DISABLED_AUTHENTICATION)) {
return Optional.empty();
}
List<String> authorization = requestHeaders.get("Authorization");
if (Verify.isEmpty(authorization)) {
return null;
}
if (Verify.isEmpty(authorization)) return Optional.empty();
String authLine = authorization.get(0);
if (StringUtils.contains(authLine, "Basic ")) {
return new BasicAuthentication(StringUtils.split(authLine, ' ')[1], dbSystem.getDatabase());
return Optional.of(new BasicAuthentication(StringUtils.split(authLine, ' ')[1], dbSystem.getDatabase()));
}
return null;
return Optional.empty();
}
public ResponseResolver getResponseResolver() {

View File

@ -1,127 +0,0 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.delivery.webserver;
import com.djrapitops.plan.delivery.web.resolver.request.Request;
import com.djrapitops.plan.delivery.web.resolver.request.URIPath;
import com.djrapitops.plan.delivery.web.resolver.request.URIQuery;
import com.djrapitops.plan.delivery.web.resolver.request.WebUser;
import com.djrapitops.plan.delivery.webserver.auth.Authentication;
import com.djrapitops.plan.exceptions.WebUserAuthException;
import com.djrapitops.plan.settings.locale.Locale;
import com.sun.net.httpserver.HttpExchange;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Represents a HttpExchange Request.
* <p>
* Automatically gets the Basic Auth from headers.
*
* @author Rsl1122
*/
public class RequestInternal {
private final String requestMethod;
private final URI requestURI;
private final HttpExchange exchange;
private final String remoteAddress;
private final Locale locale;
private Authentication auth;
public RequestInternal(HttpExchange exchange, Locale locale) {
this.requestMethod = exchange.getRequestMethod();
requestURI = exchange.getRequestURI();
remoteAddress = exchange.getRemoteAddress().getAddress().getHostAddress();
this.exchange = exchange;
this.locale = locale;
}
public Optional<Authentication> getAuth() {
return Optional.ofNullable(auth);
}
public void setAuth(Authentication authentication) {
auth = authentication;
}
public String getRequestMethod() {
return requestMethod;
}
public String getTargetString() {
return requestURI.getPath() + '?' + requestURI.getQuery();
}
public URIPath getPath() {
return new URIPath(requestURI.getPath());
}
public URIQuery getQuery() {
return new URIQuery(requestURI.getQuery());
}
@Override
public String toString() {
return "Request:" + requestMethod + " " + requestURI.getPath();
}
public String getRemoteAddress() {
return remoteAddress;
}
public Locale getLocale() {
return locale;
}
/**
* Turn the internal Request to a Page Extension API request object.
*
* @return different representation.
* @throws WebUserAuthException If the user could not be authenticated.
*/
public Request toAPIRequest() {
return new Request(
requestMethod,
getPath(),
getQuery(),
getWebUser(),
getRequestHeaders()
);
}
private WebUser getWebUser() {
return getAuth().map(authentication -> authentication.getWebUser().toNewWebUser()).orElse(null);
}
private Map<String, String> getRequestHeaders() {
Map<String, String> headers = new HashMap<>();
for (Map.Entry<String, List<String>> e : exchange.getResponseHeaders().entrySet()) {
List<String> value = e.getValue();
headers.put(e.getKey(), value.toString().substring(0, value.size() - 1));
}
return headers;
}
}

View File

@ -334,6 +334,15 @@ public class ResponseFactory {
}
}
public Response failedLoginAttempts403() {
return Response.builder()
.setMimeType(MimeType.HTML)
.setContent("<h1>403 Forbidden</h1>" +
"<p>You have too many failed login attempts. Please wait 2 minutes until attempting again.</p>")
.setStatus(403)
.build();
}
public Response basicAuth() {
try {
String tips = "<br>- Ensure you have registered a user with <b>/plan register</b><br>"

View File

@ -24,10 +24,9 @@ import com.djrapitops.plan.delivery.web.resolver.Response;
import com.djrapitops.plan.delivery.web.resolver.exception.BadRequestException;
import com.djrapitops.plan.delivery.web.resolver.exception.NotFoundException;
import com.djrapitops.plan.delivery.web.resolver.request.Request;
import com.djrapitops.plan.delivery.webserver.auth.Authentication;
import com.djrapitops.plan.delivery.web.resolver.request.WebUser;
import com.djrapitops.plan.delivery.webserver.resolver.*;
import com.djrapitops.plan.delivery.webserver.resolver.json.RootJSONResolver;
import com.djrapitops.plan.exceptions.WebUserAuthException;
import com.djrapitops.plan.exceptions.connection.ForbiddenException;
import com.djrapitops.plugin.logging.L;
import com.djrapitops.plugin.logging.error.ErrorHandler;
@ -106,46 +105,41 @@ public class ResponseResolver {
resolverService.registerResolver(plugin, "/v1", rootJSONResolver.getResolver());
}
public Response getResponse(RequestInternal request) {
public Response getResponse(Request request) {
try {
return tryToGetResponse(request);
} catch (NotFoundException e) {
return responseFactory.notFound404(e.getMessage());
} catch (WebUserAuthException e) {
return responseFactory.basicAuthFail(e);
} catch (ForbiddenException e) {
return responseFactory.forbidden403(e.getMessage());
} catch (BadRequestException e) {
return responseFactory.badRequest(e.getMessage(), request.getTargetString());
return responseFactory.badRequest(e.getMessage(), request.getPath().asString());
} catch (Exception e) {
errorHandler.log(L.ERROR, this.getClass(), e);
return responseFactory.internalErrorResponse(e, request.getTargetString());
return responseFactory.internalErrorResponse(e, request.getPath().asString());
}
}
/**
* @throws NotFoundException In some cases when page was not found, not all.
* @throws WebUserAuthException If user could not be authenticated
* @throws ForbiddenException If the user is not allowed to see the page
* @throws BadRequestException If the request did not have required things.
* @throws NotFoundException In some cases when page was not found, not all.
* @throws ForbiddenException If the user is not allowed to see the page
* @throws BadRequestException If the request did not have required things.
*/
private Response tryToGetResponse(RequestInternal internalRequest) {
if ("OPTIONS".equalsIgnoreCase(internalRequest.getRequestMethod())) {
private Response tryToGetResponse(Request request) {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS
return Response.builder().setStatus(204).setContent(new byte[0]).build();
}
Optional<Authentication> authentication = internalRequest.getAuth();
Optional<WebUser> user = request.getUser();
List<Resolver> foundResolvers = resolverService.getResolvers(internalRequest.getPath().asString());
List<Resolver> foundResolvers = resolverService.getResolvers(request.getPath().asString());
if (foundResolvers.isEmpty()) return responseFactory.pageNotFound404();
for (Resolver resolver : foundResolvers) {
Request request = internalRequest.toAPIRequest();
if (resolver.requiresAuth(request)) {
// Get required auth
boolean isAuthRequired = webServer.get().isAuthRequired();
if (isAuthRequired && !authentication.isPresent()) {
boolean isAuthRequired = webServer.get().isAuthRequired() && resolver.requiresAuth(request);
if (isAuthRequired) {
if (!user.isPresent()) {
if (webServer.get().isUsingHTTPS()) {
return responseFactory.basicAuth();
} else {
@ -153,7 +147,7 @@ public class ResponseResolver {
}
}
if (!isAuthRequired || resolver.canAccess(request)) {
if (resolver.canAccess(request)) {
Optional<Response> resolved = resolver.resolve(request);
if (resolved.isPresent()) return resolved.get();
} else {

View File

@ -26,6 +26,8 @@ import com.djrapitops.plan.utilities.Base64Util;
import com.djrapitops.plan.utilities.PassEncryptUtil;
import org.apache.commons.lang3.StringUtils;
import java.util.Arrays;
/**
* Authentication handling for Basic Auth.
* <p>
@ -50,7 +52,7 @@ public class BasicAuthentication implements Authentication {
String[] userInfo = StringUtils.split(decoded, ':');
if (userInfo.length != 2) {
throw new WebUserAuthException(FailReason.USER_AND_PASS_NOT_SPECIFIED);
throw new WebUserAuthException(FailReason.USER_AND_PASS_NOT_SPECIFIED, Arrays.toString(userInfo));
}
String user = userInfo[0];