本文特点: 针对验证码的生成做了很多算法优化和调整,支持一个典型的验证码生成和验证流程,利用缓存服务器解决聚群部署架构中数据同步的问题。
基本需求:
- 登录页面显示一个随机验证码图片、有背景干扰
- 用户输入验证码大于3位之后,开始实时验证正确性,如果正确则在输入框后面提示(比如“√”)
- 登录时,后台检查验证码是否正确
- 支持服务器集群部署的架构
(绝对原创,转载请注明转自Clement-Xu的博客:
http://blog.csdn.net/clementad/article/details/48788361)
交互过程和设计思路:
- 前端打开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);
}
- 其他代码(通过Jedis访问Redis、处理cookie:参考http://blog.csdn.net/clementad/article/details/48472013、等):代码略