-
Notifications
You must be signed in to change notification settings - Fork 296
修复事务实效,并新增rember me功能 #222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| 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); | ||
|
|
||
| private final Clock clock; | ||
|
|
||
| private final LocalCredentialRepository credentialRepository; | ||
|
|
||
| public LocalAuthFailedService(Clock clock, | ||
| LocalCredentialRepository credentialRepository | ||
| ){ | ||
| this.clock = clock; | ||
| this.credentialRepository = 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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,27 @@ 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 username from localStorage on mount | ||
| useEffect(() => { | ||
| const saved = localStorage.getItem(REMEMBER_ME_KEY) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. localStorage.getItem() 本身也可能抛出异常,例如存储被禁用或浏览器返回 SecurityError。当前 try/catch 只覆盖了 JSON.parse,没有覆盖读取动作本身。这里建议把整个读取过程都纳入异常处理,避免页面初始化阶段被存储异常中断。 |
||
| if (saved) { | ||
| try { | ||
| const { username: savedUsername } = JSON.parse(saved) | ||
| if (savedUsername) { | ||
| setUsername(savedUsername) | ||
| 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 +76,14 @@ export function LoginPage() { | |
| setFieldErrors({}) | ||
| try { | ||
| await loginMutation.mutateAsync({ username: trimmedUsername, password }) | ||
| // Save username to localStorage if remember me is checked | ||
| if (rememberMe) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 这里把 remember-me 的存储操作和登录成功后的跳转放在同一个 try/catch 中。setItem / removeItem 失败时,登录请求实际上已经成功,但页面会停留在当前页并进入错误分支。建议把存储失败与登录失败分开处理,至少不要让 localStorage 异常影响成功登录后的导航。 |
||
| localStorage.setItem(REMEMBER_ME_KEY, JSON.stringify({ | ||
| username: trimmedUsername | ||
| })) | ||
| } else { | ||
| localStorage.removeItem(REMEMBER_ME_KEY) | ||
| } | ||
| await navigate({ to: returnTo }) | ||
| } catch { | ||
| // mutation state drives the error UI | ||
|
|
@@ -107,6 +134,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 +155,7 @@ export function LoginPage() { | |
| <div className="relative"> | ||
| <Input | ||
| id="password" | ||
| name="password" | ||
| type={showPassword ? 'text' : 'password'} | ||
| autoComplete="current-password" | ||
| value={password} | ||
|
|
@@ -154,6 +183,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} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
这里把外层事务读取到的 LocalCredential 直接传入 REQUIRES_NEW 事务,并在内层通过 repository.save() 持久化。对已有 id 的实体,这里会走 merge,等价于把当前对象上的状态合并到内层持久化上下文。如果外层读取之后、内层提交之前有其他事务更新了 passwordHash、lockedUntil 等字段,这里存在覆盖这些更新的风险。建议在LocalAuthFailedService 内按 id 重新查询后只更新 failedAttempts / lockedUntil,或者改成定向 update。