Add rate limiter for login endpoint (#4062)

#### What type of PR is this?

/kind feature
/area core

#### What this PR does / why we need it:

This PR introduces https://github.com/resilience4j/resilience4j to archive the feature. The login endpoint has limited login failures at a rate of 3 per minute.

See https://github.com/halo-dev/halo/issues/4044 for more.

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/4044

#### Special notes for your reviewer:

1. Start Halo.
2. Try to login with incorrect credential 4 times
3. Check the response.

#### Does this PR introduce a user-facing change?

```release-note
增加登录失败次数限制功能
```
pull/4085/head
John Niang 2023-06-16 12:50:12 +08:00 committed by GitHub
parent 350e54d42a
commit 02369fbd3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 237 additions and 84 deletions

View File

@ -50,6 +50,9 @@ dependencies {
api "com.github.java-json-tools:json-patch"
api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
api "io.github.resilience4j:resilience4j-spring-boot3"
api "io.github.resilience4j:resilience4j-reactor"
runtimeOnly 'io.r2dbc:r2dbc-h2'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'org.postgresql:r2dbc-postgresql'

View File

@ -46,17 +46,6 @@ tasks.named('jar') {
enabled = false
}
ext {
commonsLang3 = "3.12.0"
base62 = "0.1.3"
pf4j = '3.9.0'
javaDiffUtils = "4.12"
jsoup = '1.15.3'
jsonPatch = "1.13"
springDocOpenAPI = "2.0.2"
lucene = "9.5.0"
}
dependencies {
implementation project(':api')

View File

@ -0,0 +1,15 @@
package run.halo.app.infra.exception;
public enum Exceptions {
;
public static final String THEME_ALREADY_EXISTS_TYPE =
"https://halo.run/probs/theme-alreay-exists";
public static final String INVALID_CREDENTIAL_TYPE =
"https://halo.run/probs/invalid-credential";
public static final String REQUEST_NOT_PERMITTED_TYPE =
"https://halo.run/probs/request-not-permitted";
}

View File

@ -12,9 +12,6 @@ import org.springframework.web.server.ServerWebInputException;
*/
public class ThemeAlreadyExistsException extends ServerWebInputException {
public static final String THEME_ALREADY_EXISTS_TYPE =
"https://halo.run/probs/theme-alreay-exists";
/**
* Constructs a {@code ThemeAlreadyExistsException} with the given theme name.
*
@ -23,7 +20,7 @@ public class ThemeAlreadyExistsException extends ServerWebInputException {
public ThemeAlreadyExistsException(@NonNull String themeName) {
super("Theme already exists.", null, null, "problemDetail.theme.install.alreadyExists",
new Object[] {themeName});
setType(URI.create(THEME_ALREADY_EXISTS_TYPE));
setType(URI.create(Exceptions.THEME_ALREADY_EXISTS_TYPE));
getBody().setProperty("themeName", themeName);
}
}

View File

@ -1,21 +1,54 @@
package run.halo.app.infra.utils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpHeaders;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.reactive.function.server.ServerRequest;
/**
* Ip address utils.
* Code from internet.
*/
@Slf4j
public class IpAddressUtils {
private static final String UNKNOWN = "unknown";
private static final String X_REAL_IP = "X-Real-IP";
private static final String X_FORWARDED_FOR = "X-Forwarded-For";
private static final String PROXY_CLIENT_IP = "Proxy-Client-IP";
private static final String WL_PROXY_CLIENT_IP = "WL-Proxy-Client-IP";
private static final String HTTP_CLIENT_IP = "HTTP_CLIENT_IP";
private static final String HTTP_X_FORWARDED_FOR = "HTTP_X_FORWARDED_FOR";
public static final String UNKNOWN = "unknown";
private static final String[] IP_HEADER_NAMES = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"CF-Connecting-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR",
};
/**
* Gets the IP address from request.
*
* @param request is server http request
* @return IP address if found, otherwise {@link #UNKNOWN}.
*/
public static String getClientIp(ServerHttpRequest request) {
for (String header : IP_HEADER_NAMES) {
String ipList = request.getHeaders().getFirst(header);
if (ipList != null && ipList.length() != 0 && !"unknown".equalsIgnoreCase(ipList)) {
String[] ips = ipList.trim().split("[,;]");
for (String ip : ips) {
if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
return ip;
}
}
}
}
var remoteAddress = request.getRemoteAddress();
return remoteAddress == null ? UNKNOWN : remoteAddress.getAddress().getHostAddress();
}
/**
* Gets the ip address from request.
@ -25,48 +58,11 @@ public class IpAddressUtils {
*/
public static String getIpAddress(ServerRequest request) {
try {
return getIpAddressInternal(request);
return getClientIp(request.exchange().getRequest());
} catch (Exception e) {
log.warn("Failed to obtain client IP, and fallback to unknown.", e);
return UNKNOWN;
}
}
private static String getIpAddressInternal(ServerRequest request) {
HttpHeaders httpHeaders = request.headers().asHttpHeaders();
String xrealIp = httpHeaders.getFirst(X_REAL_IP);
String xforwardedFor = httpHeaders.getFirst(X_FORWARDED_FOR);
if (StringUtils.isNotEmpty(xforwardedFor) && !UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
// After multiple reverse proxies, there will be multiple IP values. The first IP is
// the real IP
int index = xforwardedFor.indexOf(",");
if (index != -1) {
return xforwardedFor.substring(0, index);
} else {
return xforwardedFor;
}
}
xforwardedFor = xrealIp;
if (StringUtils.isNotEmpty(xforwardedFor) && !UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
return xforwardedFor;
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = httpHeaders.getFirst(PROXY_CLIENT_IP);
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = httpHeaders.getFirst(WL_PROXY_CLIENT_IP);
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = httpHeaders.getFirst(HTTP_CLIENT_IP);
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = httpHeaders.getFirst(HTTP_X_FORWARDED_FOR);
}
if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) {
xforwardedFor = request.remoteAddress()
.map(remoteAddress -> remoteAddress.getAddress().getHostAddress())
.orElse(UNKNOWN);
}
return xforwardedFor;
}
}

View File

@ -1,12 +1,23 @@
package run.halo.app.security.authentication.login;
import static org.springframework.http.HttpStatus.TOO_MANY_REQUESTS;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static run.halo.app.infra.exception.Exceptions.INVALID_CREDENTIAL_TYPE;
import static run.halo.app.infra.exception.Exceptions.REQUEST_NOT_PERMITTED_TYPE;
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
import io.micrometer.observation.ObservationRegistry;
import java.util.Map;
import java.net.URI;
import java.time.Instant;
import java.util.Locale;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.ObservationReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
@ -28,10 +39,12 @@ import org.springframework.security.web.server.context.ServerSecurityContextRepo
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
import org.springframework.stereotype.Component;
import org.springframework.web.ErrorResponse;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.infra.utils.IpAddressUtils;
import run.halo.app.security.AdditionalWebFilter;
/**
@ -40,6 +53,7 @@ import run.halo.app.security.AdditionalWebFilter;
* @author guqing
* @since 2.4.0
*/
@Slf4j
@Component
public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
@ -59,10 +73,14 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
private final AuthenticationWebFilter authenticationWebFilter;
private final RateLimiterRegistry rateLimiterRegistry;
private final MessageSource messageSource;
public UsernamePasswordAuthenticator(ServerResponse.Context context,
ObservationRegistry observationRegistry, ReactiveUserDetailsService userDetailsService,
ReactiveUserDetailsPasswordService passwordService, PasswordEncoder passwordEncoder,
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService) {
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService,
RateLimiterRegistry rateLimiterRegistry, MessageSource messageSource) {
this.context = context;
this.observationRegistry = observationRegistry;
this.userDetailsService = userDetailsService;
@ -70,6 +88,8 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
this.passwordEncoder = passwordEncoder;
this.securityContextRepository = securityContextRepository;
this.cryptoService = cryptoService;
this.rateLimiterRegistry = rateLimiterRegistry;
this.messageSource = messageSource;
this.authenticationWebFilter = new AuthenticationWebFilter(authenticationManager());
configureAuthenticationWebFilter(this.authenticationWebFilter);
@ -91,7 +111,8 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
filter.setRequiresAuthenticationMatcher(requiresMatcher);
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
filter.setServerAuthenticationConverter(new LoginAuthenticationConverter(cryptoService));
filter.setServerAuthenticationConverter(new LoginAuthenticationConverter(cryptoService
));
filter.setSecurityContextRepository(securityContextRepository);
}
@ -102,6 +123,62 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
return new ObservationReactiveAuthenticationManager(observationRegistry, manager);
}
private <T> RateLimiterOperator<T> createIPBasedRateLimiter(ServerWebExchange exchange) {
var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
var rateLimiter =
rateLimiterRegistry.rateLimiter("authentication-from-ip-" + clientIp,
"authentication");
if (log.isDebugEnabled()) {
var metrics = rateLimiter.getMetrics();
log.debug(
"Authentication with Rate Limiter: {}, available permissions: {}, number of "
+ "waiting threads: {}",
rateLimiter, metrics.getAvailablePermissions(),
metrics.getNumberOfWaitingThreads());
}
return RateLimiterOperator.of(rateLimiter);
}
private Mono<Void> handleRequestNotPermitted(RequestNotPermitted e,
ServerWebExchange exchange) {
var errorResponse =
createErrorResponse(e, TOO_MANY_REQUESTS, REQUEST_NOT_PERMITTED_TYPE, exchange);
return writeErrorResponse(errorResponse, exchange);
}
private Mono<Void> handleAuthenticationException(AuthenticationException exception,
ServerWebExchange exchange) {
var errorResponse =
createErrorResponse(exception, UNAUTHORIZED, INVALID_CREDENTIAL_TYPE, exchange);
return writeErrorResponse(errorResponse, exchange);
}
private ErrorResponse createErrorResponse(Throwable t, HttpStatus status, String type,
ServerWebExchange exchange) {
var errorResponse =
ErrorResponse.create(t, status, t.getMessage());
var problemDetail = errorResponse.updateAndGetBody(messageSource, getLocale(exchange));
problemDetail.setType(URI.create(type));
problemDetail.setInstance(exchange.getRequest().getURI());
problemDetail.setProperty("requestId", exchange.getRequest().getId());
problemDetail.setProperty("timestamp", Instant.now());
return errorResponse;
}
private Mono<Void> writeErrorResponse(ErrorResponse errorResponse,
ServerWebExchange exchange) {
return ServerResponse.status(errorResponse.getStatusCode())
.contentType(APPLICATION_JSON)
.bodyValue(errorResponse.getBody())
.flatMap(response -> response.writeTo(exchange, context));
}
private Locale getLocale(ServerWebExchange exchange) {
var locale = exchange.getLocaleContext().getLocale();
return locale == null ? Locale.getDefault() : locale;
}
public class LoginSuccessHandler implements ServerAuthenticationSuccessHandler {
private final ServerAuthenticationSuccessHandler defaultHandler =
@ -110,8 +187,9 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
Authentication authentication) {
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON)
.matches(webFilterExchange.getExchange())
var exchange = webFilterExchange.getExchange();
return ignoringMediaTypeAll(APPLICATION_JSON)
.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.switchIfEmpty(
defaultHandler.onAuthenticationSuccess(webFilterExchange, authentication)
@ -124,11 +202,14 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
}
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.contentType(APPLICATION_JSON)
.bodyValue(principal)
.flatMap(serverResponse ->
serverResponse.writeTo(webFilterExchange.getExchange(), context));
});
serverResponse.writeTo(exchange, context));
})
.transformDeferred(createIPBasedRateLimiter(exchange))
.onErrorResume(RequestNotPermitted.class,
e -> handleRequestNotPermitted(e, exchange));
}
}
@ -142,21 +223,25 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
private final ServerAuthenticationFailureHandler defaultHandler =
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
public LoginFailureHandler() {
}
@Override
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
AuthenticationException exception) {
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(
webFilterExchange.getExchange())
var exchange = webFilterExchange.getExchange();
return ignoringMediaTypeAll(APPLICATION_JSON)
.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.flatMap(matchResult -> ServerResponse.status(HttpStatus.UNAUTHORIZED)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"error", exception.getLocalizedMessage()
))
.flatMap(serverResponse -> serverResponse.writeTo(
webFilterExchange.getExchange(), context)))
.switchIfEmpty(
defaultHandler.onAuthenticationFailure(webFilterExchange, exception));
.switchIfEmpty(defaultHandler.onAuthenticationFailure(webFilterExchange, exception)
// Skip the handleAuthenticationException.
.then(Mono.empty())
)
.flatMap(matchResult -> handleAuthenticationException(exception, exchange))
.transformDeferred(createIPBasedRateLimiter(exchange))
.onErrorResume(RequestNotPermitted.class,
e -> handleRequestNotPermitted(e, exchange));
}
}
}

View File

@ -66,3 +66,10 @@ management:
enabled: true
os:
enabled: true
resilience4j.ratelimiter:
configs:
authentication:
limitForPeriod: 3
limitRefreshPeriod: 1m
timeoutDuration: 0

View File

@ -8,6 +8,7 @@ problemDetail.title.org.springframework.web.bind.support.WebExchangeBindExceptio
problemDetail.title.org.springframework.web.server.NotAcceptableStatusException=Not Acceptable
problemDetail.title.org.springframework.web.server.ServerErrorException=Server Error
problemDetail.title.org.springframework.web.server.MethodNotAllowedException=Method Not Allowed
problemDetail.title.org.springframework.security.authentication.BadCredentialsException=Bad Credentials
problemDetail.title.run.halo.app.extension.exception.SchemaViolationException=Schema Violation
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=Attachment Already Exists
problemDetail.title.run.halo.app.infra.exception.AccessDeniedException=Access Denied
@ -17,6 +18,7 @@ problemDetail.title.run.halo.app.infra.exception.ThemeUpgradeException=Theme Upg
problemDetail.title.run.halo.app.infra.exception.PluginInstallationException=Plugin Install Error
problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin Already Exists Error
problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=Duplicate Name Error
problemDetail.title.io.github.resilience4j.ratelimiter.RequestNotPermitted=Request Not Permitted
# Detail definitions
problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException=Content type {0} is not supported. Supported media types: {1}.
@ -27,11 +29,13 @@ problemDetail.org.springframework.web.bind.support.WebExchangeBindException=Inva
problemDetail.org.springframework.web.server.NotAcceptableStatusException=Acceptable representations: {0}.
problemDetail.org.springframework.web.server.NotAcceptableStatusException.parseError=Could not parse Accept header.
problemDetail.org.springframework.web.server.ServerErrorException={0}.
problemDetail.org.springframework.security.authentication.BadCredentialsException=The username or password is incorrect.
problemDetail.org.springframework.web.server.MethodNotAllowedException=Request method {0} is not supported. Supported methods: {1}.
problemDetail.run.halo.app.extension.exception.SchemaViolationException={1} of schema {0}.
problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=File {0} already exists, please rename it and try again.
problemDetail.run.halo.app.infra.exception.DuplicateNameException=Duplicate name detected, please rename it and retry.
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin {0} already exists.
problemDetail.io.github.resilience4j.ratelimiter.RequestNotPermitted=API rate limit exceeded, please try again later.
problemDetail.user.signUpFailed.disallowed=System does not allow new users to register.
problemDetail.user.duplicateName=The username {0} already exists, please rename it and retry.

View File

@ -1,14 +1,18 @@
problemDetail.title.org.springframework.web.server.ServerWebInputException=请求参数有误
problemDetail.title.org.springframework.security.authentication.BadCredentialsException=无效凭据
problemDetail.title.run.halo.app.infra.exception.UnsatisfiedAttributeValueException=请求参数属性值不满足要求
problemDetail.title.run.halo.app.infra.exception.PluginInstallationException=插件安装失败
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=附件已存在
problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=名称重复
problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=插件已存在
problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=主题安装失败
problemDetail.title.io.github.resilience4j.ratelimiter.RequestNotPermitted=请求限制
problemDetail.org.springframework.security.authentication.BadCredentialsException=用户名或密码错误。
problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=文件 {0} 已存在,建议更名后重试。
problemDetail.run.halo.app.infra.exception.DuplicateNameException=检测到有重复的名称,请重命名后重试。
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=插件 {0} 已经存。
problemDetail.io.github.resilience4j.ratelimiter.RequestNotPermitted=请求过于频繁,请稍候再试。
problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。
problemDetail.user.duplicateName=用户名 {0} 已存在,请更换用户名后重试。

View File

@ -0,0 +1,44 @@
package run.halo.app.infra.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
class IpAddressUtilsTest {
@Test
void testGetIPAddressFromCloudflareProxy() {
var request = MockServerHttpRequest.get("/")
.header("CF-Connecting-IP", "127.0.0.1")
.build();
var expected = "127.0.0.1";
var actual = IpAddressUtils.getClientIp(request);
assertEquals(expected, actual);
}
@Test
void testGetUnknownIPAddress() {
var request = MockServerHttpRequest.get("/").build();
var actual = IpAddressUtils.getClientIp(request);
assertEquals(IpAddressUtils.UNKNOWN, actual);
}
@Test
void testGetIPAddressWithMultipleHeaders() {
var headers = new HttpHeaders();
headers.add("X-Forwarded-For", "127.0.0.1, 127.0.1.1");
headers.add("Proxy-Client-IP", "127.0.0.2");
headers.add("CF-Connecting-IP", "127.0.0.2");
headers.add("WL-Proxy-Client-IP", "127.0.0.3");
headers.add("HTTP_CLIENT_IP", "127.0.0.4");
headers.add("HTTP_X_FORWARDED_FOR", "127.0.0.5");
var request = MockServerHttpRequest.get("/")
.headers(headers)
.build();
var expected = "127.0.0.1";
var actual = IpAddressUtils.getClientIp(request);
assertEquals(expected, actual);
}
}

View File

@ -92,7 +92,13 @@ const handleLogin = async () => {
return;
}
Toast.error(t("core.login.operations.submit.toast_failed"));
const { title: errorTitle, detail: errorDetail } = e.response?.data || {};
if (errorTitle || errorDetail) {
Toast.error([errorTitle, errorDetail].filter(Boolean).join(" - "));
} else {
Toast.error(t("core.common.toast.unknown_error"));
}
} else {
Toast.error(t("core.common.toast.unknown_error"));
}

View File

@ -20,6 +20,7 @@ ext {
jsonPatch = "1.13"
springDocOpenAPI = "2.1.0"
lucene = "9.5.0"
resilience4jVersion = "2.0.2"
}
javaPlatform {
@ -49,6 +50,8 @@ dependencies {
api "org.springframework.integration:spring-integration-core"
api "com.github.java-json-tools:json-patch:$jsonPatch"
api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
api "io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion"
api "io.github.resilience4j:resilience4j-reactor:$resilience4jVersion"
runtime 'org.mariadb:r2dbc-mariadb:1.1.4'
}