From f3206f2ded742dfaf52427797438bca7f17a03af Mon Sep 17 00:00:00 2001 From: zhaieryuan Date: Thu, 2 Apr 2026 10:35:09 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix=20=EF=BC=9A=20=E4=BF=AE=E5=A4=8Dswagger?= =?UTF-8?q?=20=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=9C=AA=E9=85=8D=E7=BD=AE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skillhub/auth/policy/RouteSecurityPolicyRegistry.java | 1 + 1 file changed, 1 insertion(+) 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"), From 1a644081a3dd2bbfee13d9c62314ed9579b5524d Mon Sep 17 00:00:00 2001 From: zhaieryuan Date: Fri, 3 Apr 2026 09:26:00 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat=20:=201-=E6=96=B0=E5=A2=9E=E7=99=BB?= =?UTF-8?q?=E9=99=86=E9=A1=B5=E9=9D=A2=20rember=20me=20=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C2-=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=B8=AA=E7=99=BB?= =?UTF-8?q?=E9=99=86=E8=BF=87=E7=A8=8B=E4=B8=AD=EF=BC=8C=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E5=AE=9E=E6=95=88=E7=9A=84=E6=A1=88=E4=BE=8B=EF=BC=8C=E5=8E=9F?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E4=BB=A3=E7=A0=81=EF=BC=8C=E4=B8=8D=E4=BC=9A?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E4=BA=8B=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/local/LocalAuthFailedService.java | 38 ++++++++++++++ .../skillhub/auth/local/LocalAuthService.java | 7 ++- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/pages/login.tsx | 50 ++++++++++++++++++- 5 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthFailedService.java 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..69dcf45c --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthFailedService.java @@ -0,0 +1,38 @@ +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); + + @Resource + private Clock clock; + + @Resource + private LocalCredentialRepository 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..e7fb197b 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,6 +47,9 @@ public class LocalAuthService { private final PasswordEncoder passwordEncoder; private final Clock clock; + @Resource + private LocalAuthFailedService localAuthFailedService; + public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, @@ -126,7 +131,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/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..35f25e5f 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,30 @@ 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 credentials from localStorage on mount + useEffect(() => { + const saved = localStorage.getItem(REMEMBER_ME_KEY) + if (saved) { + try { + const { username: savedUsername, password: savedPassword } = JSON.parse(saved) + if (savedUsername) { + setUsername(savedUsername) + } + if (savedPassword) { + setPassword(savedPassword) + } + 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 +79,15 @@ export function LoginPage() { setFieldErrors({}) try { await loginMutation.mutateAsync({ username: trimmedUsername, password }) + // Save credentials to localStorage if remember me is checked + if (rememberMe) { + localStorage.setItem(REMEMBER_ME_KEY, JSON.stringify({ + username: trimmedUsername, + password + })) + } else { + localStorage.removeItem(REMEMBER_ME_KEY) + } await navigate({ to: returnTo }) } catch { // mutation state drives the error UI @@ -107,6 +138,7 @@ export function LoginPage() { { @@ -127,6 +159,7 @@ export function LoginPage() {
{fieldErrors.password}

) : null}
+
+ setRememberMe(e.target.checked)} + className="h-4 w-4 rounded border-input bg-background text-primary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 cursor-pointer" + /> + +
{loginMutation.error ? (

{loginMutation.error.message}

) : null} From c056735a9606fd529a52586d3502547a453b7e08 Mon Sep 17 00:00:00 2001 From: zhaieryuan Date: Fri, 3 Apr 2026 11:42:35 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat=20=EF=BC=9A=20remember=20me=20?= =?UTF-8?q?=E4=B8=8D=E8=83=BD=E6=8A=8A=E6=98=8E=E6=96=87=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E5=AD=98=E8=BF=9B=20localStorage=EF=BC=8C=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=20LocalAuthFailedService=20=E6=9E=84=E9=80=A0=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=EF=BC=8C=20=E5=B9=B6=E8=A1=A5=E5=85=85=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=8C=20fix=20=20LocalAuthServic?= =?UTF-8?q?eTest=20=E6=89=93=E6=8C=82=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/local/LocalAuthFailedService.java | 15 ++++++++--- .../skillhub/auth/local/LocalAuthService.java | 7 ++--- .../auth/local/LocalAuthServiceTest.java | 26 ++++++++++++++++++- web/src/pages/login.tsx | 14 ++++------ 4 files changed, 45 insertions(+), 17 deletions(-) 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 index 69dcf45c..a76690ac 100644 --- 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 @@ -15,11 +15,18 @@ public class LocalAuthFailedService { private static final int MAX_FAILED_ATTEMPTS = 5; private static final Duration LOCK_DURATION = Duration.ofMinutes(15); - @Resource - private Clock clock; + private final Clock clock; + + private final LocalCredentialRepository credentialRepository; + + public LocalAuthFailedService(Clock clock, + LocalCredentialRepository credentialRepository + ){ + this.clock = clock; + this.credentialRepository = credentialRepository; + } + - @Resource - private LocalCredentialRepository credentialRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) 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 e7fb197b..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 @@ -47,8 +47,7 @@ public class LocalAuthService { private final PasswordEncoder passwordEncoder; private final Clock clock; - @Resource - private LocalAuthFailedService localAuthFailedService; + private final LocalAuthFailedService localAuthFailedService; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, @@ -56,7 +55,8 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + LocalAuthFailedService localAuthFailedService) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -64,6 +64,7 @@ public LocalAuthService(LocalCredentialRepository credentialRepository, this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.localAuthFailedService = localAuthFailedService; } /** 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/pages/login.tsx b/web/src/pages/login.tsx index 35f25e5f..b54bd808 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -33,19 +33,16 @@ export function LoginPage() { const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh' const { data: authMethods } = useAuthMethods(search.returnTo) - // Load saved credentials from localStorage on mount + // Load saved username from localStorage on mount useEffect(() => { const saved = localStorage.getItem(REMEMBER_ME_KEY) if (saved) { try { - const { username: savedUsername, password: savedPassword } = JSON.parse(saved) + const { username: savedUsername } = JSON.parse(saved) if (savedUsername) { setUsername(savedUsername) + setRememberMe(true) } - if (savedPassword) { - setPassword(savedPassword) - } - setRememberMe(true) } catch { // Invalid data, ignore } @@ -79,11 +76,10 @@ export function LoginPage() { setFieldErrors({}) try { await loginMutation.mutateAsync({ username: trimmedUsername, password }) - // Save credentials to localStorage if remember me is checked + // Save username to localStorage if remember me is checked if (rememberMe) { localStorage.setItem(REMEMBER_ME_KEY, JSON.stringify({ - username: trimmedUsername, - password + username: trimmedUsername })) } else { localStorage.removeItem(REMEMBER_ME_KEY)