ruoyi-auth/src/main/java/com/ruoyi/auth/service/LoginRateLimitService.java
@@ -6,41 +6,45 @@ import java.util.Locale; import java.util.concurrent.TimeUnit; import javax.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import com.ruoyi.common.core.constant.CacheConstants; import com.ruoyi.common.core.exception.ServiceException; import com.ruoyi.common.core.utils.StringUtils; import com.ruoyi.common.core.utils.ip.IpUtils; import com.ruoyi.common.redis.service.RedisService; /** * 登录频率限制服务 * 登录频率限制服务(账号维度) */ @Component public class LoginRateLimitService { private static final String UNKNOWN = "unknown"; private static final String SESSION_HEADER = "X-Login-Session"; @Autowired private RedisService redisService; /** * 按 IP、账号、登录会话三个维度限制登录尝试频率。 * 按账号维度限制登录尝试频率。 * IP、登录会话维度已由网关 LoginRateLimitFilter 在验证码校验之前处理 * (验证码错误的请求不会到达本服务,若在 auth 重复计数会与网关双重计数)。 */ public void checkLoginRate(String username, HttpServletRequest request) public void checkLoginRate(String username) { String normalizedUsername = StringUtils.defaultString(username).trim().toLowerCase(Locale.ROOT); String ip = StringUtils.defaultIfEmpty(IpUtils.getIpAddr(request), UNKNOWN); String sessionId = resolveSessionId(request); checkDimension("ip", ip); checkDimension("account", normalizedUsername); checkDimension("session", sessionId); } /** * 清除账号维度的登录限流计数(登录成功后调用)。 */ public void clearAccountRecord(String username) { String normalizedUsername = StringUtils.defaultString(username).trim().toLowerCase(Locale.ROOT); String key = CacheConstants.LOGIN_RATE_LIMIT_KEY + "account:" + digest(normalizedUsername); if (redisService.hasKey(key)) { redisService.deleteObject(key); } } private void checkDimension(String dimension, String identity) @@ -53,17 +57,6 @@ long retryAfter = remainingSeconds > 0 ? remainingSeconds : CacheConstants.LOGIN_RATE_LIMIT_WINDOW; throw new ServiceException(String.format("登录尝试过于频繁,请%s秒后重试", retryAfter)); } } private String resolveSessionId(HttpServletRequest request) { String sessionId = request.getHeader(SESSION_HEADER); if (StringUtils.isNotEmpty(sessionId)) { return sessionId; } String userAgent = StringUtils.defaultString(request.getHeader("User-Agent")); return IpUtils.getIpAddr(request) + "|" + userAgent; } private String digest(String value) ruoyi-auth/src/main/java/com/ruoyi/auth/service/SysLoginService.java
@@ -47,8 +47,10 @@ * 登录 */ public LoginUser login(String username, String password, HttpServletRequest request) { loginRateLimitService.checkLoginRate(username, request); username = StringUtils.trim(username); // 优先校验账号是否已锁定(锁定期内直接提示,避免被限流提示掩盖) passwordService.checkLocked(username, request); loginRateLimitService.checkLoginRate(username); // 查询用户信息 R<LoginUser> userResult = remoteUserService.getUserInfo(username, SecurityConstants.INNER); @@ -97,6 +99,8 @@ throw new ServiceException("对不起,您的账号:" + username + " 已停用"); } passwordService.validate(user, password, request); // 登录成功,清除账号维度的登录限流计数,避免正常用户短时间再次登录被误伤 loginRateLimitService.clearAccountRecord(username); recordLogService.recordLogininfor(request, user.getUserId().intValue(), username, Constants.LOGIN_SUCCESS_STATUS, "登录成功"); return userInfo; } ruoyi-auth/src/main/java/com/ruoyi/auth/service/SysPasswordService.java
@@ -41,11 +41,28 @@ return CacheConstants.PWD_ERR_CNT_KEY + username; } /** * 检查账号是否处于锁定状态(锁定期内直接抛出锁定提示)。 * 在限流之前调用,避免锁定提示被"登录尝试过于频繁"掩盖。 * * @param username 用户名 * @param request 请求 */ public void checkLocked(String username, HttpServletRequest request) { Integer retryCount = redisService.getIntegerCacheObject(getCacheKey(username)); if (retryCount != null && retryCount >= maxRetryCount) { String errMsg = String.format("密码输入错误%s次,帐户锁定%s分钟", maxRetryCount, lockTime); throw new ServiceException(errMsg); } } public void validate(SysUser user, String password, HttpServletRequest request) { String username = user.getUserName(); Integer retryCount = redisService.getCacheObject(getCacheKey(username)); Integer retryCount = redisService.getIntegerCacheObject(getCacheKey(username)); if (retryCount == null) { ruoyi-common/ruoyi-common-redis/src/main/java/com/ruoyi/common/redis/service/RedisService.java
@@ -84,6 +84,30 @@ Long.class); /** * 获取缓存值并转为 Integer(使用 String 序列化读取, * 避免 FastJson2 对纯数字字符串反序列化的类型不确定性) * * @param key 缓存键值 * @return 缓存值对应的 Integer,key 不存在或值非数字时返回 null */ public Integer getIntegerCacheObject(final String key) { String value = stringRedisTemplate.opsForValue().get(key); if (value == null) { return null; } try { return Integer.valueOf(value); } catch (NumberFormatException e) { return null; } } /** * 设置有效时间 * * @param key Redis键 ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/LoginRateLimitFilter.java