转自
Clement-Xu的博客:
http://blog.csdn.net/clementad/article/details/48788361
基本需求:
- 登录页面显示一个随机验证码图片、有背景干扰
- 用户输入验证码大于3位之后,开始实时验证正确性,如果正确则在输入框后面提示(比如“√”)
- 登录时,后台检查验证码是否正确
- 支持服务器集群部署的架构
交互过程和设计思路:
- 前端打开login页面时,向后端请求一个验证码图片;login页面点击验证码图片时,重新请求一个新图片
- 后端处理验证码图片请求:
- 随机生成一个字符串
- 随机生成一个UUID作为cookie的值,放进HttpServletResponse中送给前端
- 把cookie的值作为key、随机字符串作为value存进Redis中,设置过期时间(比如2分钟)
- 根据字符串生成一个图片:
- 随机打印一些小字母或数字作为图片的背景
- 把字符串拆成独立的字母,每个字母在一定范围内随机上下左右偏移,并旋转一个角度
- 通过调整偏移量和角度防止字符位置超出图片范围
- 把图片通过HttpServletResponse发送给前端
- 前端监控用户的输入,当验证码输入框中的文本长度大于3的时候,通过Ajax发送给后端校验
- 后端处理校验请求:
- 通过HttpServletRequest获取之前设置的cookie的值,把它作为key去Redis中获取value
- 比较Redis中获取的value和前端发送来的字符串,如果匹配,则同样以cookie的值作为key在Redis中写入验证通过的标识(比如"1",同时设置过期时间,比如2分钟),否则写入或清空Redis中之前写入的标识
- 返回成功或失败的结果给前端
- 前端根据校验结果设置输入框后面的提示标识(比如“√”)
- 前端提交登录表单,此时不需要提交用户输入的验证码,因为后端已经根据cookie记录验证码是否正确
- 后端处理用户的登录表单:从cookie中取出值后,去Redis查找是否通过的标识(4.2中设置的),如果找不到成功的标识,则提示用户“验证码错误或已经过期”
- 前端判断登录是否成功,如果失败则重新向后端请求一个验证码图片、自动刷新验证码
效果图:
单独的验证码大图:
代码实现:
- 前端页面
- <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);
- }