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() {
{loginMutation.error.message}
) : null}