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,56 @@
package com.iflytek.skillhub.auth.local;

import jakarta.annotation.Nonnull;
import jakarta.persistence.EntityNotFoundException;
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;

/**
* Handles failed login attempts for local credentials.
* @author zhaieryuan
*/
@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, rollbackFor = Exception.class)
public void handleFailedLogin(@Nonnull Long credentialId) {

LocalCredential credential = credentialRepository.findById(credentialId)
.orElseThrow(() -> new EntityNotFoundException("Invalid credential id"));

int failedAttempts = credential.getFailedAttempts() + 1;
Instant lockedUntil = credential.getLockedUntil();

if (failedAttempts >= MAX_FAILED_ATTEMPTS && lockedUntil == null) {
lockedUntil = currentTime().plus(LOCK_DURATION);
}

credentialRepository.updateFailedAttemptsAndLockedUntil(credentialId, failedAttempts, lockedUntil);
}

private Instant currentTime() {
return Instant.now(clock);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,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 +130,7 @@ public PlatformPrincipal login(String username, String password) {
ensureNotLocked(credential);

if (!passwordEncoder.matches(password, credential.getPasswordHash())) {
handleFailedLogin(credential);
localAuthFailedService.handleFailedLogin(credential.getId());
throw invalidCredentials();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.iflytek.skillhub.auth.local;

import java.time.Instant;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

/**
Expand All @@ -15,4 +18,20 @@ public interface LocalCredentialRepository extends JpaRepository<LocalCredential
Optional<LocalCredential> findByUserId(String userId);

boolean existsByUsernameIgnoreCase(String username);

/**
* 更新本地凭证的失败尝试次数和锁定截止时间。
*
* @param id 凭证ID
* @param failedAttempts 失败尝试次数
* @param lockedUntil 锁定截止时间,如果为null表示不锁定
*/
@Modifying
@Query("UPDATE LocalCredential c SET c.failedAttempts = :failedAttempts, c.lockedUntil = :lockedUntil WHERE c.id = :id")
void updateFailedAttemptsAndLockedUntil(
Long id,
int failedAttempts,
Instant lockedUntil
);

}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ class LocalAuthServiceTest {
@Mock
private PasswordEncoder passwordEncoder;

@Mock
private LocalAuthFailedService localAuthFailedService;

private LocalAuthService service;

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

Expand Down Expand Up @@ -122,8 +126,7 @@ void login_withInvalidPassword_incrementsCounter() {
.extracting("status")
.isEqualTo(HttpStatus.UNAUTHORIZED);

assertThat(credential.getFailedAttempts()).isEqualTo(1);
verify(credentialRepository).save(credential);
verify(localAuthFailedService).handleFailedLogin(credential.getId());
}

@Test
Expand All @@ -141,7 +144,7 @@ void login_afterMaxFailures_setsLockUsingInjectedClock() {
.extracting("status")
.isEqualTo(HttpStatus.UNAUTHORIZED);

assertThat(credential.getLockedUntil()).isEqualTo(Instant.now(CLOCK).plusSeconds(15 * 60));
verify(localAuthFailedService).handleFailedLogin(credential.getId());
}

@Test
Expand Down
Loading