用户登录接口的设计,首先要考虑的是防止用户的账号被暴力破解,常用的是使用谷歌图形验证码,同时还需要对用户每天的“连续”登录错误次数进行限制,假设一个账号用户每天连续登陆的错误次数是十次,那么当用户第十一次输入错误时,就应该锁住当前用户的账户。但是我们真正的目的是为了保证用户的账号安全,屏蔽攻击者的恶意调用的同时,又要保证真实用户的正常使用,此时就应该提供重置登录密码的操作,密码重置成功之后,用户在当天又能恢复登录。其次需要考虑的是用户登录密码在传输过程中的安全性的问题,虽说现在的HTTPS 的安全性已经足够,但是对于一些安全性很高的项目,仍旧需要有自己的加密方式来保护用户的敏感信息,比如我这里使用的RSA加密方法,具体的论述以及使用,请参考我的另一篇博客:https://blog.csdn.net/weixin_42023666/article/details/89706659。
下面废话也不多说,直接上代码,当然了,还是老规矩,只分享service层的代码来陈述具体的思路,至于全套代码无法提供(还请谅解)。另外代码中的注释已经有足够多的解释,千万别忘了他们。
package sy.service.impl;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.apache.commons.lang.StringUtils;
import org.apache.log4j.Logger;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson.JSON;
import DateUtil;
import sy.util.mobile.MobileUtil;
@Service("userService")
public class UserServiceImpl implements UserService {
private static final Logger logger = Logger.getLogger(UserServiceImpl.class);
/**
登录时,当用户输入手机号后,就调用该接口判断当前手机号是否注册,
如果是已经注册过的,后台就会生成rsa公私钥对并将公钥返回给前端;
如果没有注册,就提醒用户当前手机号未注册,以此来提高用户体验
*/
@Override
public Result findUser(String mobile, HttpServletRequest request) {
Result result = new Result();
// 判断传入的手机号格式是否正确
if (StringUtils.isBlank(mobile)) {
result.setSuccess(false);
result.setMsg("缺少必要参数");
return result;
}
mobile = mobile.trim();
// 判断传入的手机号格式是否正确
if (mobile.length() != 11 || !MobileUtil.isMobileNum(mobile)) {
result.setSuccess(false);
result.setMsg("传入的手机号格式不正确");
return result;
}
//查询当前手机号是否注册,此处省略具体的数据库相关的操作
if(未注册){//伪代码,实际使用需换成你自己的dao层查询
result.setSuccess(false);
result.setMsg("当前手机号没有注册");
return result;
}
String rsaPublicKey = "login_rsa_public_" + mobile;
String rsaPrivateKey = "login_rsa_private_" + mobile;
Long rsa = RedisUtils.ttl(rsaPublicKey);
if (rsa > 60 *15+5) {// 原来的公钥有效时间大于15分钟,继续使用
result.setStatus(RedisUtils.get(rsaPublicKey));//该字段保存生成的rsa公钥并返回给前端
} else {
Map map = RSAEncrypt.genKeyPair();
if (map != null && map.get(0) != null) {
RedisUtils.set(rsaPublicKey, map.get(0), 60 * 120 + 120);// 保存公钥
RedisUtils.set(rsaPrivateKey, map.get(1), 60 * 120 + 150);// 保存私钥,2小时有效
result.setStatus(map.get(0));
}
}
// 登录失败超过三次就要输入验证码,判断当前用户是否需要设置验证码
String times = RedisUtils.get( mobile + "_login_error_times");
if (times != null && new Integer(times).intValue() >= 3) {
result.setPage(1);//告诉前端,此次请求需要输入谷歌图形验证码;此时公钥其实已经一并返回给前端,
//如果前端没有携带谷歌图形验证码就直接调用登录接口,请求依旧会失败,因为登录接口有做检验,有判断当前用户今日登录次数是否超次数
}
result.setSuccess(true);
return result;
}
/**
用户登录接口service层的代码,按照惯例,controller层就不贴出来了
mobile是登录的手机号,pwd是使用rsa公钥加密后的密码,code是谷歌图形验证码
*/
@Override
public Result login(String mobile,String pwd,String code, HttpServletRequest request) {
Result result = new Result();
// 判断请求参数是否正确
if (StringUtils.isBlank(mobile) || StringUtils.isBlank(pwd)) {
result.setSuccess(false);
result.setMsg("缺少必要的参数");
return result;
}
mobile = mobile.trim();
// 判断传入的手机号格式是否正确
if (mobile.length() != 11 || !MobileUtil.isMobileNum(mobile)) {
result.setSuccess(false);
result.setMsg("传入的手机号格式不正确");
return result;
}
// 判断是否有图片验证码
String kaptcha = RedisUtils.get("login_captcha_" + mobile);
if (kaptcha != null && code != null && !code.toLowerCase().equals(kaptcha)) {
result.setPage(1);
result.setSuccess(false);
result.setMsg("您输入的验证码有误,请重新输入");
return result;
}
String rsaPublicKey = "login_rsa_public_" + mobile;
String rsaPrivateKey = "login_rsa_private_" + mobile;
// 从redis数据库获取私钥
String privateKey = RedisUtils.get(rsaPrivateKey);
if (StringUtils.isBlank(privateKey)) {
RedisUtils.del(rsaPublicKey);
RedisUtils.del(rsaPrivateKey);
result.setPage(1);
result.setSuccess(false);
result.setMsg("密钥验证失败,请输入新的验证码后再登录");
result.setStatus("1");
return result;
}
// 判断当前手机号的登录失败次数,防止有人暴力破解用户的密码
String limitKey = mobile + "_login_error_times";
String limitTimes = RedisUtils.get(limitKey);
Integer times = 1;
if (limitTimes != null) {
if (new Integer(limitTimes).intValue() >= 6) {
result.setSuccess(false);
result.setMsg("当前账号今日登录失败次数超过6次,为保证您的账号安全,系统已锁定当前账号,您可明天再登录或立即重置密码后使用新密码登录!");
return result;
}
times = new Integer(limitTimes) + 1;
if (kaptcha == null && times > 3) {
result.setPage(1);//该字段如果为1,表示前端需要用户输入谷歌图形验证码接口
result.setSuccess(false);
result.setMsg("您没有输入验证码");
return result;
}
}
// 使用私钥解密后的密码
pwd = RSAEncrypt.decrypt(pwd, privateKey);
if (pwd == null) {
result.setSuccess(false);
result.setMsg("解密失败,您传入的数据有误");
result.setStatus("1");
return result;
}
//核心业务隐身符,此处通过手机号获取用户的密码,然后通过对应的加密方式来检验密码是否一致性
if(密码不正确){//伪代码
// 记录密码输入错误数
RedisUtils.set(limitKey, times + "", DateUtil.getTodaySurplusTime());
if (times > 2) {
result.setPage(1);// 密码输错两次,就需要输入谷歌图形验证码
}
result.setSuccess(false);
result.setMsg("登录密码错误");
return result;
}
//密码登录成功,执行登录成功后的核心业务,此处省略
// 删除登录失败次数的标识
if (limitTimes != null) {
RedisUtils.del(limitKey);
}
// 删除验证码
if (kaptcha != null) {
RedisUtils.del("login_captcha_" + mobile);
}
// 删除保存的公钥私钥
RedisUtils.del(rsaPublicKey);
RedisUtils.del(rsaPrivateKey);
result.setSuccess(true);
return result;
}
/**
获取用户重置登录密码所需要的手机短信验证码的接口
mobile是手机号,contents是谷歌图形验证码(该值可能为null)
*/
@Override
public Result sendMobileCode(String mobile, String contents, HttpServletRequest request) {
Result result = new Result();
if (StringUtils.isBlank(mobile)) {
result.setSuccess(false);
result.setMsg("请求参数不全");
return result;
}
mobile = mobile.trim();
// 判断传入的手机号格式是否正确
if (mobile.length() != 11 || !MobileUtil.isMobile(mobile)) {
result.setSuccess(false);
result.setMsg("传入的手机号格式不正确");
return result;
}
//业务代码隐身符1,此处需要用该手机号去查询当前帐号是否注册,只有注册的号码才能调用重置密码的手机短信验证码
if(手机号未注册){
result.setSuccess(false);
result.setMsg("当前手机号未注册,请先注册");
return result;
}
String kapchatKey = "kaptcha_reset_login_" + mobile;//记录此次的谷歌图形验证码,如果有的话
String mobileKey = "mobile_reset_login_" + mobile;//记录此次的重置登录密码的手机短信验证码
String todayKey = "mobile_code_times_" + mobile;//记录今日发送短信验证码的次数
// 验证码十分钟内有效,2分钟后才能重新发送
Long times = RedisUtils.ttl(mobileKey);
if (times > 60 * 18) {
result.setSuccess(false);
result.setMsg("距离您上次发送验证码不足两分钟,请两分钟后再尝试获取");
return result;
}
// 判断当前手机号今天发送密码次数是否已达上线,每天9条,具体条数根据自己的需求制定
String todayTimes = RedisUtils.get(todayKey);
int todayCount = 1;
if (todayTimes != null) {
todayCount = new Integer(todayTimes);
if (todayCount >= 9) {
result.setSuccess(false);
result.setMsg("当前手机号今日发送验证码已达上限,请明日再来");
return result;
}
todayCount++;
}
//今天调用接口超过三次,就需要调谷歌图形验证码,这也是对短信接口的一种保护机制
if(todayCount>3){
result.setPage(1);//只要今日获取验证码次数超过三次,之后每次调用短信验证码接口时都要先调用谷歌验证码
if(StringUtils.isBlank(contents)){
result.setSuccess(false);
result.setMsg("为保证您账号安全,请输入图形验证码获取手机短信");
return result;
}
// 检验图形验证码
String kapchat = RedisUtils.get(kapchatKey);
if (kapchat == null) {
result.setMsg("图形验证码已失效,请重新输入");
result.setSuccess(false);
return result;
} else if (!kapchat.equals(contents.toLowerCase())) {
result.setSuccess(false);
result.setMsg("您输入的验证码错误,请重新输入");
return result;
}
}else if(todayCount==3){
result.setPage(1);
}
String msg = null;
Map map = null;
String publicKey = null;
String rsaPublicKey = "rsa_public_reset_login_" + mobile;// redis保存的公钥的key
String rsaPrivateKey = "rsa_private_reset_login_" + mobile;// redis中保存私钥的key
try {
//判断原来的公钥是否可以使用,如果可以,就不需要重新生成新的rsa公钥私钥对
Long rsa = RedisUtils.ttl(rsaPublicKey);
if (rsa > 60 * 20 + 10) {// 原来的公钥有效时间大于20分钟,继续使用
publicKey = RedisUtils.get(rsaPublicKey);
} else {
// 生成公钥和私钥
map = RSAEncrypt.genKeyPair();
if (map == null || map.get(0) == null) {
result.setSuccess(false);
result.setMsg("生成公钥私钥失败,请联系管理员");
return result;
}
}
// 要发送的验证码
String code = (int) ((Math.random() * 9 + 1) * 100000) + "";
// 业务代码隐身符2,此处省略调用第三方接口给用户手机发送验证码的操作
if(短信发送成功){
result.setSuccess(true);
result.setMsg("您的手机验证码发送成功,请注意查收,本验证码20分钟内有效");
// 保存私钥,返回公钥
if (map != null) {
RedisUtils.set(rsaPublicKey, map.get(0), 60 * 90 + 250);// 2小时有效
RedisUtils.set(rsaPrivateKey, map.get(1), 60 * 90 + 300);// 2小时有效
result.setStatus(map.get(0));
}
if (publicKey != null) {
result.setStatus(publicKey);
}
// 保存验证码
RedisUtils.set(mobileKey, code, 60 * 20 + 5);
// 记录本号码发送验证码次数
RedisUtils.set(todayKey, todayCount + "", MobileUtil.getTodaySurplusTime());
// 删除图形验证码
RedisUtils.del(kapchatKey);
} else {
result.setSuccess(false);
result.setMsg("短信验证码发送失败,请联系管理员:" + msg);
}
} catch (Exception e) {
result.setSuccess(false);
result.setMsg("获取短信验证码异常:" + e.getMessage());
}
return result;
}
/**
通过手机号重置用户登录密码的service层代码
mobile是用户手机号,contents是短信验证码,pwd是新的登录密码
*/
@Override
public Result resetSecret(String mobile, String contents, String pwd,HttpServletRequest request) {
Result result = new Result();
if (StringUtils.isBlank(mobile) || StringUtils.isBlank(contents) || StringUtils.isBlank(pwd)) {
result.setSuccess(false);
result.setMsg("缺少必要的参数");
return result;
}
mobile = mobile.trim();
// 判断传入的手机号格式是否正确
if (mobile.length() != 11 || !MobileUtil.isMobile(mobile)) {
result.setSuccess(false);
result.setMsg("传入的手机号格式不正确");
return result;
}
//此处可以再次考虑判断当前的手机号是否已经注册,防止前端调错了接口
//······
String mobileKey = "mobile_reset_login_" + mobile;// 存储到redis中的验证码的key
String rsaPublicKey = "rsa_public_reset_login_" + mobile;// 存储到redis的公钥的key
String rsaPrivateKey = "rsa_private_reset_login_" + mobile;// 存储到redis的私钥的key
// 校验短信验证码
String code = RedisUtils.get(mobileKey);
if (code == null) {
result.setPage(1);// 需要重新获取验证码
result.setSuccess(false);
result.setMsg("当前验证码已失效,请获取最新验证码后再进行此操作");
return result;
} else if (!code.equals(contents)) {
result.setSuccess(false);
result.setMsg("您输入的验证码不正确,请重新输入(不用重新获取)");
return result;
}
// 获取保存的私钥
String privateKey = RedisUtils.get(rsaPrivateKey);
if (privateKey == null) {
result.setSuccess(false);
result.setMsg("没有获取到私钥信息,请重新获取验证码");
return result;
}
// 私钥解析密码
pwd = RSAEncrypt.decrypt(pwd, privateKey);
if (pwd == null) {
result.setSuccess(false);
result.setMsg("非法请求,传入的密码无法解析");
return result;
}
//此处的密码应该是用户的密码明文,可以对用户输入新密码格式进行后端校验
// 删除缓存的key
RedisUtils.del(mobileKey);
RedisUtils.del(RsaPublicKey);
RedisUtils.del(RsaPrivateKey);
// 删除用户今日登录失败次数的标识,如果有
RedisUtils.del( mobile + "_login_error_times");
/* 以下几行是添加操作流水 */
result.setSuccess(true);
result.setMsg("密码重置成功");
return result;
}
}
下面是工具类 MobileUtil.java
package util;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang.StringUtils;
/**
* @ClassName: MobileUtil
* @author hqq
*
*/
public class MobileUtil {
/**
* 正则表达式:验证手机号
*/
private static final String REGEX_MOBILE = "^((13[0-9])|(15[^4,\\D])|(18[0-3,5-9])|(17[0-9]))\\d{8}$";
/**
* 判断是否是手机号格式,如果传入的是空串,返回false
* @param mobile
* @return 校验通过返回true,否则返回false
*/
public static boolean isMobile(String mobile) {
if(StringUtils.isBlank(mobile)){
return false;
}
return Pattern.matches(REGEX_MOBILE, mobile);
}
/**
* 获取今日的剩余时间,单位是秒
* @return
*/
public static Integer getTodaySurplusTime(){
Calendar c = Calendar.getInstance();
long now = c.getTimeInMillis();
c.add(Calendar.DAY_OF_MONTH, 1);
c.set(Calendar.HOUR_OF_DAY, 0);
c.set(Calendar.MINUTE, 0);
c.set(Calendar.SECOND, 0);
c.set(Calendar.MILLISECOND, 0);
long millis = c.getTimeInMillis() - now+2000;
return (int)(millis/1000);
}
}
另外里面涉及到的短信验证码接口和谷歌图形验证码的接口,可以参考一下我的另外几篇博客: