mirror of https://github.com/halo-dev/halo
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
parent
350e54d42a
commit
02369fbd3c
|
@ -50,6 +50,9 @@ dependencies {
|
||||||
api "com.github.java-json-tools:json-patch"
|
api "com.github.java-json-tools:json-patch"
|
||||||
api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
|
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 'io.r2dbc:r2dbc-h2'
|
||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
runtimeOnly 'org.postgresql:r2dbc-postgresql'
|
runtimeOnly 'org.postgresql:r2dbc-postgresql'
|
||||||
|
|
|
@ -46,17 +46,6 @@ tasks.named('jar') {
|
||||||
enabled = false
|
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 {
|
dependencies {
|
||||||
implementation project(':api')
|
implementation project(':api')
|
||||||
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
}
|
|
@ -12,9 +12,6 @@ import org.springframework.web.server.ServerWebInputException;
|
||||||
*/
|
*/
|
||||||
public class ThemeAlreadyExistsException extends 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.
|
* Constructs a {@code ThemeAlreadyExistsException} with the given theme name.
|
||||||
*
|
*
|
||||||
|
@ -23,7 +20,7 @@ public class ThemeAlreadyExistsException extends ServerWebInputException {
|
||||||
public ThemeAlreadyExistsException(@NonNull String themeName) {
|
public ThemeAlreadyExistsException(@NonNull String themeName) {
|
||||||
super("Theme already exists.", null, null, "problemDetail.theme.install.alreadyExists",
|
super("Theme already exists.", null, null, "problemDetail.theme.install.alreadyExists",
|
||||||
new Object[] {themeName});
|
new Object[] {themeName});
|
||||||
setType(URI.create(THEME_ALREADY_EXISTS_TYPE));
|
setType(URI.create(Exceptions.THEME_ALREADY_EXISTS_TYPE));
|
||||||
getBody().setProperty("themeName", themeName);
|
getBody().setProperty("themeName", themeName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,54 @@
|
||||||
package run.halo.app.infra.utils;
|
package run.halo.app.infra.utils;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ip address utils.
|
* Ip address utils.
|
||||||
* Code from internet.
|
* Code from internet.
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
public class IpAddressUtils {
|
public class IpAddressUtils {
|
||||||
private static final String UNKNOWN = "unknown";
|
public 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[] IP_HEADER_NAMES = {
|
||||||
private static final String PROXY_CLIENT_IP = "Proxy-Client-IP";
|
"X-Forwarded-For",
|
||||||
private static final String WL_PROXY_CLIENT_IP = "WL-Proxy-Client-IP";
|
"Proxy-Client-IP",
|
||||||
private static final String HTTP_CLIENT_IP = "HTTP_CLIENT_IP";
|
"WL-Proxy-Client-IP",
|
||||||
private static final String HTTP_X_FORWARDED_FOR = "HTTP_X_FORWARDED_FOR";
|
"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.
|
* Gets the ip address from request.
|
||||||
|
@ -25,48 +58,11 @@ public class IpAddressUtils {
|
||||||
*/
|
*/
|
||||||
public static String getIpAddress(ServerRequest request) {
|
public static String getIpAddress(ServerRequest request) {
|
||||||
try {
|
try {
|
||||||
return getIpAddressInternal(request);
|
return getClientIp(request.exchange().getRequest());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to obtain client IP, and fallback to unknown.", e);
|
||||||
return UNKNOWN;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,23 @@
|
||||||
package run.halo.app.security.authentication.login;
|
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 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 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.HttpMethod;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.security.authentication.ObservationReactiveAuthenticationManager;
|
import org.springframework.security.authentication.ObservationReactiveAuthenticationManager;
|
||||||
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
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.ServerWebExchangeMatcher;
|
||||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.ErrorResponse;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import org.springframework.web.server.ServerWebExchange;
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
import org.springframework.web.server.WebFilterChain;
|
import org.springframework.web.server.WebFilterChain;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.infra.utils.IpAddressUtils;
|
||||||
import run.halo.app.security.AdditionalWebFilter;
|
import run.halo.app.security.AdditionalWebFilter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -40,6 +53,7 @@ import run.halo.app.security.AdditionalWebFilter;
|
||||||
* @author guqing
|
* @author guqing
|
||||||
* @since 2.4.0
|
* @since 2.4.0
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
||||||
|
|
||||||
|
@ -59,10 +73,14 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
||||||
|
|
||||||
private final AuthenticationWebFilter authenticationWebFilter;
|
private final AuthenticationWebFilter authenticationWebFilter;
|
||||||
|
|
||||||
|
private final RateLimiterRegistry rateLimiterRegistry;
|
||||||
|
private final MessageSource messageSource;
|
||||||
|
|
||||||
public UsernamePasswordAuthenticator(ServerResponse.Context context,
|
public UsernamePasswordAuthenticator(ServerResponse.Context context,
|
||||||
ObservationRegistry observationRegistry, ReactiveUserDetailsService userDetailsService,
|
ObservationRegistry observationRegistry, ReactiveUserDetailsService userDetailsService,
|
||||||
ReactiveUserDetailsPasswordService passwordService, PasswordEncoder passwordEncoder,
|
ReactiveUserDetailsPasswordService passwordService, PasswordEncoder passwordEncoder,
|
||||||
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService) {
|
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService,
|
||||||
|
RateLimiterRegistry rateLimiterRegistry, MessageSource messageSource) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.observationRegistry = observationRegistry;
|
this.observationRegistry = observationRegistry;
|
||||||
this.userDetailsService = userDetailsService;
|
this.userDetailsService = userDetailsService;
|
||||||
|
@ -70,6 +88,8 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.securityContextRepository = securityContextRepository;
|
this.securityContextRepository = securityContextRepository;
|
||||||
this.cryptoService = cryptoService;
|
this.cryptoService = cryptoService;
|
||||||
|
this.rateLimiterRegistry = rateLimiterRegistry;
|
||||||
|
this.messageSource = messageSource;
|
||||||
|
|
||||||
this.authenticationWebFilter = new AuthenticationWebFilter(authenticationManager());
|
this.authenticationWebFilter = new AuthenticationWebFilter(authenticationManager());
|
||||||
configureAuthenticationWebFilter(this.authenticationWebFilter);
|
configureAuthenticationWebFilter(this.authenticationWebFilter);
|
||||||
|
@ -91,7 +111,8 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
||||||
filter.setRequiresAuthenticationMatcher(requiresMatcher);
|
filter.setRequiresAuthenticationMatcher(requiresMatcher);
|
||||||
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
|
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
|
||||||
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
|
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
|
||||||
filter.setServerAuthenticationConverter(new LoginAuthenticationConverter(cryptoService));
|
filter.setServerAuthenticationConverter(new LoginAuthenticationConverter(cryptoService
|
||||||
|
));
|
||||||
filter.setSecurityContextRepository(securityContextRepository);
|
filter.setSecurityContextRepository(securityContextRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,6 +123,62 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
||||||
return new ObservationReactiveAuthenticationManager(observationRegistry, manager);
|
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 {
|
public class LoginSuccessHandler implements ServerAuthenticationSuccessHandler {
|
||||||
|
|
||||||
private final ServerAuthenticationSuccessHandler defaultHandler =
|
private final ServerAuthenticationSuccessHandler defaultHandler =
|
||||||
|
@ -110,8 +187,9 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
|
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON)
|
var exchange = webFilterExchange.getExchange();
|
||||||
.matches(webFilterExchange.getExchange())
|
return ignoringMediaTypeAll(APPLICATION_JSON)
|
||||||
|
.matches(exchange)
|
||||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||||
.switchIfEmpty(
|
.switchIfEmpty(
|
||||||
defaultHandler.onAuthenticationSuccess(webFilterExchange, authentication)
|
defaultHandler.onAuthenticationSuccess(webFilterExchange, authentication)
|
||||||
|
@ -124,11 +202,14 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
return ServerResponse.ok()
|
return ServerResponse.ok()
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(APPLICATION_JSON)
|
||||||
.bodyValue(principal)
|
.bodyValue(principal)
|
||||||
.flatMap(serverResponse ->
|
.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 =
|
private final ServerAuthenticationFailureHandler defaultHandler =
|
||||||
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
|
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
|
||||||
|
|
||||||
|
public LoginFailureHandler() {
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
||||||
AuthenticationException exception) {
|
AuthenticationException exception) {
|
||||||
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(
|
var exchange = webFilterExchange.getExchange();
|
||||||
webFilterExchange.getExchange())
|
return ignoringMediaTypeAll(APPLICATION_JSON)
|
||||||
|
.matches(exchange)
|
||||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||||
.flatMap(matchResult -> ServerResponse.status(HttpStatus.UNAUTHORIZED)
|
.switchIfEmpty(defaultHandler.onAuthenticationFailure(webFilterExchange, exception)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
// Skip the handleAuthenticationException.
|
||||||
.bodyValue(Map.of(
|
.then(Mono.empty())
|
||||||
"error", exception.getLocalizedMessage()
|
)
|
||||||
))
|
.flatMap(matchResult -> handleAuthenticationException(exception, exchange))
|
||||||
.flatMap(serverResponse -> serverResponse.writeTo(
|
.transformDeferred(createIPBasedRateLimiter(exchange))
|
||||||
webFilterExchange.getExchange(), context)))
|
.onErrorResume(RequestNotPermitted.class,
|
||||||
.switchIfEmpty(
|
e -> handleRequestNotPermitted(e, exchange));
|
||||||
defaultHandler.onAuthenticationFailure(webFilterExchange, exception));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,3 +66,10 @@ management:
|
||||||
enabled: true
|
enabled: true
|
||||||
os:
|
os:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
resilience4j.ratelimiter:
|
||||||
|
configs:
|
||||||
|
authentication:
|
||||||
|
limitForPeriod: 3
|
||||||
|
limitRefreshPeriod: 1m
|
||||||
|
timeoutDuration: 0
|
||||||
|
|
|
@ -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.NotAcceptableStatusException=Not Acceptable
|
||||||
problemDetail.title.org.springframework.web.server.ServerErrorException=Server Error
|
problemDetail.title.org.springframework.web.server.ServerErrorException=Server Error
|
||||||
problemDetail.title.org.springframework.web.server.MethodNotAllowedException=Method Not Allowed
|
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.extension.exception.SchemaViolationException=Schema Violation
|
||||||
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=Attachment Already Exists
|
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=Attachment Already Exists
|
||||||
problemDetail.title.run.halo.app.infra.exception.AccessDeniedException=Access Denied
|
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.PluginInstallationException=Plugin Install Error
|
||||||
problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin Already Exists 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.run.halo.app.infra.exception.DuplicateNameException=Duplicate Name Error
|
||||||
|
problemDetail.title.io.github.resilience4j.ratelimiter.RequestNotPermitted=Request Not Permitted
|
||||||
|
|
||||||
# Detail definitions
|
# Detail definitions
|
||||||
problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException=Content type {0} is not supported. Supported media types: {1}.
|
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=Acceptable representations: {0}.
|
||||||
problemDetail.org.springframework.web.server.NotAcceptableStatusException.parseError=Could not parse Accept header.
|
problemDetail.org.springframework.web.server.NotAcceptableStatusException.parseError=Could not parse Accept header.
|
||||||
problemDetail.org.springframework.web.server.ServerErrorException={0}.
|
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.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.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.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.DuplicateNameException=Duplicate name detected, please rename it and retry.
|
||||||
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin {0} already exists.
|
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.signUpFailed.disallowed=System does not allow new users to register.
|
||||||
problemDetail.user.duplicateName=The username {0} already exists, please rename it and retry.
|
problemDetail.user.duplicateName=The username {0} already exists, please rename it and retry.
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
problemDetail.title.org.springframework.web.server.ServerWebInputException=请求参数有误
|
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.UnsatisfiedAttributeValueException=请求参数属性值不满足要求
|
||||||
problemDetail.title.run.halo.app.infra.exception.PluginInstallationException=插件安装失败
|
problemDetail.title.run.halo.app.infra.exception.PluginInstallationException=插件安装失败
|
||||||
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=附件已存在
|
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=附件已存在
|
||||||
problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=名称重复
|
problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=名称重复
|
||||||
problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=插件已存在
|
problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=插件已存在
|
||||||
problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=主题安装失败
|
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.AttachmentAlreadyExistsException=文件 {0} 已存在,建议更名后重试。
|
||||||
problemDetail.run.halo.app.infra.exception.DuplicateNameException=检测到有重复的名称,请重命名后重试。
|
problemDetail.run.halo.app.infra.exception.DuplicateNameException=检测到有重复的名称,请重命名后重试。
|
||||||
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=插件 {0} 已经存。
|
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=插件 {0} 已经存。
|
||||||
|
problemDetail.io.github.resilience4j.ratelimiter.RequestNotPermitted=请求过于频繁,请稍候再试。
|
||||||
|
|
||||||
problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。
|
problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。
|
||||||
problemDetail.user.duplicateName=用户名 {0} 已存在,请更换用户名后重试。
|
problemDetail.user.duplicateName=用户名 {0} 已存在,请更换用户名后重试。
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -92,7 +92,13 @@ const handleLogin = async () => {
|
||||||
return;
|
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 {
|
} else {
|
||||||
Toast.error(t("core.common.toast.unknown_error"));
|
Toast.error(t("core.common.toast.unknown_error"));
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ ext {
|
||||||
jsonPatch = "1.13"
|
jsonPatch = "1.13"
|
||||||
springDocOpenAPI = "2.1.0"
|
springDocOpenAPI = "2.1.0"
|
||||||
lucene = "9.5.0"
|
lucene = "9.5.0"
|
||||||
|
resilience4jVersion = "2.0.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
javaPlatform {
|
javaPlatform {
|
||||||
|
@ -49,6 +50,8 @@ dependencies {
|
||||||
api "org.springframework.integration:spring-integration-core"
|
api "org.springframework.integration:spring-integration-core"
|
||||||
api "com.github.java-json-tools:json-patch:$jsonPatch"
|
api "com.github.java-json-tools:json-patch:$jsonPatch"
|
||||||
api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
|
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'
|
runtime 'org.mariadb:r2dbc-mariadb:1.1.4'
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue