Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Expand Up @@ -16,6 +16,8 @@
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;
Expand Down Expand Up @@ -45,20 +47,24 @@ public class LocalAuthService {
private final PasswordEncoder passwordEncoder;
private final Clock clock;

private final LocalAuthFailedService localAuthFailedService;

public LocalAuthService(LocalCredentialRepository credentialRepository,
UserAccountRepository userAccountRepository,
UserRoleBindingRepository userRoleBindingRepository,
GlobalNamespaceMembershipService globalNamespaceMembershipService,
PasswordPolicyValidator passwordPolicyValidator,
PasswordEncoder passwordEncoder,
Clock clock) {
Clock clock,
LocalAuthFailedService localAuthFailedService) {
this.credentialRepository = credentialRepository;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
this.passwordPolicyValidator = passwordPolicyValidator;
this.passwordEncoder = passwordEncoder;
this.clock = clock;
this.localAuthFailedService = localAuthFailedService;
}

/**
Expand Down Expand Up @@ -126,7 +132,7 @@ public PlatformPrincipal login(String username, String password) {
ensureNotLocked(credential);

if (!passwordEncoder.matches(password, credential.getPasswordHash())) {
handleFailedLogin(credential);
localAuthFailedService.handleFailedLogin(credential);
Copy link
Copy Markdown
Collaborator

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。

throw invalidCredentials();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
Expand Down Expand Up @@ -51,6 +52,9 @@ class LocalAuthServiceTest {
@Mock
private PasswordEncoder passwordEncoder;

@Mock
private LocalAuthFailedService localAuthFailedService;

private LocalAuthService service;

@BeforeEach
Expand All @@ -62,7 +66,8 @@ void setUp() {
globalNamespaceMembershipService,
new PasswordPolicyValidator(),
passwordEncoder,
CLOCK
CLOCK,
localAuthFailedService
);
}

Expand Down Expand Up @@ -117,6 +122,14 @@ void login_withInvalidPassword_incrementsCounter() {
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded")).willReturn(false);

// Mock handleFailedLogin to increment failedAttempts
doAnswer(invocation -> {
LocalCredential cred = invocation.getArgument(0);
cred.setFailedAttempts(cred.getFailedAttempts() + 1);
credentialRepository.save(cred);
return null;
}).when(localAuthFailedService).handleFailedLogin(any(LocalCredential.class));

assertThatThrownBy(() -> service.login("alice", "bad"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
Expand All @@ -136,12 +149,23 @@ void login_afterMaxFailures_setsLockUsingInjectedClock() {
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded")).willReturn(false);

// Mock handleFailedLogin to set lockedUntil using CLOCK
doAnswer(invocation -> {
LocalCredential cred = invocation.getArgument(0);
cred.setFailedAttempts(cred.getFailedAttempts() + 1);
cred.setLockedUntil(Instant.now(CLOCK).plus(java.time.Duration.ofMinutes(15)));
credentialRepository.save(cred);
return null;
}).when(localAuthFailedService).handleFailedLogin(any(LocalCredential.class));

assertThatThrownBy(() -> service.login("alice", "bad"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.UNAUTHORIZED);

assertThat(credential.getFailedAttempts()).isEqualTo(5);
assertThat(credential.getLockedUntil()).isEqualTo(Instant.now(CLOCK).plusSeconds(15 * 60));
verify(credentialRepository).save(credential);
}

@Test
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@
"password": "密码",
"usernamePlaceholder": "输入用户名",
"passwordPlaceholder": "输入密码",
"rememberMe": "记住我",
"usernameRequired": "请输入用户名",
"passwordRequired": "请输入密码",
"showPassword": "显示密码",
Expand Down
46 changes: 45 additions & 1 deletion web/src/pages/login.tsx
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'
Expand All @@ -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.
*
Expand All @@ -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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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) => {
Expand All @@ -127,6 +155,7 @@ export function LoginPage() {
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
value={password}
Expand Down Expand Up @@ -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}
Expand Down
Loading