Java 中实现用户登录次数限制的多种方案

开发 前端
如果你是单机应用,ConcurrentHashMap​ 和 ScheduledExecutorService​ 可能是个不错的选择;如果是分布式系统,使用 Redis​ 方案会更有优势;而对于 Spring​ 框架的项目,Spring Security 方案会更贴合你的需求。​

前言

在开发应用程序时,为了保障系统的安全性,我们常常需要对用户的登录行为进行限制,比如规定用户在 5 分钟内最多允许尝试登录 3 次,如果超过次数,就锁定当前用户。今天我们就来探讨几种在 Java 中实现这一功能的方案,让我们的系统更加安全可靠。

使用 HashMap 和 Timer 实现

首先,我们创建一个 HashMap 来存储用户登录失败的信息。这个 HashMap 中的键是用户名,而值是一个自定义的 LoginAttempt 对象,它包含登录失败次数和最近一次失败时间。当用户进行登录操作时,我们会检查 HashMap 中是否存在该用户的记录。若存在,会查看是否超过 5 分钟,如果超过,我们将重置失败次数;如果未超过且次数已达 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");
                // 模拟登录成功,重置尝试次数
                loginAttempts.remove(username);
            } else {
                System.out.println("Login not allowed, user locked");
            }
            recordFailedLogin(username);
        }
    }
}

使用 ConcurrentHashMap 和 ScheduledExecutorService 实现

这里我们使用 ConcurrentHashMap 来存储用户登录失败信息,它支持并发访问。并且使用 ScheduledExecutorService 来进行定时清理过期记录,避免 Timer 的一些潜在问题,如可能导致的内存泄漏,更适合高并发场景。

示例

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");
                // 模拟登录成功,重置尝试次数
                loginAttempts.remove(username);
            } else {
                System.out.println("Login not allowed, user locked");
            }
            recordFailedLogin(username);
        }
    }
}

使用 Redis 存储登录尝试信息

借助 Redis 的键值存储,我们将用户的登录失败次数和最近一次失败时间存储起来,并利用 Redis 的 TTL(Time To Live)功能自动清理过期记录。在用户登录时,从 Redis 获取信息,据此判断是否允许登录。

示例

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);
        }
    }
}

补充-滑动窗口

构造一个滑动窗口,窗口大小限制5分钟,然后限流次数设置为3次即可实现这个功能了。而滑动窗口我们可以借助Redis来实现。

public class SlidingWindowRateLimiter {
    private Jedis jedis;
    private String key;
    private int limit = 3; //限制请求次数最大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" +  // 获取当前请求次数
                "if current_requests < tonumber(ARGV[2]) then\n" +  // 如果请求次数小于限制
                "    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";
        // 调用 Lua 脚本进行原子操作
        Object result = jedis.eval(luaScript, 1, key, String.valueOf(currentTime), String.valueOf(limit), String.valueOf(lockTime));
        // 返回操作结果
        return (Long) result == 1;
    }
}

使用 Spring Security 实现

我们还可以使用 Spring Security 的相关组件来实现。利用 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();
        }
    }
}

最后

如果你是单机应用,ConcurrentHashMap 和 ScheduledExecutorService 可能是个不错的选择;如果是分布式系统,使用 Redis 方案会更有优势;而对于 Spring 框架的项目,Spring Security 方案会更贴合你的需求。

责任编辑:武晓燕 来源: 一安未来
相关推荐

2018-04-02 10:16:00

bug代码安卓

2009-12-23 10:46:38

WPF实现用户界面

2009-12-30 09:45:52

Silverlight

2010-01-28 10:00:54

linux用户注销logout

2012-05-04 09:28:49

Linux

2010-08-04 10:48:17

路由器

2021-08-05 10:40:37

加密方案Spring

2024-09-22 10:46:33

数据飞轮算法

2016-10-24 23:18:55

数据分析漏斗留存率

2024-04-08 14:10:06

2011-03-24 08:56:23

escalationsNagios报警

2009-09-07 09:20:34

2016-05-17 10:03:39

用户体验运维可度量

2018-05-30 10:22:47

电商平台

2019-08-22 15:42:03

2024-01-10 08:26:16

用户注册用户重复数据

2011-03-25 14:56:43

Nagiosescalations

2011-03-21 15:44:52

escalationsNagios

2010-06-18 13:52:24

SQL Server查
点赞
收藏

51CTO技术栈公众号