mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-03 02:24:36 +00:00
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:
commit
fa981f455c
6 changed files with 96 additions and 2 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@
|
|||
"password": "密码",
|
||||
"usernamePlaceholder": "输入用户名",
|
||||
"passwordPlaceholder": "输入密码",
|
||||
"rememberMe": "记住我",
|
||||
"usernameRequired": "请输入用户名",
|
||||
"passwordRequired": "请输入密码",
|
||||
"showPassword": "显示密码",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue