Merge branch 'pr/20260402-bug-fix' into feature/20260402-sh-dev

* pr/20260402-bug-fix:
  feat : 1-新增登陆页面 rember me 功能,2-修复一个登陆过程中,事务实效的案例,原仓库代码,不会回滚事务
  fix : 修复swagger 接口文档权限未配置问题
This commit is contained in:
zhaieryuan 2026-04-03 09:31:26 +08:00
commit fa981f455c
6 changed files with 96 additions and 2 deletions

View file

@ -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);
}
}

View file

@ -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();
}

View file

@ -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"),

View file

@ -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",

View file

@ -203,6 +203,7 @@
"password": "密码",
"usernamePlaceholder": "输入用户名",
"passwordPlaceholder": "输入密码",
"rememberMe": "记住我",
"usernameRequired": "请输入用户名",
"passwordRequired": "请输入密码",
"showPassword": "显示密码",

View file

@ -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() {
<label className="text-sm font-medium" htmlFor="username">{t('login.username')}</label>
<Input
id="username"
name="username"
autoComplete="username"
value={username}
onChange={(event) => {
@ -127,6 +159,7 @@ export function LoginPage() {
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
value={password}
@ -154,6 +187,21 @@ export function LoginPage() {
<p className="text-sm text-red-600">{fieldErrors.password}</p>
) : null}
</div>
<div className="flex items-center space-x-2">
<input
id="rememberMe"
type="checkbox"
checked={rememberMe}
onChange={(e) => 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"
/>
<label
htmlFor="rememberMe"
className="text-sm font-medium cursor-pointer select-none"
>
{t('login.rememberMe')}
</label>
</div>
{loginMutation.error ? (
<p className="text-sm text-red-600">{loginMutation.error.message}</p>
) : null}