写在前面
现在很多互联网项目、app等都会有登录异常提醒、登录异常次数限制,基本都是5次异常后就会锁定一定时间的账户,让该账户无法进行登录操作。需要注册用户使用安全验证手段(如动态验证码等),解除锁定后才能进行新一轮登录操作。这么做无非就是两个目的:
1. 为了注册用户的账户安全;
2. 更多的是防止黑客攻击,维护网站等的安全。
项目需求
项目开发结束,进入测试阶段,很多项目或者开发就进入到了边测试边修改bug的阶段了。不过,我们公司的项目还需要进行安全测试,对不符合要求的项目需要进行安全隐患的整改。这不,我就“被迫”的研究起了如何避免被无差别攻击,防止通过无限攻击次数、穷举等破解密码的手段了。
整体思路
1. 验证及提示
获取并验证账号登录异常信息,提示登录异常。代码片段如下
// 验证账户是否封锁
String usercode = userLoginDto.getUsercode();
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
// 如果这个账号登录异常,则在登录页面提醒。
String shiroLoginCount = opsForValue.get(SHIRO_LOGIN_COUNT + usercode);
if (!StringUtils.isEmpty(shiroLoginCount) && Integer.parseInt(shiroLoginCount) >= 10) {
if ("LOCK".equals(opsForValue.get(SHIRO_IS_LOCK + usercode))) {
// 计数大于10次,设置用户被锁定5分钟
String msg = "由于输入错误次数大于10次,帐号5分钟内已经禁止登录!";
log.info(msg);
return PlatformResult.failure(msg);
}
}
2. 计数
用户的每次异常登录(其实就是密码错误)时,将账户及错误次数记录到redis中。代码片段如下
// 登录失败计数
opsForValue.increment(SHIRO_LOGIN_COUNT + usercode, 1); // 每次增加1
log.info(usercode + ":账号登录异常次数:" + opsForValue.get(SHIRO_LOGIN_COUNT + usercode));
3. 检查及限制
检查累积阈值错误次数(本系统设置为10次)时,锁定该用户,并限制一定时间内(本系统设置为5分钟)无法进行登录操作。代码片段如下
// 登录异常次数超限实现锁定
if (Integer.parseInt(opsForValue.get(SHIRO_LOGIN_COUNT + usercode)) >= 10) {
opsForValue.set(SHIRO_IS_LOCK + usercode, "LOCK"); // 锁住这个账号,值是LOCK。
stringRedisTemplate.expire(SHIRO_IS_LOCK + usercode, 5, TimeUnit.MINUTES); // expire 变量存活期限
}
4. 解锁及清空
在错误10次(可根据业务需要进行调整)前登录成功,则解锁该用户异常状态,清空登录错误次数和解锁账户。代码片段如下
/**
* 清空登录计数
*
* @author: caip
* @date: 2021-04-07 15:45:57
* @param userName
*/
private void clearLoginCount(String userName) {
log.info("UserLoginServiceImpl clearLoginCount start");
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
// 清空登录计数
opsForValue.set(SHIRO_LOGIN_COUNT + userName, "0");
// 清空锁
opsForValue.set(SHIRO_IS_LOCK + userName, "");
log.info("UserLoginServiceImpl clearLoginCount end");
}
整体代码
1. 参数对象
用于前后交互传参
package cn.xxx.rdc.fi.dto;
import lombok.Getter;
import lombok.Setter;
/**
* @author xxx
* @date: 2021-02-25 10:54:02
* @Copyright: Copyright (c) 2006 - 2021
* @Company: 公司
* @Version: V1.0
*/
@Getter
@Setter
public class UserLoginDto {
/** 账号. */
private String usercode;
/** 密码. */
private String password;
/** 记住密码. */
private String remarkid;
/** 用户类型. */
private Integer userType;
/** 微信userid. */
private String wxuserid;
/** 是否加密:1.是;0.否. */
private String isEncryption;
}
2. 接口
定义交互标准
@Autowired
private IUserLoginService userLoginService;
@ApiOperation(value = "用户登录", notes = "用户登录")
@PostMapping("/userLogin")
public PlatformResult<JSONObject> userLogin(@RequestBody UserLoginDto userLoginDto, ServletResponse response) {
return userLoginService.userLogin(userLoginDto, response);
}
3. 服务定义
定义服务标准
/**
* 用户登录
*
* @author: xxx
* @date: 2021-02-24 17:17:23
* @param userLoginDto 用户登录对象
* @return
*/
PlatformResult<JSONObject> userLogin(UserLoginDto userLoginDto, ServletResponse response);
4. 服务实现
定义服务具体实现
package cn.xxx.rdc.knowledge.service.impl;
import java.util.concurrent.TimeUnit;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import com.alibaba.druid.util.StringUtils;
import com.alibaba.fastjson.JSONObject;
import cn.xxx.BootComm.Contants;
import cn.xxx.BootComm.utils.PlatformResult;
import cn.xxx.rdc.knowledge.dto.UserLoginDto;
import cn.xxx.rdc.knowledge.service.IUserService;
import cn.xxx.rdc.knowledge.utils.HttpClientUtil;
import cn.xxx.rdc.knowledge.utils.HttpRequestUtil;
import cn.xxx.rdc.knowledge.utils.RSAUtil;
import lombok.extern.slf4j.Slf4j;
/**
* 用户登录服务类
*
* @author xxx
* @date: 2021-02-24 17:17:53
* @Copyright: Copyright (c) 2006 - 2021
* @Company: xxx
* @Version: V1.0
*/
@Slf4j
@Service
public class UserServiceImpl implements IUserService {
@Value("${user.login.url}")
private String userLoginUrl;
@Value("${sso.verifyUrl}")
private String ssoVerifyUrl;
@Value("${sso.defaultPrikey}")
private String ssoDefaultPrikey;
@Value("${user.logout.url}")
private String userLogoutUrl;
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 用户登录次数计数 redisKey 前缀
private final String SHIRO_LOGIN_COUNT = "kb_shiro_login_count_";
// 用户登录是否被锁定 redisKey 前缀
private final String SHIRO_IS_LOCK = "kb_shiro_is_lock_";
@Override
public PlatformResult<JSONObject> userLogin(UserLoginDto userLoginDto, ServletResponse response) {
log.info("UserLoginServiceImpl userLogin start");
// 获取HttpServletRequest、HttpServletResponse
HttpServletResponse res = (HttpServletResponse)response;
// 验证账户是否封锁
String usercode = userLoginDto.getUsercode();
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
// 如果这个账号登录异常,则在登录页面提醒。
String shiroLoginCount = opsForValue.get(SHIRO_LOGIN_COUNT + usercode);
if (!StringUtils.isEmpty(shiroLoginCount) && Integer.parseInt(shiroLoginCount) >= 10) {
if ("LOCK".equals(opsForValue.get(SHIRO_IS_LOCK + usercode))) {
// 计数大于10次,设置用户被锁定5分钟
String msg = "由于输入错误次数大于10次,帐号5分钟内已经禁止登录!";
log.info(msg);
return PlatformResult.failure(msg);
}
}
// 未封锁,则进行登录请求
userLoginDto.setIsEncryption("1");
String params = JSONObject.toJSONString(userLoginDto);
log.info("userLogin urlStr=[" + userLoginUrl + "]");
log.info("userLogin params=[" + params.toString() + "]");
// 调用登录http请求
String result = HttpRequestUtil.httpCrossDomain(userLoginUrl, params);
// 获取登录返回参数
JSONObject resultJson = JSONObject.parseObject(result);
int statusCode = resultJson.getIntValue("statusCode");
boolean success = resultJson.getBooleanValue("success");
// 判断是否登录成功
String loginMsg = resultJson.getString("message");
if (statusCode != 200 || !success) {
// 登录失败计数
opsForValue.increment(SHIRO_LOGIN_COUNT + usercode, 1); // 每次增加1
log.info(usercode + ":账号登录异常次数:" + opsForValue.get(SHIRO_LOGIN_COUNT + usercode));
// 登录异常次数超限实现锁定
if (Integer.parseInt(opsForValue.get(SHIRO_LOGIN_COUNT + usercode)) >= 10) {
opsForValue.set(SHIRO_IS_LOCK + usercode, "LOCK"); // 锁住这个账号,值是LOCK。
stringRedisTemplate.expire(SHIRO_IS_LOCK + usercode, 5, TimeUnit.MINUTES); // expire 变量存活期限
}
return PlatformResult.failure(loginMsg);
}
// 登录成功解锁
this.clearLoginCount(usercode);
// 查询本系统用户信息
JSONObject userObject = resultJson.getJSONObject("object");
// 判断是否免密登录
String tokenEncryption = userObject.getString("token");
log.info("tokenEncryption=" + tokenEncryption);
// 判断是否调用免密登录
if (!StringUtils.isEmpty(tokenEncryption) && 36 < tokenEncryption.length()) {
// 通过加密token,解密后调用sso接口获取用户信息
String token = RSAUtil.decrypt(ssoDefaultPrikey, tokenEncryption);
log.info("token=" + token);
// 调用登录http请求
String userResult = HttpClientUtil.getInstance().sendHttpGet(ssoVerifyUrl + "?token=" + token);
log.info("userResult=" + userResult);
// 获取登录返回参数
resultJson = JSONObject.parseObject(userResult);
String code = resultJson.getString("code");
// 判断是否登录成功
loginMsg = resultJson.getString("msg");
if (!"0".equals(code)) {
return PlatformResult.failure(loginMsg);
}
userObject = resultJson.getJSONObject("uid");
}
// 设置cookie中的THPMSCookie为登录用户的token
res.addCookie(new Cookie(Contants.COOKIE_NAME, userObject.getString("token")));
log.info("UserLoginServiceImpl userLogin result=[" + userObject.toString() + "]");
log.info("UserLoginServiceImpl userLogin end");
return PlatformResult.success(userObject);
}
/**
* 清空登录计数
*
* @author: caip
* @date: 2021-04-07 15:45:57
* @param userName
*/
private void clearLoginCount(String userName) {
log.info("UserLoginServiceImpl clearLoginCount start");
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
// 清空登录计数
opsForValue.set(SHIRO_LOGIN_COUNT + userName, "0");
// 清空锁
opsForValue.set(SHIRO_IS_LOCK + userName, "");
log.info("UserLoginServiceImpl clearLoginCount end");
}
}
补充说明
以上代码有部分业务代码,整合了自用的SSO(单点登录系统)用于验证账户密码正确性,使用时只需要关心锁定和解锁功能,其他代码直接删除或替换为业务代码即可。