mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
fix(security): throttle auth failures and sanitize security logs
This commit is contained in:
parent
39b0f3c852
commit
b44a7c0b35
14 changed files with 313 additions and 15 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=会话引导能力未启用
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue