diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthFailedService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthFailedService.java new file mode 100644 index 00000000..a76690ac --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthFailedService.java @@ -0,0 +1,45 @@ +package com.iflytek.skillhub.auth.local; + +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; + +@Service +public class LocalAuthFailedService { + + private static final int MAX_FAILED_ATTEMPTS = 5; + private static final Duration LOCK_DURATION = Duration.ofMinutes(15); + + private final Clock clock; + + private final LocalCredentialRepository credentialRepository; + + public LocalAuthFailedService(Clock clock, + LocalCredentialRepository credentialRepository + ){ + this.clock = clock; + this.credentialRepository = credentialRepository; + } + + + + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleFailedLogin(LocalCredential credential) { + int failedAttempts = credential.getFailedAttempts() + 1; + credential.setFailedAttempts(failedAttempts); + if (failedAttempts >= MAX_FAILED_ATTEMPTS) { + credential.setLockedUntil(currentTime().plus(LOCK_DURATION)); + } + credentialRepository.save(credential); + } + + private Instant currentTime() { + return Instant.now(clock); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index 9d378e25..306d7c46 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -16,6 +16,8 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; + +import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -45,13 +47,16 @@ public class LocalAuthService { private final PasswordEncoder passwordEncoder; private final Clock clock; + private final LocalAuthFailedService localAuthFailedService; + public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + LocalAuthFailedService localAuthFailedService) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -59,6 +64,7 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.localAuthFailedService = localAuthFailedService; } /** @@ -126,7 +132,7 @@ public PlatformPrincipal login(String username, String password) { ensureNotLocked(credential); if (!passwordEncoder.matches(password, credential.getPasswordHash())) { - handleFailedLogin(credential); + localAuthFailedService.handleFailedLogin(credential); throw invalidCredentials(); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 498d2010..efbe121e 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -31,6 +31,7 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(null, "/actuator/health"), RouteAuthorizationPolicy.permitAll(null, "/v3/api-docs/**"), RouteAuthorizationPolicy.permitAll(null, "/swagger-ui/**"), + RouteAuthorizationPolicy.permitAll(null, "/swagger-ui.html"), RouteAuthorizationPolicy.permitAll(null, "/.well-known/**"), RouteAuthorizationPolicy.roles(null, "/actuator/prometheus", "SUPER_ADMIN", "AUDITOR"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/star"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index b566b922..72bc728b 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -51,6 +52,9 @@ class LocalAuthServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private LocalAuthFailedService localAuthFailedService; + private LocalAuthService service; @BeforeEach @@ -62,7 +66,8 @@ void setUp() { globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + localAuthFailedService ); } @@ -117,6 +122,14 @@ void login_withInvalidPassword_incrementsCounter() { given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); given(passwordEncoder.matches("bad", "encoded")).willReturn(false); + // Mock handleFailedLogin to increment failedAttempts + doAnswer(invocation -> { + LocalCredential cred = invocation.getArgument(0); + cred.setFailedAttempts(cred.getFailedAttempts() + 1); + credentialRepository.save(cred); + return null; + }).when(localAuthFailedService).handleFailedLogin(any(LocalCredential.class)); + assertThatThrownBy(() -> service.login("alice", "bad")) .isInstanceOf(AuthFlowException.class) .extracting("status") @@ -136,12 +149,23 @@ void login_afterMaxFailures_setsLockUsingInjectedClock() { given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); given(passwordEncoder.matches("bad", "encoded")).willReturn(false); + // Mock handleFailedLogin to set lockedUntil using CLOCK + doAnswer(invocation -> { + LocalCredential cred = invocation.getArgument(0); + cred.setFailedAttempts(cred.getFailedAttempts() + 1); + cred.setLockedUntil(Instant.now(CLOCK).plus(java.time.Duration.ofMinutes(15))); + credentialRepository.save(cred); + return null; + }).when(localAuthFailedService).handleFailedLogin(any(LocalCredential.class)); + assertThatThrownBy(() -> service.login("alice", "bad")) .isInstanceOf(AuthFlowException.class) .extracting("status") .isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(credential.getFailedAttempts()).isEqualTo(5); assertThat(credential.getLockedUntil()).isEqualTo(Instant.now(CLOCK).plusSeconds(15 * 60)); + verify(credentialRepository).save(credential); } @Test diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b1e35669..88ba08ad 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -203,6 +203,7 @@ "password": "Password", "usernamePlaceholder": "Enter username", "passwordPlaceholder": "Enter password", + "rememberMe": "Remember me", "usernameRequired": "Username is required", "passwordRequired": "Password is required", "showPassword": "Show password", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 41972c55..cec0f310 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -203,6 +203,7 @@ "password": "密码", "usernamePlaceholder": "输入用户名", "passwordPlaceholder": "输入密码", + "rememberMe": "记住我", "usernameRequired": "请输入用户名", "passwordRequired": "请输入密码", "showPassword": "显示密码", diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index a23d0e3e..b54bd808 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Eye, EyeOff } from 'lucide-react' import { getDirectAuthRuntimeConfig } from '@/api/client' @@ -11,6 +11,8 @@ import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +const REMEMBER_ME_KEY = 'skillhub.remember-me' + /** * Authentication entry page. * @@ -26,10 +28,27 @@ export function LoginPage() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) + const [rememberMe, setRememberMe] = useState(false) const [fieldErrors, setFieldErrors] = useState<{ username?: string, password?: string }>({}) const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh' const { data: authMethods } = useAuthMethods(search.returnTo) + // Load saved username from localStorage on mount + useEffect(() => { + const saved = localStorage.getItem(REMEMBER_ME_KEY) + if (saved) { + try { + const { username: savedUsername } = JSON.parse(saved) + if (savedUsername) { + setUsername(savedUsername) + setRememberMe(true) + } + } catch { + // Invalid data, ignore + } + } + }, []) + const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' const disabledMessage = search.reason === 'accountDisabled' ? t('apiError.auth.accountDisabled') : null const directMethod = directAuthConfig.provider @@ -57,6 +76,14 @@ export function LoginPage() { setFieldErrors({}) try { await loginMutation.mutateAsync({ username: trimmedUsername, password }) + // Save username to localStorage if remember me is checked + if (rememberMe) { + localStorage.setItem(REMEMBER_ME_KEY, JSON.stringify({ + username: trimmedUsername + })) + } else { + localStorage.removeItem(REMEMBER_ME_KEY) + } await navigate({ to: returnTo }) } catch { // mutation state drives the error UI @@ -107,6 +134,7 @@ export function LoginPage() { { @@ -127,6 +155,7 @@ export function LoginPage() {
{loginMutation.error.message}
) : null}