Java 中實現(xiàn)用戶登錄次數(shù)限制的多種方案
前言
在開發(fā)應(yīng)用程序時,為了保障系統(tǒng)的安全性,我們常常需要對用戶的登錄行為進行限制,比如規(guī)定用戶在 5 分鐘內(nèi)最多允許嘗試登錄 3 次,如果超過次數(shù),就鎖定當前用戶。今天我們就來探討幾種在 Java 中實現(xiàn)這一功能的方案,讓我們的系統(tǒng)更加安全可靠。
使用 HashMap 和 Timer 實現(xiàn)
首先,我們創(chuàng)建一個 HashMap 來存儲用戶登錄失敗的信息。這個 HashMap 中的鍵是用戶名,而值是一個自定義的 LoginAttempt 對象,它包含登錄失敗次數(shù)和最近一次失敗時間。當用戶進行登錄操作時,我們會檢查 HashMap 中是否存在該用戶的記錄。若存在,會查看是否超過 5 分鐘,如果超過,我們將重置失敗次數(shù);如果未超過且次數(shù)已達 3 次,將拒絕登錄。同時,我們使用 Timer 來清理過期的記錄。
示例
public class LoginAttempt {
int attempts;
long lastAttemptTime;
public LoginAttempt() {
this.attempts = 0;
this.lastAttemptTime = System.currentTimeMillis();
}
}
public class LoginService {
private static final Map<String, LoginAttempt> loginAttempts = new HashMap<>();
private static final long LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
private static final int MAX_ATTEMPTS = 3;
public static boolean isLoginAllowed(String username) {
LoginAttempt attempt = loginAttempts.get(username);
if (attempt == null) {
return true;
}
long currentTime = System.currentTimeMillis();
if (currentTime - attempt.lastAttemptTime > LOCKOUT_DURATION) {
loginAttempts.remove(username);
return true;
}
if (attempt.attempts >= MAX_ATTEMPTS) {
return false;
}
return true;
}
public static void recordFailedLogin(String username) {
LoginAttempt attempt = loginAttempts.get(username);
if (attempt == null) {
attempt = new LoginAttempt();
loginAttempts.put(username, attempt);
}
attempt.attempts++;
attempt.lastAttemptTime = System.currentTimeMillis();
}
public static void main(String[] args) {
Timer timer = new Timer();
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
long currentTime = System.currentTimeMillis();
loginAttempts.entrySet().removeIf(entry -> currentTime - entry.getValue().lastAttemptTime > LOCKOUT_DURATION);
}
}, LOCKOUT_DURATION, LOCKOUT_DURATION);
// 模擬登錄嘗試
String username = "testUser";
for (int i = 0; i < 5; i++) {
if (isLoginAllowed(username)) {
System.out.println("Login allowed");
// 模擬登錄成功,重置嘗試次數(shù)
loginAttempts.remove(username);
} else {
System.out.println("Login not allowed, user locked");
}
recordFailedLogin(username);
}
}
}
使用 ConcurrentHashMap 和 ScheduledExecutorService 實現(xiàn)
這里我們使用 ConcurrentHashMap 來存儲用戶登錄失敗信息,它支持并發(fā)訪問。并且使用 ScheduledExecutorService 來進行定時清理過期記錄,避免 Timer 的一些潛在問題,如可能導致的內(nèi)存泄漏,更適合高并發(fā)場景。
示例
public class LoginAttempt {
int attempts;
long lastAttemptTime;
public LoginAttempt() {
this.attempts = 0;
this.lastAttemptTime = System.currentTimeMillis();
}
}
public class LoginService {
private static final ConcurrentHashMap<String, LoginAttempt> loginAttempts = new ConcurrentHashMap<>();
private static final long LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
private static final int MAX_ATTEMPTS = 3;
private static final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
static {
executorService.scheduleAtFixedRate(() -> {
long currentTime = System.currentTimeMillis();
loginAttempts.entrySet().removeIf(entry -> currentTime - entry.getValue().lastAttemptTime > LOCKOUT_DURATION);
}, LOCKOUT_DURATION, LOCKOUT_DURATION, TimeUnit.MILLISECONDS);
}
public static boolean isLoginAllowed(String username) {
LoginAttempt attempt = loginAttempts.get(username);
if (attempt == null) {
return true;
}
long currentTime = System.currentTimeMillis();
if (currentTime - attempt.lastAttemptTime > LOCKOUT_DURATION) {
loginAttempts.remove(username);
return true;
}
if (attempt.attempts >= MAX_ATTEMPTS) {
return false;
}
return true;
}
public static void recordFailedLogin(String username) {
loginAttempts.compute(username, (key, value) -> {
if (value == null) {
return new LoginAttempt();
}
value.attempts++;
value.lastAttemptTime = System.currentTimeMillis();
return value;
});
}
public static void main(String[] args) {
// 模擬登錄嘗試
String username = "testUser";
for (int i = 0; i < 5; i++) {
if (isLoginAllowed(username)) {
System.out.println("Login allowed");
// 模擬登錄成功,重置嘗試次數(shù)
loginAttempts.remove(username);
} else {
System.out.println("Login not allowed, user locked");
}
recordFailedLogin(username);
}
}
}
使用 Redis 存儲登錄嘗試信息
借助 Redis 的鍵值存儲,我們將用戶的登錄失敗次數(shù)和最近一次失敗時間存儲起來,并利用 Redis 的 TTL(Time To Live)功能自動清理過期記錄。在用戶登錄時,從 Redis 獲取信息,據(jù)此判斷是否允許登錄。
示例
public class LoginService {
private static final Jedis jedis = new Jedis("localhost", 6379);
private static final long LOCKOUT_DURATION = 5 * 60; // 5 minutes in seconds
private static final int MAX_ATTEMPTS = 3;
public static boolean isLoginAllowed(String username) {
String attemptsKey = "login_attempts:" + username;
String attemptsStr = jedis.get(attemptsKey);
if (attemptsStr == null) {
return true;
}
String[] parts = attemptsStr.split(":");
int attempts = Integer.parseInt(parts[0]);
long lastAttemptTime = Long.parseLong(parts[1]);
long currentTime = System.currentTimeMillis() / 1000;
if (currentTime - lastAttemptTime > LOCKOUT_DURATION) {
jedis.del(attemptsKey);
return true;
}
if (attempts >= MAX_ATTEMPTS) {
return false;
}
return true;
}
public static void recordFailedLogin(String username) {
String attemptsKey = "login_attempts:" + username;
String attemptsStr = jedis.get(attemptsKey);
long currentTime = System.currentTimeMillis() / 1000;
if (attemptsStr == null) {
jedis.setex(attemptsKey, (int) LOCKOUT_DURATION, "1:" + currentTime);
} else {
String[] parts = attemptsStr.split(":");
int attempts = Integer.parseInt(parts[0]);
jedis.setex(attemptsKey, (int) LOCKOUT_DURATION, (attempts + 1) + ":" + currentTime);
}
}
public static void main(String[] args) {
// 模擬登錄嘗試
String username = "testUser";
for (int i = 0; i < 5; i++) {
if (isLoginAllowed(username)) {
System.out.println("Login allowed");
// 模擬登錄成功,刪除嘗試記錄
jedis.del("login_attempts:" + username);
} else {
System.out.println("Login not allowed, user locked");
}
recordFailedLogin(username);
}
}
}
補充-滑動窗口
構(gòu)造一個滑動窗口,窗口大小限制5分鐘,然后限流次數(shù)設(shè)置為3次即可實現(xiàn)這個功能了。而滑動窗口我們可以借助Redis來實現(xiàn)。
public class SlidingWindowRateLimiter {
private Jedis jedis;
private String key;
private int limit = 3; //限制請求次數(shù)最大3次
private int lockTime; // 鎖定用戶的時間,單位:秒
public SlidingWindowRateLimiter(Jedis jedis, String key, int limit, int lockTime) {
this.jedis = jedis;
this.key = key;
this.limit = limit;
this.lockTime = lockTime; // 鎖定時間
}
public boolean allowRequest() {
// 當前時間戳,單位:毫秒
long currentTime = System.currentTimeMillis();
// 鎖定鍵的名稱(鎖定的用戶)
String lockKey = "lock:" + key;
// 檢查用戶是否已被鎖定
if (jedis.exists(lockKey)) {
returnfalse; // 用戶已被鎖定,返回 false
}
// 使用Lua腳本來確保原子性操作
String luaScript = "local window_start = ARGV[1] - 300000\n" + // 計算5分鐘的起始時間
"redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', window_start)\n" + // 清理過期的請求
"local current_requests = redis.call('ZCARD', KEYS[1])\n" + // 獲取當前請求次數(shù)
"if current_requests < tonumber(ARGV[2]) then\n" + // 如果請求次數(shù)小于限制
" redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1])\n" + // 添加當前請求時間
" return 1\n" + // 允許請求
"else\n" +
" redis.call('SET', 'lock:'..KEYS[1], 1, 'EX', tonumber(ARGV[3]))\n" + // 鎖定用戶
" return 0\n" + // 拒絕請求
"end";
// 調(diào)用 Lua 腳本進行原子操作
Object result = jedis.eval(luaScript, 1, key, String.valueOf(currentTime), String.valueOf(limit), String.valueOf(lockTime));
// 返回操作結(jié)果
return (Long) result == 1;
}
}
使用 Spring Security 實現(xiàn)
我們還可以使用 Spring Security 的相關(guān)組件來實現(xiàn)。利用 AuthenticationFailureHandler 記錄登錄失敗信息,在 UserDetailsService 的 loadUserByUsername 方法中檢查用戶是否被鎖定,使用 UserDetails 的 isAccountNonLocked 屬性表示用戶是否被鎖定。
示例
public class CustomAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
private PasswordEncoder passwordEncoder;
private Map<String, LoginAttempt> loginAttempts = new HashMap<>();
private static final long LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
private static final int MAX_ATTEMPTS = 3;
public CustomAuthenticationProvider(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = new BCryptPasswordEncoder();
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
UserDetails userDetails;
try {
userDetails = userDetailsService.loadUserByUsername(username);
} catch (UsernameNotFoundException e) {
throw new BadCredentialsException("Invalid username or password");
}
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
recordFailedLogin(username);
throw new BadCredentialsException("Invalid username or password");
}
if (!isLoginAllowed(username)) {
throw new BadCredentialsException("User is locked");
}
return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
}
private boolean isLoginAllowed(String username) {
LoginAttempt attempt = loginAttempts.get(username);
if (attempt == null) {
return true;
}
long currentTime = System.currentTimeMillis();
if (currentTime - attempt.lastAttemptTime > LOCKOUT_DURATION) {
loginAttempts.remove(username);
return true;
}
if (attempt.attempts >= MAX_ATTEMPTS) {
return false;
}
return true;
}
private void recordFailedLogin(String username) {
LoginAttempt attempt = loginAttempts.get(username);
if (attempt == null) {
attempt = new LoginAttempt();
loginAttempts.put(username, attempt);
}
attempt.attempts++;
attempt.lastAttemptTime = System.currentTimeMillis();
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
public static class LoginAttempt {
int attempts;
long lastAttemptTime;
public LoginAttempt() {
this.attempts = 0;
this.lastAttemptTime = System.currentTimeMillis();
}
}
}
最后
如果你是單機應(yīng)用,ConcurrentHashMap 和 ScheduledExecutorService 可能是個不錯的選擇;如果是分布式系統(tǒng),使用 Redis 方案會更有優(yōu)勢;而對于 Spring 框架的項目,Spring Security 方案會更貼合你的需求。