From f3206f2ded742dfaf52427797438bca7f17a03af Mon Sep 17 00:00:00 2001 From: zhaieryuan Date: Thu, 2 Apr 2026 10:35:09 +0800 Subject: [PATCH 1/2] =?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/2] =?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.Set; 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 class LocalAuthService { 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}