fix(security): throttle auth failures and sanitize security logs

This commit is contained in:
yun-zhi-ztl 2026-03-15 17:44:54 +08:00
parent 39b0f3c852
commit b44a7c0b35
14 changed files with 313 additions and 15 deletions

View file

@ -8,12 +8,15 @@ import com.iflytek.skillhub.dto.AuthMethodResponse;
import com.iflytek.skillhub.dto.AuthProviderResponse;
import com.iflytek.skillhub.dto.DirectLoginRequest;
import com.iflytek.skillhub.dto.SessionBootstrapRequest;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.service.AuthMethodCatalog;
import com.iflytek.skillhub.service.DirectAuthService;
import com.iflytek.skillhub.service.SessionBootstrapService;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.iflytek.skillhub.exception.UnauthorizedException;
@ -32,15 +35,18 @@ public class AuthController extends BaseApiController {
private final AuthMethodCatalog authMethodCatalog;
private final SessionBootstrapService sessionBootstrapService;
private final DirectAuthService directAuthService;
private final AuthFailureThrottleService authFailureThrottleService;
public AuthController(ApiResponseFactory responseFactory,
AuthMethodCatalog authMethodCatalog,
SessionBootstrapService sessionBootstrapService,
DirectAuthService directAuthService) {
DirectAuthService directAuthService,
AuthFailureThrottleService authFailureThrottleService) {
super(responseFactory);
this.authMethodCatalog = authMethodCatalog;
this.sessionBootstrapService = sessionBootstrapService;
this.directAuthService = directAuthService;
this.authFailureThrottleService = authFailureThrottleService;
}
@GetMapping("/me")
@ -78,17 +84,42 @@ public class AuthController extends BaseApiController {
@RateLimit(category = "auth-direct-login", authenticated = 20, anonymous = 10, windowSeconds = 60)
public ApiResponse<AuthMeResponse> directLogin(@Valid @RequestBody DirectLoginRequest request,
HttpServletRequest httpRequest) {
return ok(
"response.success.read",
AuthMeResponse.from(
directAuthService.authenticate(
String category = "direct:" + request.provider();
String clientIp = resolveClientIp(httpRequest);
authFailureThrottleService.assertAllowed(category, request.username(), clientIp);
PlatformPrincipal principal;
try {
principal = directAuthService.authenticate(
request.provider(),
request.username(),
request.password(),
httpRequest
)
)
);
} catch (AuthFlowException ex) {
if (HttpStatus.UNAUTHORIZED.equals(ex.getStatus())) {
authFailureThrottleService.recordFailure(category, request.username(), clientIp);
}
throw ex;
}
authFailureThrottleService.resetIdentifier(category, request.username());
return ok(
"response.success.read",
AuthMeResponse.from(principal)
);
}
private String resolveClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import com.iflytek.skillhub.dto.ApiResponse;
@ -12,8 +13,10 @@ import com.iflytek.skillhub.dto.LocalRegisterRequest;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@ -27,15 +30,18 @@ public class LocalAuthController extends BaseApiController {
private final LocalAuthService localAuthService;
private final SkillHubMetrics skillHubMetrics;
private final PlatformSessionService platformSessionService;
private final AuthFailureThrottleService authFailureThrottleService;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
SkillHubMetrics skillHubMetrics,
PlatformSessionService platformSessionService) {
PlatformSessionService platformSessionService,
AuthFailureThrottleService authFailureThrottleService) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
this.platformSessionService = platformSessionService;
this.authFailureThrottleService = authFailureThrottleService;
}
@PostMapping("/register")
@ -52,13 +58,21 @@ public class LocalAuthController extends BaseApiController {
@RateLimit(category = "auth-local-login", authenticated = 20, anonymous = 10, windowSeconds = 60)
public ApiResponse<AuthMeResponse> login(@Valid @RequestBody LocalLoginRequest request,
HttpServletRequest httpRequest) {
authFailureThrottleService.assertAllowed("local", request.username(), resolveClientIp(httpRequest));
PlatformPrincipal principal;
try {
principal = localAuthService.login(request.username(), request.password());
} catch (AuthFlowException ex) {
if (HttpStatus.UNAUTHORIZED.equals(ex.getStatus())) {
authFailureThrottleService.recordFailure("local", request.username(), resolveClientIp(httpRequest));
}
skillHubMetrics.recordLocalLogin(false);
throw ex;
} catch (RuntimeException ex) {
skillHubMetrics.recordLocalLogin(false);
throw ex;
}
authFailureThrottleService.resetIdentifier("local", request.username());
skillHubMetrics.recordLocalLogin(true);
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.read", AuthMeResponse.from(principal));
@ -74,4 +88,18 @@ public class LocalAuthController extends BaseApiController {
localAuthService.changePassword(principal.userId(), request.currentPassword(), request.newPassword());
return ok("response.success.updated", null);
}
private String resolveClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View file

@ -7,6 +7,7 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -25,9 +26,12 @@ public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory) {
public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
}
@ExceptionHandler(LocalizedException.class)
@ -110,7 +114,7 @@ public class GlobalExceptionHandler {
"Unhandled API exception [requestId={}, method={}, path={}, userId={}]",
MDC.get("requestId"),
request.getMethod(),
request.getRequestURI(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
resolveUserId(request),
ex
);
@ -124,7 +128,7 @@ public class GlobalExceptionHandler {
MDC.get("requestId"),
status.value(),
request.getMethod(),
request.getRequestURI(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
resolveUserId(request),
messageCode
);

View file

@ -21,10 +21,14 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
private static final Logger logger = LoggerFactory.getLogger(ApiAccessDeniedHandler.class);
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
public ApiAccessDeniedHandler(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) {
public ApiAccessDeniedHandler(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
}
@Override
@ -35,7 +39,7 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
"Forbidden API request [requestId={}, method={}, path={}, reason={}]",
MDC.get("requestId"),
request.getMethod(),
request.getRequestURI(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
accessDeniedException.getClass().getSimpleName()
);
ApiResponse<Void> body = apiResponseFactory.error(403, "error.forbidden");

View file

@ -21,10 +21,14 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(ApiAuthenticationEntryPoint.class);
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
public ApiAuthenticationEntryPoint(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory) {
public ApiAuthenticationEntryPoint(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
}
@Override
@ -35,7 +39,7 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
"Unauthorized API request [requestId={}, method={}, path={}, reason={}]",
MDC.get("requestId"),
request.getMethod(),
request.getRequestURI(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
authException.getClass().getSimpleName()
);
ApiResponse<Void> body = apiResponseFactory.error(401, "error.auth.required");

View file

@ -0,0 +1,99 @@
package com.iflytek.skillhub.security;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import java.time.Duration;
import java.util.Locale;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class AuthFailureThrottleService {
private static final Duration WINDOW = Duration.ofMinutes(15);
private static final int IDENTIFIER_LIMIT = 8;
private static final int IP_LIMIT = 30;
private final StringRedisTemplate redisTemplate;
public AuthFailureThrottleService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void assertAllowed(String category, String identifier, String clientIp) {
if (isLimited(identifierKey(category, identifier), IDENTIFIER_LIMIT)
|| isLimited(ipKey(category, clientIp), IP_LIMIT)) {
throw new AuthFlowException(HttpStatus.TOO_MANY_REQUESTS, "error.auth.login.throttled", remainingMinutes(category, identifier, clientIp));
}
}
public void recordFailure(String category, String identifier, String clientIp) {
increment(identifierKey(category, identifier));
increment(ipKey(category, clientIp));
}
public void resetIdentifier(String category, String identifier) {
String key = identifierKey(category, identifier);
if (key != null) {
redisTemplate.delete(key);
}
}
private boolean isLimited(String key, int limit) {
if (key == null) {
return false;
}
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
return false;
}
try {
return Integer.parseInt(value) >= limit;
} catch (NumberFormatException ignored) {
redisTemplate.delete(key);
return false;
}
}
private void increment(String key) {
if (key == null) {
return;
}
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
redisTemplate.expire(key, WINDOW);
}
}
private long remainingMinutes(String category, String identifier, String clientIp) {
long identifierMinutes = remainingMinutes(identifierKey(category, identifier));
long ipMinutes = remainingMinutes(ipKey(category, clientIp));
return Math.max(1, Math.max(identifierMinutes, ipMinutes));
}
private long remainingMinutes(String key) {
if (key == null) {
return 1;
}
Long seconds = redisTemplate.getExpire(key);
if (seconds == null || seconds <= 0) {
return 1;
}
return Math.max(1, (seconds + 59) / 60);
}
private String identifierKey(String category, String identifier) {
if (!StringUtils.hasText(identifier)) {
return null;
}
return "auth-failure:" + category + ":id:" + identifier.trim().toLowerCase(Locale.ROOT);
}
private String ipKey(String category, String clientIp) {
if (!StringUtils.hasText(clientIp)) {
return null;
}
return "auth-failure:" + category + ":ip:" + clientIp.trim();
}
}

View file

@ -0,0 +1,45 @@
package com.iflytek.skillhub.security;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Component
public class SensitiveLogSanitizer {
private static final Set<String> SENSITIVE_KEYS = Set.of(
"password", "passwd", "pwd", "token", "authorization", "cookie",
"secret", "api_key", "apikey", "access_key", "refresh_token", "code");
public String sanitizeRequestTarget(HttpServletRequest request) {
String uri = request.getRequestURI();
String query = request.getQueryString();
if (!StringUtils.hasText(query)) {
return uri;
}
return uri + "?" + sanitizeQuery(query);
}
String sanitizeQuery(String query) {
return Arrays.stream(query.split("&"))
.map(this::sanitizeQueryPart)
.collect(Collectors.joining("&"));
}
private String sanitizeQueryPart(String queryPart) {
int idx = queryPart.indexOf('=');
if (idx < 0) {
return queryPart;
}
String key = queryPart.substring(0, idx);
String normalizedKey = key.trim().toLowerCase(Locale.ROOT);
if (SENSITIVE_KEYS.contains(normalizedKey)) {
return key + "=[REDACTED]";
}
return queryPart;
}
}

View file

@ -39,6 +39,7 @@ error.auth.local.accountDisabled=This account has been disabled
error.auth.local.accountPending=This account is pending activation
error.auth.local.accountMerged=This account has been merged and can no longer be used to log in
error.auth.local.locked=Too many failed attempts. Please try again in {0} minute(s)
error.auth.login.throttled=Too many login attempts. Please try again in {0} minute(s)
error.auth.direct.disabled=Direct authentication compatibility is disabled
error.auth.direct.providerUnsupported=Unsupported direct authentication provider: {0}
error.auth.sessionBootstrap.disabled=Session bootstrap is disabled

View file

@ -39,6 +39,7 @@ error.auth.local.accountDisabled=该账号已被禁用
error.auth.local.accountPending=该账号尚未激活
error.auth.local.accountMerged=该账号已合并,不能再用于登录
error.auth.local.locked=连续失败次数过多,请在 {0} 分钟后重试
error.auth.login.throttled=登录尝试过于频繁,请在 {0} 分钟后重试
error.auth.direct.disabled=直连认证兼容层未启用
error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0}
error.auth.sessionBootstrap.disabled=会话引导能力未启用

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@ -51,6 +52,9 @@ class AuthControllerTest {
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private AuthFailureThrottleService authFailureThrottleService;
@Test
void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
mockMvc.perform(get("/api/v1/auth/me"))

View file

@ -1,9 +1,12 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimiter;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@ -43,6 +46,9 @@ class AuthRateLimitControllerTest {
@MockBean
private RateLimiter rateLimiter;
@MockBean
private AuthFailureThrottleService authFailureThrottleService;
@Test
void localLoginShouldReturnTooManyRequestsWhenRateLimitIsExceeded() throws Exception {
given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(false);
@ -59,4 +65,45 @@ class AuthRateLimitControllerTest {
verify(localAuthService, never()).login(anyString(), anyString());
}
@Test
void localLoginShouldRecordCredentialFailuresForBruteForceTracking() throws Exception {
given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true);
given(localAuthService.login("alice", "wrong"))
.willThrow(new AuthFlowException(org.springframework.http.HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials"));
mockMvc.perform(post("/api/v1/auth/local/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"wrong"}
"""))
.andExpect(status().isUnauthorized());
verify(authFailureThrottleService).assertAllowed("local", "alice", "127.0.0.1");
verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1");
}
@Test
void localLoginShouldResetIdentifierThrottleAfterSuccess() throws Exception {
given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true);
given(localAuthService.login("alice", "correct")).willReturn(new PlatformPrincipal(
"usr_1",
"alice",
"alice@example.com",
"",
"local",
java.util.Set.of("USER")
));
mockMvc.perform(post("/api/v1/auth/local/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"correct"}
"""))
.andExpect(status().isOk());
verify(authFailureThrottleService).resetIdentifier("local", "alice");
}
}

View file

@ -10,6 +10,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
@ -39,6 +40,9 @@ class DirectAuthControllerTest {
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private AuthFailureThrottleService authFailureThrottleService;
@Test
void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(

View file

@ -15,6 +15,7 @@ import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
@ -46,6 +47,9 @@ class LocalAuthControllerTest {
@MockBean
private SkillHubMetrics skillHubMetrics;
@MockBean
private AuthFailureThrottleService authFailureThrottleService;
@Test
void login_returnsCurrentUserEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
@ -70,6 +74,7 @@ class LocalAuthControllerTest {
.andExpect(jsonPath("$.data.oauthProvider").value("local"));
verify(skillHubMetrics).recordLocalLogin(true);
verify(skillHubMetrics, never()).recordLocalLogin(false);
verify(authFailureThrottleService).resetIdentifier("local", "alice");
}
@Test
@ -125,6 +130,7 @@ class LocalAuthControllerTest {
{"username":"alice","password":"wrong"}
"""))
.andExpect(status().isUnauthorized());
verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1");
verify(skillHubMetrics).recordLocalLogin(false);
verify(skillHubMetrics, never()).recordLocalLogin(true);
}

View file

@ -0,0 +1,20 @@
package com.iflytek.skillhub.security;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class SensitiveLogSanitizerTest {
private final SensitiveLogSanitizer sanitizer = new SensitiveLogSanitizer();
@Test
void shouldRedactSensitiveQueryParameters() {
String sanitized = sanitizer.sanitizeQuery("returnTo=%2Fdashboard&token=abc123&password=secret&code=xyz");
assertThat(sanitized).contains("returnTo=%2Fdashboard");
assertThat(sanitized).contains("token=[REDACTED]");
assertThat(sanitized).contains("password=[REDACTED]");
assertThat(sanitized).contains("code=[REDACTED]");
}
}