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 "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'

View File

@ -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')

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 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);
} }
} }

View File

@ -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;
}
} }

View File

@ -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)); }
}
} }
} }

View File

@ -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

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.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.

View File

@ -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} 已存在,请更换用户名后重试。

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; 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"));
} }

View File

@ -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'
} }