Java后端如何保证用户注册登录接口的安全性之用户注册篇

       现在的面试难免显得有些教科书式,面试官往往会问应试者一些跟自己现有项目压根用不上的新技术,以及一些所谓的基础知识,然后招到的往往又都是理论知识很丰富,前言技术又很熟悉,但是处理起实际业务时就只能呵呵的人才,还美其名曰人才储备。我个人认为,公司招人的第一准则是新入职者适应能力强,能以最快的速度上岗干活,适应期过后,其工作效率起码不能比原有岗位的开发者工作效率低太多。至于所谓人才储备,那应该是发展前景良好的公司才需要考虑的问题,小公司能生存下去已很不易。这就好比给一辆十万不到的小轿车装上宝马x5的轮胎,即便能用也无法发挥出轮胎的真正性能,最终的结果也只能是搞得双方都不愉快。因此,一个合格的面试官,他为公司寻找的应该是胜任公司现有项目的开发人才,而不是理论全才,况且,真要是技术很牛逼的大神,哪个不是眼高于顶的人物,小庙又哪里留的住。

        个人愚见,要寻找适合公司当前岗位的员工,要么是拿你在当前公司当下项目遇到的实际问题去面试求职者,通过他们的回答其实已经能够分辨出他们是否能够接受当前项目;要么是拿最普遍常用的项目模块去问他们具体的实现方式,就算他没有开发过该功能,拥有实际开发经验和没有实际开发经验的人的回答其实是很明显的。就以接下来要分享的用户登录注册模块为例,普通的面试官着眼点通常都是用户密码的加密方式云云,什么传输时使用md5加密一遍,数据库保存的密码是后台根据前端传来的密码进行二次加密后的密码等等。

       但是实际开发中,后端开发人员需要考虑的问题还要复杂很多。网站的注册登录接口作为完全暴露的接口,便意味着不管是真实用户还是攻击者都可以调用,也因为无法明确判断访问注册接口的请求来自何人,一般的后端开发通常都只能默认访问当前注册接口的人都是正常用户,以此来保证真实用户能正常使用注册登录功能。但是这样做登录注册接口是很不负责任的,攻击者只需要写一个for循环开个多线程调用你的注册登录接口,你的网站就得崩溃,此时就需要考虑使用图形验证码机制和ip黑名单机制。同一个ip的用户连续注册多少次后,就必须输入图形验证码(由于调用图形验证码接口的次数也是完全暴露的,此时也要对这个用户的日访问次数做限制,达到上限就禁止调用),这只是第一重安全限制,第二重安全限制需要考虑到的是验证码机制被破解后,注册接口仍旧能被调用,此时也要对访问这个注册接口的IP进行日访问量限制。但是对于注册,现在大部分的网站都是使用手机号注册,此时需要加上短信验证码功能(短信验证码接口也是要防恶意调用的,详情请看我的另一篇博客https://blog.csdn.net/weixin_42023666/article/details/89680342,此处不赘述),因为有了短信验证码,所以图形验证码可以考虑不使用。 以为这样就万事大吉了吗?还差得远呢。前面说的IP是指外网的固定IP,我们知道一个外网IP可以映射多个内网IP,一般的公司通常都只拉一条网线,那么同一个公司的不同员工访问我们的网站,我们获取到的IP都是一样的,此时如果员工张三恶意操作导致该IP进我们网站的黑名单,那么同公司的李四也会因此无法访问注册,而李四只是第一次点击我们的网站来注册,这时该怎么办?笔者能力浅薄,暂时也无法回答这个问题。

       另外,用户注册时填写的登录密码,后台是需要校验的,主要是为了判断密码是否符合我们定义的规则(还是那句话,永远不要相信前端传来的敏感数据),这个时候就需要保证后端能够正确得到用户的密码明文,详细的理由我已经在上一篇博客https://blog.csdn.net/weixin_42023666/article/details/89706659中说明,此处不赘述,下面直接上代码。注意,下面分享的只有业务层的代码,并且省去了数据库相关的业务,目的只是为了分享一个思路。

 

package demo;

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.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;

import com.alibaba.fastjson.JSON;
import com.jpush.JPushClientExample;


/**
 * @ClassName: UserServiceImpl
 * @author hqq
 */
@Service("userService")
public class UserServiceImpl implements UserService {

	private static final Logger logger = Logger.getLogger(UserServiceImpl.class);
	
	//获取短信验证码
	@Override
	public Result sendMobileCode(String mobile, String type, String imgCode, HttpServletRequest request) {
		//mobile字段是传入的用户名,即手机号,必传字段;
		//type是传入的需要发送的短信类型,必传字段,如登录,注册等(也可以不区分,按个人爱好喽);
		//imgCode是传入的图形验证码,非必传字段,因为只有今日第四次调用该接口时才需要校验图形验证码
		
		Result result = new Result();
 
		//校验请求参数是否正确
		if (StringUtils.isBlank(mobile) || StringUtils.isBlank(type)) {
			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 mobileCode = (int) ((Math.random() * 9 + 1) * 100000) + "";
 
		String modelCode = null;//我这里使用的是阿里云的短信服务,调用阿里云的短信接口需要传入一个模板code参数,这个code再你申请短信模板时就会产生,且固定的值
		
		//判断当前要发送的是哪种类型的短信,不同的类型的验证码应该进行区分,这样可以提高用户体验;区分的参数由调用者(前端开发人员)传入,通常不会出现参数不存在的问题
		switch (type) {
			case "register":// 发送注册的短信验证码
			
				//核心业务隐身符1,用手机号去自己数据库查询当前用户是否存在,如果存在,则不能发送该类手机验证码,提示用户直接登录
				//······
				if(手机号已注册){//伪代码
					result.setSuccess(false);
					result.setMsg("当前手机号已注册,请直接登录");
					return result;
				}
				
				modelCode = "SMS_123456788";
				break;
				
			case "reset":// 发送重置登录密码的短信验证码
			
				//核心业务隐身符1,用手机号去自己数据库查询当前用户是否存在,如果不存在,则不能发送该类手机验证码,提示用户注册
				//·······
				if(手机号未注册){//伪代码
					result.setSuccess(false);
					result.setMsg("当前手机号未注册,请先注册");
					return result;
				}
 
				modelCode = "SMS_111111111";
				break;
	
			default:
				result.setSuccess(false);
				result.setMsg("非法请求");
				return result;
		}
 
		String mobileKey = type+"_mobile_" + mobile;
		String todayKey = "today_mobile_code_times_" + mobile;
 
		// 验证码三十分钟内有效,并且距离上一次发送要超过2分钟的时间才能重新发送
		Long times = RedisUtils.ttl(mobileKey);
		if (times > 60 * 28) {
			result.setSuccess(false);
			result.setMsg("距离您上次发送验证码不足两分钟,请两分钟后再尝试获取");
			return result;
		}
		
		// 判断当前手机号今天发送密码次数是否已达上线,每天15条(具体条数根据自己的需求调用)
		String todayTimes = RedisUtils.get(todayKey);
		int todayCount = 1;
		if (todayTimes != null) {
			todayCount = new Integer(todayTimes);
			if (todayCount >= 15) {
				
				//此时还可以记录当前用户的手机号,ip,调用的短信验证码类型到表中,方便系统记录与分析。系统可以分析该用户该周该月调用短信接口的次数
				//由此来分析该ip的用户是否是正常的用户,如果调用太频繁,比如连续一周或数周都在调用该接口,系统可以暂时禁用该ip发来的请求,或者降低该手机号获取短信验证码的次数
				//一般大网站通常都得使用大数据来监控了,而小网站,就没必要整的这么复杂了
				
				result.setSuccess(false);
				result.setMsg("当前手机号今日发送验证码已达上限,请明日再来");
				return result;
			}
			todayCount++;
		}
		
		//今天发送短信超过三次,再次调用接口时,需要调谷歌图形验证码
		if(todayCount>3){
			result.setStatus(1);//只要今日获取验证码次数超过三次,之后每次获取都要谷歌验证码,这个标识返回给前端,
													//前端看到这个值,需要调用谷歌图形验证码,待用户输入图形验证码后才能调用该接口
			
			if(StringUtils.isBlank(imgCode)){
				result.setSuccess(false);
				result.setMsg("为保证您账号安全,本次请求需要输入图形验证码");
				return result;
			}
			
			// 检验图形验证码
			String kapchatKey =type+ "_kaptcha_" + mobile;
			String kapchat = RedisUtils.get(kapchatKey);//获取redis数据库保存的谷歌图形验证码
			if (kapchat == null) {
				result.setMsg("图形验证码已失效,请重新输入");
				result.setSuccess(false);
				return result;
			} else if (!kapchat.equals(imgCode.toLowerCase())) {
				result.setSuccess(false);
				result.setMsg("您输入的验证码错误,请重新输入");
				return result;
			}
		}else if(todayCount==3){
			result.setStatus(1);//这已是第三次调用,下次调用时,就得传入谷歌验证码
		}
 
		String msg = "";//发送短信验证码是否成功与失败
 		
		try {
			
			String public=null;
			String private=null;
			Map map=null;
			
			//注册的验证码,同时还需要返回公钥
			if("register".equals(type)){
					public = "register_rsa_public_" + mobile;// redis保存的公钥的key
					private = "register_rsa_private_" + mobile;// redis中保存私钥的key
					if (RedisUtils.ttl(public) > 60 * 30 + 10) {// 原来的公钥有效时间大于30分钟,继续使用,避免浪费资源来频繁生成密钥对
						result.setStatus(RedisUtils.get(public));
					} else {
						// 生成公钥和私钥
						map = RSAEncrypt.genKeyPair();
						if (map == null || map.get(0) == null) {
							result.setSuccess(false);
							result.setMsg("生成公钥私钥失败,请联系管理员");//通常生成公钥私钥失败的情况是不会发生的,如果发生了,极有可能是jar包的问题
							return dg;
						}
					}
			}
			
			
			//发送短信验证码,请求成功后返回指定标识,请求失败,可以返回失败的信息,方便开发人员排查bug
			//此处使用的是阿里云的短信服务,你也可以使用其他的短信服务,此处不做赘述
			msg = MobileCodeUtils.sendCode(mobile, modelCode, mobileCode);//这个工具类和方法不分享
				
			logger.info("手机号:" + mobile + " 的验证码是:" + mobileCode);
 
			if (msg != null && "SUCCESS".equals(msg)) {	
				if(map!=null){
					RedisUtils.set(public, map.get(0), 60 * 60 * 2 + 10);// 2小时有效
					RedisUtils.set(private, map.get(1), 60 * 60 * 2 + 20);// 2小时有效
					result.setStatus(map.get(0));
				}
				
				result.setSuccess(true);
				result.setMsg("您的手机验证码发送成功,请注意查收,本验证码30分钟内有效");
 
				// 保存验证码到redis
				RedisUtils.set(mobileKey, mobileCode, 60 * 30 + 5);//redis中的code比实际要多5秒
 
				// 记录本号码发送验证码次数
				RedisUtils.set(todayKey, todayCount + "", MobileUtil.getSurplusTime());
 
				// 删除图形验证码
				RedisUtils.del(kapchatKey);
 
			} else {
				result.setSuccess(false);
				result.setMsg("短信验证码发送失败:" + msg);
				result.setStatus(null);
				return result;
			}
 
		} catch (Exception e) {
			result.setSuccess(false);
			result.setMsg("获取短信验证码异常:" + e.getMessage());
			logger.info("获取手机验证码异常:" + e.getMessage());
			return result;
		}
		
		//此处考虑添加操作流水,记录哪个手机号,哪个ip,哪个时间调用了哪种类型的接口
		//········
 
		return result;
	}

    //注册接口
	@Override
	public Result register(String mobile,String password,String code, HttpServletRequest request) {
		Result result = new Result();
		
		
		//判断当前IP今日访问注册接口的次数是否已达上限,如果是,就限制访问
		String ip=IpUtil.getIpAddr(request);//获取当前的ip地址值
		Long incr = RedisUtils.incrExpire(ip,MobileUtil.getSurplusTime());
		if(incr>200){//每日注册上限可根据各自的业务安排来完成
			
			//此处可以做一个记录,比如当前IP调用注册接口今日已达上限时,表中记录超标次数+1;明天再超标,记录数就再+1;记录本月该ip调用
			//该接口的上限次数,系统可以相应减少该ip每日的访问次数或直接拉入黑名单;记住每日只需+1;不是每次超标后的请求+1;
			//·········
			
			result.setSuccess(false);
			result.setMsg("您今日访问此接口次数已达上限,请明日再来");
			return result;
		}

		if (StringUtils.isBlank(mobile) || StringUtils.isBlank(password) || StringUtils.isBlank(code)) {
			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 mobileKey = "register_mobile_" + mobile;

		// 判断当前手机号是否已注册,防止重复注册,此时需要去数据库查询是否有该手机号
		//·······
		
		if (手机号已注册) {//伪代码
			RedisUtils.del(mobileKey);// 删除保存的手机验证码

			result.setMsg("当前手机号已注册,请直接登录");
			result.setSuccess(false);
			return result;
		}

		// 判断验证码是否正确,请记住,一定要将短信验证码的校验和具体的注册业务放在一起,我遇到的搞笑的做法是,
		//将校验短信验证码的操作放在另一个接口中单独进行,然后由前端控制校验成功后才调用真正的注册接口。
		//如果按照这种方法做注册,攻击者只需要绕开前端的校验,编写个for循环直接调用注册接口,你的用户表的数据不用半个小时就可能被撑爆
		String redisCode = RedisUtils.get(mobileKey);
		if (redisCode == null) {
			result.setStatus(1);// 需要重新获取手机验证码
			result.setSuccess(false);
			result.setMsg("当前验证码已失效,请重新获取");
			return result;
		} else if (!redisCode.equals(code.trim())) {
			result.setSuccess(false);
			result.setMsg("您输入的验证码错误,请重新输入");
			return result;
		}

		// 获取当前私钥
		// 从redis数据库获取私钥
		String private = "register_rsa_private_" + mobile;
		String public = "register_rsa_public_" + mobile;
		String privateKey = RedisUtils.get(private);
		if (StringUtils.isBlank(privateKey)) {
			RedisUtils.del(mobileKey);
			RedisUtils.del(private);
			RedisUtils.del(public);

			result.setStatus(2);// 公钥失效,通知前端提醒用户再次获取短信验证码
			result.setSuccess(false);
			result.setMsg("密钥验证失败,请重新获取验证码后再注册");
			return result;
		}

		// 使用私钥解密后的登录密码
		password = RSAEncrypt.decrypt(password.trim(), privateKey);
		if (password == null) {
			RedisUtils.del(mobileKey);
			RedisUtils.del(private);
			RedisUtils.del(public);

			result.setStatus(3);// 私钥解密密码失败,通常为非法请求,按实际业务选择处理
			result.setSuccess(false);
			result.setMsg("私钥解密失败,您的请求被拒绝处理");
			return result;
		}

		// 判断用户登录的密码是否合法。使用公钥加密用户注册时的密码的另一个原因,是希望传输过程中密码不是密码显示,
		//而传到后台的时候系统还能获取到明文密码,然后对该密码格式进行后端的校验。还是那句老话,永远不要过度相信
		//前端传来的数据,尤其是敏感信息
		if (!MobileUtil.certifyLoginSecret(password)) {
			result.setSuccess(false);
			result.setMsg("登录密码格式不正确,请输入某某格式的密码");//密码格式错误
			return result;
		}

		//校验成功,执行真正的用户注册操作,比如往用户表中添加手机号,密码等信息,需记住不能直接将明码保存到数据库中,
		//当用户登录时,只需将用户传入的密码用你注册时的加密规律加密一遍之后,再与数据库保存到的密码比对是否一致即可。具体根据你自己的业务来添加
		//········

		// 删除验证码、公钥、私钥
		RedisUtils.del(mobileKey);
		RedisUtils.del(private);
		RedisUtils.del(public);

		//最后别忘了添加操作流水
		//·······
		

		result.setSuccess(true);
		result.setMsg("注册成功,请前往登录");

		return result;
	}

}

 

工具类RedisUtils.java类请参照我的这篇博客https://blog.csdn.net/weixin_42023666/article/details/89287418

手机短信接口的安全防护可参照这篇博客https://blog.csdn.net/weixin_42023666/article/details/89680342

第三方短信工具类的此处不分享,大家只需要复用自己原来的短信接口即可。

 

工具类MobileUtil.java的代码如下:

package sy.util.mobile;

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

 

暂时分享到这里,等有更完美的方案再补充!下一篇分享用户登录接口相关的。

你可能感兴趣的:(Java后端)