web登录:随机验证码的设计和实现

本文特点: 针对验证码的生成做了很多算法优化和调整,支持一个典型的验证码生成和验证流程,利用缓存服务器解决聚群部署架构中数据同步的问题。

基本需求:
  • 登录页面显示一个随机验证码图片、有背景干扰
  • 用户输入验证码大于3位之后,开始实时验证正确性,如果正确则在输入框后面提示(比如“√”)
  • 登录时,后台检查验证码是否正确
  • 支持服务器集群部署的架构

(绝对原创,转载请注明转自Clement-Xu的博客: http://blog.csdn.net/clementad/article/details/48788361

交互过程和设计思路:
  1. 前端打开login页面时,向后端请求一个验证码图片;login页面点击验证码图片时,重新请求一个新图片
  2. 后端处理验证码图片请求:
    1. 随机生成一个字符串
    2. 随机生成一个UUID作为cookie的值,放进HttpServletResponse中送给前端
    3. 把cookie的值作为key、随机字符串作为value存进Redis中,设置过期时间(比如2分钟)
    4. 根据字符串生成一个图片:
      1. 随机打印一些小字母或数字作为图片的背景
      2. 把字符串拆成独立的字母,每个字母在一定范围内随机上下左右偏移,并旋转一个角度
      3. 通过调整偏移量和角度防止字符位置超出图片范围
    5. 把图片通过HttpServletResponse发送给前端
  3. 前端监控用户的输入,当验证码输入框中的文本长度大于3的时候,通过Ajax发送给后端校验
  4. 后端处理校验请求:
    1. 通过HttpServletRequest获取之前设置的cookie的值,把它作为key去Redis中获取value
    2. 比较Redis中获取的value和前端发送来的字符串,如果匹配,则同样以cookie的值作为key在Redis中写入验证通过的标识(比如"1",同时设置过期时间,比如2分钟),否则写入或清空Redis中之前写入的标识
    3. 返回成功或失败的结果给前端
  5. 前端根据校验结果设置输入框后面的提示标识(比如“√”)
  6. 前端提交登录表单,此时不需要提交用户输入的验证码,因为后端已经根据cookie记录验证码是否正确
  7. 后端处理用户的登录表单:从cookie中取出值后,去Redis查找是否通过的标识(4.2中设置的),如果找不到成功的标识,则提示用户“验证码错误或已经过期”
  8. 前端判断登录是否成功,如果失败则重新向后端请求一个验证码图片、自动刷新验证码

效果图:

单独的验证码大图:


代码实现:
  • 前端页面
<html>
<head>
	<meta charset="UTF-8">
	<title>登录</title>
	
	<script src="./resources/js/jquery-1.11.3.js" charset="UTF-8" type="text/javascript"></script>
	<script src="./resources/js/sys.js" charset="UTF-8" type="text/javascript"></script>
	<script src="./resources/js/md5.js" charset="UTF-8" type="text/javascript"></script>
	
	<link href="./resources/css/common.css" type="text/css" rel="stylesheet" />
	
	<style>
		.captcha_img{cursor:pointer;height:40px; width:173px;}
	</style>
	
</head>
<body>
	<form name="loginForm">
		<div>
			<div>请登录:</div>
			<div>
				<input type="text" maxlength="20" name="userAccount" placeholder="用户名" />
			</div>
			<div>
				<input type="password" name="password" placeholder="密码" />
			</div>
			<div>
				<input type="text" id="captcha" placeholder="验证码"/>
				<span id="captchaChecked" style="display:none;color:green;font-weight:bold">√</span>
			</div>
			<div>
				<img class="captcha_img" id="captchaImg" alt="点击刷新验证码">
			</div>
			<div>
				<button type="button" id="submit">登录</button>
			</div>
		</div>
	</form>
	
	<script>
		var captchaChecked = false;
		
		$(function() {
			refreshCaptcha();
			
			$("#captcha").on("keyup", checkCaptchaInput);
			$("#captchaImg").on("click", refreshCaptcha);
			$("#submit").on("click", goLogin);
		});

		function checkCaptchaInput(){
			var captchaText =$(this).val() 
			if(captchaText.length <=3 ){ //验证码一般大于三位
				$("#captchaChecked").hide();
				return;
			}
			
			ajaxRequest("/servlet/auth/verifyCaptcha", {captcha : captchaText},
				function callback(result) {
					if(result.code == "40001"){
						if(result.data==true){
							$("#captchaChecked").show();
							captchaChecked = true;
						}else{
							$("#captchaChecked").hide();
							captchaChecked = false;
						}
					}else{
						alert(result.message);
					}
				});
				
				if(event.keyCode==13){
					goLogin();
				}
		}

		function goLogin() {
			if(!captchaChecked){
				alert("请输入正确的验证码!");
				return;
			}
		
			var params = $("form").serializeObject();
			params.password = md5(params.password);
			ajaxRequest("/servlet/auth/webLogin", params,
				function callback(result) {
					if(result.code == "40001"){
						alert("登录成功");
						history.go(-1);
					}
				},
				function errorCallback(){ //发生错误,刷新验证码
					refreshCaptcha();
				});
		}

		function refreshCaptcha() {
			//重载验证码
			$('#captchaImg').attr('src', getApiRoot()+'/servlet/auth/captcha?' + Math.random());
		}
	</script>
</body>
</html>

  • 生成随机验证码和图片的工具类
	static char[] chars = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 
            'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 
            'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
	/**
	 * 生成一个位数为count的随机验证码
	 * @param count
	 * @return
	 */
	public static String genCaptcha(int count) {
		StringBuilder captcha = new StringBuilder();
		
		for(int i=0; i<count; i++){
			char c = chars[ThreadLocalRandom.current().nextInt(chars.length)];//随机选取一个字母或数字
			captcha.append(c);
		}
		return captcha.toString();
	}
	
	/**
	 * 为一个验证码生成一个图片
	 * 
	 * 特性:
	 * - 颜色随机
	 * - 上下位置随机
	 * - 左右位置随机,但字符之间不会重叠
	 * - 左右随机旋转一个角度
	 * - 避免字符出界
	 * - 随机颜色的小字符做背景干扰
	 * - 根据字符大小自动调整图片大小、自动计算干扰字符的个数
	 * 
	 * @author XuJijun
	 *  
	 * @param captcha
	 * @return
	 */
	public static BufferedImage genCaptchaImg(String captcha){
		ThreadLocalRandom r = ThreadLocalRandom.current();

		int count = captcha.length();
		int fontSize = 80; //code的字体大小
		int fontMargin = fontSize/4; //字符间隔
		int width = (fontSize+fontMargin)*count+fontMargin; //图片长度
		int height = (int) (fontSize*1.2); //图片高度,根据字体大小自动调整;调整这个系数可以调整字体占图片的比例
		int avgWidth = width/count;	//字符平均占位宽度
		int maxDegree = 26;	//最大旋转度数
		
		//背景颜色
		Color bkColor = Color.WHITE;
		//验证码的颜色
		Color[] catchaColor = {Color.MAGENTA, Color.BLACK, Color.BLUE, Color.CYAN, Color.GREEN, Color.ORANGE, Color.PINK};
		
		BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
		Graphics2D g = image.createGraphics();
		
		//填充底色为灰白
		g.setColor(bkColor);
		g.fillRect(0, 0, width, height);
		
		//画边框
		g.setColor(Color.BLACK);
		g.drawRect(0, 0, width-1, height-1);
		
		//画干扰字母、数字
		int dSize = fontSize/3; //调整分母大小以调整干扰字符大小
		Font font = new Font("Fixedsys", Font.PLAIN, dSize);
		g.setFont(font);
		int dNumber = width*height/dSize/dSize;//根据面积计算干扰字母的个数
		for(int i=0; i<dNumber; i++){
			char d_code = chars[r.nextInt(chars.length)];
			g.setColor(new Color(r.nextInt(255),r.nextInt(255),r.nextInt(255)));
			g.drawString(String.valueOf(d_code), r.nextInt(width), r.nextInt(height));
		}
		
		//开始画验证码:
		
		// 创建字体   
		font = new Font(Font.MONOSPACED, Font.ITALIC|Font.BOLD, fontSize);
		// 设置字体     
		g.setFont(font);

		for(int i=0; i<count; i++){
			char c = captcha.charAt(i);
			g.setColor(catchaColor[r.nextInt(catchaColor.length)]);//随机选取一种颜色
			
			//随机旋转一个角度[-maxDegre, maxDegree]
			int degree = r.nextInt(-maxDegree, maxDegree+1);
			
			//偏移系数,和旋转角度成反比,以避免字符在图片中越出边框
			double offsetFactor = 1-(Math.abs(degree)/(maxDegree+1.0));//加上1,避免出现结果为0
			
			g.rotate(degree * Math.PI / 180); //旋转一个角度
			int x = (int) (fontMargin + r.nextInt(avgWidth-fontSize)*offsetFactor); //横向偏移的距离
			int y = (int) (fontSize + r.nextInt(height-fontSize)*offsetFactor); //上下偏移的距离
			
			g.drawString(String.valueOf(c), x, y); //x,y是字符的左下角,偏离原点的距离!!!
			
			g.rotate(-degree * Math.PI / 180); //画完一个字符之后,旋转回原来的角度
			g.translate(avgWidth, 0);//移动到下一个画画的原点
			//System.out.println(c+": x="+x+" y="+y+" degree="+degree+" offset="+offsetFactor);
			
			//X、Y坐标在合适的范围内随机,不旋转:
			//g.drawString(String.valueOf(c), width/count*i+r.nextInt(width/count-fontSize), fontSize+r.nextInt(height-fontSize));
		}
		
		g.dispose();
		
		return image;
	}


  • 后端常量定义
	//web登录相关:
	/** 验证码,Hash类型, 后面跟着cookie Id */
	public static final String CAPTCHA = "captcha:";
	/** 验证码,field,验证码内容*/
	public static final String CAPTCHA_CODE = "code";
	/** 验证码,field,验证码是否已经验证过 */
	public static final String CAPTCHA_CHECKED = "checked";
	/** 验证码失效时间,分钟 */
	public static final int CAPTCHA_EXPIRED = 2;

  • 后端Controller代码
@RestController
@RequestMapping("/servlet/auth")
public class AuthController {
	@RequestMapping(value = "/captcha")
	public void captcha(HttpServletRequest request, HttpServletResponse response){
		try {
			//把校验码转为图像
			BufferedImage image = authService.genCaptcha(response);
			
			response.setContentType("image/jpeg");
	        
	        //输出图像
			ServletOutputStream outStream = response.getOutputStream();
			ImageIO.write(image, "jpeg", outStream);
			outStream.close();
		} catch (Exception ex) {
			logger.error(ex.getMessage(), ex);
		}
	}

	/**
	 * 检查验证码是否正确
	 * @param request
	 * @param map
	 * @return
	 */
	@RequestMapping(value = "/verifyCaptcha", method = RequestMethod.POST)
	public JsonResult verifyCaptcha(HttpServletRequest request, @RequestBody Map<String, String> map) {
		try {
			return authService.verifyCaptcha(request, map);
		} catch (Exception ex) {
			logger.error(ex.getMessage(), ex);
			return new JsonResult(ReturnCode.EXCEPTION, "检查验证码失败!", null);
		}
	}
	
	/**
	 * 用户登录(web)
	 * 
	 * @param map(userName, password)
	 * @return
	 */
	@RequestMapping(value = "/webLogin", method = RequestMethod.POST)
	public JsonResult webLogin(HttpServletRequest request, HttpServletResponse response, @RequestBody Map<String, String> map) {
		try {
			//获取验证码的cookie id
			String captchaCookie = WebUtils.getCookieByName(request, "YsbCaptcha");
			map.put("captchaCookie", captchaCookie);
			
			return authService.webLogin(map, response, true);
		} catch (Exception ex) {
			logger.error(ex.getMessage(), ex);
			return new JsonResult(ReturnCode.EXCEPTION, "用户登录失败!", null);
		}
	}
}

  • 后端Service代码
	/**
	 * 用户web登录,response用于写入token到cookie中
	 */
	@Override
	public JsonResult webLogin(Map<String, String> map,	HttpServletResponse response, boolean captchaRequired) {
		
		//检查验证码是否已经被验证过
		if(captchaRequired){
			String cookieId = map.get("captchaCookie");
			String storedCaptcha = redisOperator.hget(RedisConstants.CAPTCHA+cookieId, RedisConstants.CAPTCHA_CHECKED);
			if(!"1".equals(storedCaptcha)){
				return new JsonResult(ReturnCode.ERROR, "验证码错误或已经过期!", null);
			}
		}
		
		// 验证用户名密码
		String userAccount = map.get("userAccount");
		String pw = map.get("password");
		
		if(StringUtils.isEmpty(userAccount) || StringUtils.isEmpty(pw)){
			return new JsonResult(ReturnCode.PARAMSERROR, "用户名或密码不能为空!", null);
		}
		
		TabUser user = checkUserExist(userAccount, 1);
		
		if(user==null || !user.getPassword().equals(EncryptUtils.MD5Str(pw + user.getLoginSalt()))){//前端送过来的是md5编码后的密码,小写
			return new JsonResult(ReturnCode.ERROR, "用户名或密码错误!", null);
		}
		
		
		// TODO 生成token,
		
		// TODO token存进redis,过期时间为24小时(不可能有人连续24小时不关浏览器的吧?)
		
		// TODO token放进response的cookie中,maxAge=0表示关闭浏览器后cookie就失效
		
		// TODO 记录登录日志
		
		return new JsonResult(ReturnCode.SUCCESS, "登录成功", null);
	}

	
	/**
	 * 生成一个验证码并保存到redis中
	 */
	@Override
	public BufferedImage genCaptcha(HttpServletResponse response) {
	
		//生成一个校验码
		String captcha = WebUtils.genCaptcha(5);
		
		//生成一个cookie ID,并塞进response里面
		String cookieId = UUID.randomUUID().toString().replace("-", "").toUpperCase();
		WebUtils.addCookie(response, "YsbCaptcha", cookieId, RedisConstants.CAPTCHA_EXPIRED);
		
		//把校验码、是否已经通过校验(缺省不设置为0)保存到redis中,以cookie ID 为key
		redisOperator.hset(RedisConstants.CAPTCHA+cookieId, RedisConstants.CAPTCHA_CODE, captcha, RedisConstants.CAPTCHA_EXPIRED*60);
		
		//把校验码转为图像
		BufferedImage image = WebUtils.genCaptchaImg(captcha);
		return image;
	}

	/**
	 * 检查验证码是否正确,并把结果写入redis中
	 */
	@Override
	public JsonResult verifyCaptcha(HttpServletRequest request, Map<String, String> map) {
		
		String receivedCaptcha = map.get("captcha"); 
		
		if(!StringUtils.isEmpty(receivedCaptcha)){
			String cookieId = WebUtils.getCookieByName(request, "YsbCaptcha");
			String storedCaptcha = redisOperator.hget(RedisConstants.CAPTCHA+cookieId, RedisConstants.CAPTCHA_CODE);
			if(receivedCaptcha.toUpperCase().equals(storedCaptcha)){
				redisOperator.hset(RedisConstants.CAPTCHA+cookieId, RedisConstants.CAPTCHA_CHECKED, "1", RedisConstants.CAPTCHA_EXPIRED*60);
				return new JsonResult(ReturnCode.SUCCESS, "有效的验证码。", true);
			}else{
				redisOperator.hset(RedisConstants.CAPTCHA+cookieId, RedisConstants.CAPTCHA_CHECKED, "0", RedisConstants.CAPTCHA_EXPIRED*60);
			}
		}
		
		return new JsonResult(ReturnCode.SUCCESS, "无效的验证码。", false);
	}




  • 12
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值