HybridLoginAttemptStore.java
package org.darkroomlibrary.infrastructure.security;
import org.darkroomlibrary.infrastructure.cache.CacheService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Component
public class HybridLoginAttemptStore implements LoginAttemptStore {
private static final String COUNT_PREFIX = "login:fail-count:";
private static final String LOCK_PREFIX = "login:locked-until:";
@Resource
private CacheService cacheService;
private final Map<String, AttemptEntry> attemptMap = new ConcurrentHashMap<>();
@Override
public void loginFailed(String account, int maxFailAttempts, int lockDurationMinutes) {
long now = System.currentTimeMillis();
long lockMillis = lockDurationMinutes * 60L * 1000;
Optional<Long> redisCount = cacheService.increment(
COUNT_PREFIX + account,
Duration.ofMillis(lockMillis)
);
if (redisCount.isPresent()) {
attemptMap.remove(account);
if (redisCount.get() >= maxFailAttempts) {
boolean stored = cacheService.setString(
LOCK_PREFIX + account,
String.valueOf(now + lockMillis),
Duration.ofMillis(lockMillis));
if (!stored) {
lockLocally(account, maxFailAttempts, now, lockMillis);
}
log.warn("账户 {} 已被锁定,失败次数: {}", account, redisCount.get());
}
return;
}
AttemptEntry entry = attemptMap.computeIfAbsent(account, k -> new AttemptEntry());
synchronized (entry) {
if (entry.lastFailTime > 0 && now - entry.lastFailTime > lockMillis) {
entry.failCount = 0;
entry.lockedUntil = null;
}
entry.failCount++;
entry.lastFailTime = now;
entry.expireAfterMillis = lockMillis;
if (entry.failCount >= maxFailAttempts) {
entry.lockedUntil = now + lockMillis;
}
}
if (entry.failCount >= maxFailAttempts) {
log.warn("账户 {} 已被锁定,失败次数: {}", account, entry.failCount);
}
}
@Override
public void loginSucceeded(String account) {
attemptMap.remove(account);
cacheService.delete(COUNT_PREFIX + account);
cacheService.delete(LOCK_PREFIX + account);
}
@Override
public boolean isBlocked(String account) {
Long lockedUntil = getLockedUntil(account);
if (lockedUntil == null) {
return false;
}
if (System.currentTimeMillis() > lockedUntil) {
loginSucceeded(account);
return false;
}
return true;
}
@Override
public long getRemainingLockSeconds(String account) {
Long lockedUntil = getLockedUntil(account);
if (lockedUntil == null) {
return 0;
}
long remaining = lockedUntil - System.currentTimeMillis();
return remaining > 0 ? remaining / 1000 : 0;
}
@Override
public void clearExpired() {
long now = System.currentTimeMillis();
attemptMap.entrySet().removeIf(entry -> {
AttemptEntry val = entry.getValue();
if (val.lockedUntil != null) {
return now > val.lockedUntil;
}
return val.lastFailTime > 0 && val.expireAfterMillis > 0
&& now - val.lastFailTime > val.expireAfterMillis;
});
}
private Long getLockedUntil(String account) {
Optional<String> redisValue = cacheService.getString(LOCK_PREFIX + account);
if (redisValue.isPresent()) {
try {
return Long.parseLong(redisValue.get());
} catch (NumberFormatException ignored) {
return null;
}
}
AttemptEntry entry = attemptMap.get(account);
return entry == null ? null : entry.lockedUntil;
}
private void lockLocally(String account, int failCount, long now, long lockMillis) {
AttemptEntry entry = attemptMap.computeIfAbsent(account, k -> new AttemptEntry());
synchronized (entry) {
entry.failCount = failCount;
entry.lastFailTime = now;
entry.expireAfterMillis = lockMillis;
entry.lockedUntil = now + lockMillis;
}
}
private static class AttemptEntry {
int failCount;
long lastFailTime;
long expireAfterMillis;
Long lockedUntil;
}
}