diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index 6bcba1a6..3fcaac46 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -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 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; + } + } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 602b6b5b..daea30e0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -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 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; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index e43c2997..a1694498 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -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 ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index 4987bf9d..44a7c626 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -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 body = apiResponseFactory.error(403, "error.forbidden"); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java index bac1e010..2611d3bf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java @@ -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 body = apiResponseFactory.error(401, "error.auth.required"); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java new file mode 100644 index 00000000..19997a4b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/AuthFailureThrottleService.java @@ -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(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java new file mode 100644 index 00000000..9b7efe73 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/SensitiveLogSanitizer.java @@ -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 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; + } +} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 857470b1..70e73e4d 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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 diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 5c74b625..0353c3d2 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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=会话引导能力未启用 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index b56befd2..1e127f64 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -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")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java index f36a3a8d..9f26e22f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java @@ -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"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java index e6298171..8ef61470 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -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( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index d88cba1e..c83c66fc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -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); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java new file mode 100644 index 00000000..f68e819e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/SensitiveLogSanitizerTest.java @@ -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]"); + } +}