最近的一个项目需要实现双因素强认证,平常我们都是采用 静态密码+动态短信这样的方式来实现,但用户侧并没有相应的短信接口。 后来决定采用 google身份验证器来实现。在网上找了一些资料和代码片段,经过梳理和改造,目前已上线使用了,效果还是比较好的,记录一下,也给需要的朋友做个参考。
首先简述一下双因素认证:双因素身份认证就是通过你所知道再加上你所能拥有的这二个要素组合到一起才能发挥作用的身份认证系统。双因素认证是一种采用时间同步技术的系统,采用了基于时间、事件和密钥三变量而产生的一次性密码来代替传统的静态密码。每个动态密码卡都有一个唯一的密钥,该密钥同时存放在服务器端,每次认证时动态密码卡与服务器分别根据同样的密钥,同样的随机参数(时间、事件)和同样的算法计算了认证的动态密码,从而确保密码的一致性,从而实现了用户的认证。----引自百度百科
实现原理可以参考:http://www.zhihu.com/question/20462696 http://blog.seetee.me/archives/73.html
主要实现思路:
1: 当用户 登录系统,先经过用户名和密码验证后,再进行动态验证,查询用户的Google密钥是否启用(密钥信息在数据库中保存);
2:如果没有Google密钥,则将页面重定向到启用密钥的页面;
2.1 : 调用 GoogleAuthenticator生成google密钥,并将密钥写入二维码中,提示用户使用Google验证器扫描二维码;
2.2 : 用户使用Google身份验证器扫描二维码,用户的手机中即可生成动态验证码;
2.3 : 为了确保二维码密钥和用户手机上时间与服务器基本一致,需要让用户验证生成的动态验证码是否可以通过系统的验证(验证过程同登录过程,暂且不表);
3:如果用户已有Google密钥,用户打开手机上的Google身份验证器,找到对应的帐号生成的动态码,输入系统;
4: 调用 GoogleAuthenticator 密钥验证接口,判断是否验证通过;
4.1: 验证通过,执行系统登录的相关流程;
4.2: 验证不通过,提示用户:查验手机时间 与服务器的时间是否一致或者帐号与动态验证码的对应关系是否准确(如果用户手机上有多个帐号时,经常出现这种问题)
待完善功能:如果用户手机丢失,可信用户重置google密钥;
以上,仅是我的一个实现思路,如果大家有好的想法,欢迎交流。
以下为两个重要类的代码,供参考。
如果需要完整的项目代码,请移步:基于Google 验证器 实现内网的双因素认证项目 ,由于本代码为实际项目的代码片段,经过删减后的版本,比较混乱(但可以正常运行),所以不建议大家下载,
谷歌身份验证的Java服务器端:
package com.google.module.authenticator.utils; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.apache.commons.codec.binary.Base32; import org.apache.commons.codec.binary.Base64; import com.google.framework.QuickResponse.QRUtil; /** * 谷歌身份验证的Java服务器端 */ public class GoogleAuthenticator { // 来自谷歌文档,不用修该 public static final int SECRET_SIZE = 10; public static final String SEED = "g8GjEvTbW5oVSV7avLBdwIHqGlUYNzKFI7izOF8GwLDVKs2m0QN7vxRs2im5MDaNCWGmcD2rvcZx"; public static final String RANDOM_NUMBER_ALGORITHM = "SHA1PRNG";//安全哈希算法(Secure Hash Algorithm) int window_size = 3; //默认 3 - 最大值17 (from google docs)多可偏移的时间--3*30秒的验证时间(手机客户端验证为30秒变化次) /** * 设置偏移量,最大值17 * 默认 3 - 最大值17 (from google docs)多可偏移的时间--3*30秒的验证时间(手机客户端验证为30秒变化次) * @param s */ public void setWindowSize(int s) { if (s >= 1 && s <= 17) window_size = s; } /**秘钥 * 随机生成1个秘钥,这个秘钥必须在服务器上保存,用户在手机Google身份验证器上配置账号时也要这个秘钥 * @return secret key */ public static String generateSecretKey() { SecureRandom sr = null; try { sr = SecureRandom.getInstance(RANDOM_NUMBER_ALGORITHM); byte[] seedBytes = SEED.getBytes(); sr.setSeed(Base64.decodeBase64(seedBytes)); byte[] buffer = sr.generateSeed(SECRET_SIZE); Base32 codec = new Base32(); byte[] bEncodedKey = codec.encode(buffer); String encodedKey = new String(bEncodedKey); return encodedKey; }catch (NoSuchAlgorithmException e) { // should never occur... configuration error } return null; } /** *返回一个URL生成并显示二维码。用户扫描这个二维码 *谷歌身份验证应用程序的智能手机注册身份验证代码 *他们还可以手动输入秘钥key * @param user * @param host * @param secret 之前为用户生成的秘钥 * @return 二维码的url */ /** * @Definition: * @author: TangWenWu * @Created date: 2014-11-24 * @param user * @param host * @param secret * @return */ public static String getQRBarcodeURL(String user, String host, String secret) { /* * 由于是内网系统,无法访问google,所以注释 * String format = "https://www.google.com/chart?chs=200x200&chld=M%%7C0&cht=qr&chl=otpauth://totp/%[email protected]%s%%3Fsecret%%3D%s"; * return String.format(format, user, host, secret); */ //采用com.google.zxing 自己生成 二维码图片,保存在项目某个目录下 // 二维码内容 String content = "otpauth://totp/"+user+"@"+host+"?secret="+secret; // 二维码宽度 int width = 300; // 二维码高度 int height = 300; // 二维码存放地址 String imageName = "googleAuthCode_"+user+"_"+host+".png"; return QRUtil.generateImageInBorderQR(content, width, height, imageName); } public static void main(String[] args) { String path = getQRBarcodeURL("tangww","ROOT","WERRWEIU23424U"); System.out.println("path======================="+path); } /** * 查用户输入的6位码是否有效 * @param secret 秘钥 * @param code 6位码 * @param t 偏移时间 * @return */ public boolean check_code(String secret, long code, long timeMsec) { Base32 codec = new Base32(); byte[] decodedKey = codec.decode(secret); // convert unix msec time into a 30 second "window" // this is per the TOTP spec (see the RFC for details) long t = (timeMsec / 1000L) / 30L; // window是用来检验之前生成的6位码 // 可以用这个window_size来调整允许6位码生效的时间 for (int i = -window_size; i <= window_size; ++i) { long hash; try { hash = verify_code(decodedKey, t + i); }catch (Exception e) { // Yes, this is bad form - but // the exceptions thrown would be rare and a static configuration problem e.printStackTrace(); throw new RuntimeException(e.getMessage()); //return false; } if (hash == code) { return true; } } // The validation code is invalid. return false; } /** * 生成验证码 * @param key * @param t * @return * @throws NoSuchAlgorithmException * @throws InvalidKeyException */ private static int verify_code(byte[] key, long t) throws NoSuchAlgorithmException, InvalidKeyException { byte[] data = new byte[8]; long value = t; for (int i = 8; i-- > 0; value >>>= 8) { data[i] = (byte) value; } SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA1"); Mac mac = Mac.getInstance("HmacSHA1"); mac.init(signKey); byte[] hash = mac.doFinal(data); int offset = hash[20 - 1] & 0xF; // We're using a long because Java hasn't got unsigned int. long truncatedHash = 0; for (int i = 0; i < 4; ++i) { truncatedHash <<= 8; // We are dealing with signed bytes: // we just keep the first byte. truncatedHash |= (hash[offset + i] & 0xFF); } truncatedHash &= 0x7FFFFFFF; truncatedHash %= 1000000; return (int) truncatedHash; } }
生成二维码密钥类
package com.google.framework.QuickResponse; import java.awt.AlphaComposite; import java.awt.Color; import java.awt.Graphics2D; import java.awt.Image; import java.awt.geom.AffineTransform; import java.awt.image.AffineTransformOp; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Hashtable; import javax.imageio.ImageIO; import com.google.framework.utils.FilePathUtil; import com.google.zxing.BarcodeFormat; import com.google.zxing.BinaryBitmap; import com.google.zxing.DecodeHintType; import com.google.zxing.EncodeHintType; import com.google.zxing.LuminanceSource; import com.google.zxing.MultiFormatReader; import com.google.zxing.MultiFormatWriter; import com.google.zxing.ReaderException; import com.google.zxing.Result; import com.google.zxing.WriterException; import com.google.zxing.client.j2se.BufferedImageLuminanceSource; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.common.HybridBinarizer; import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; /** * @Description:二维码图片工具类 使用了两张工具图 名称是 heihei.png qr_bg.png * @Copyright (C) 2014 BOCO All Right Reserved. * @createDate:2014-11-24 * @author:TangWenWu * @version 1.0 * * * * */ public class QRUtil { /** * 编码(将文本生成二维码) * * @param content * 二维码中的内容 * @param width * 二维码图片宽度 * @param height * 二维码图片高度 * @param imagePath * 二维码图片存放位置 * @return 图片地址 */ private static String encode(String content, int width, int height, String imagePath) { Hashtable<EncodeHintType, Object> hints = new Hashtable<EncodeHintType, Object>(); // 设置编码类型为utf-8 hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); // 设置二维码纠错能力级别为H(最高) hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); BitMatrix byteMatrix = null; try { // 生成二维码 byteMatrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints); File file = new File(imagePath); MatrixToImageWriter.writeToFile(byteMatrix, "png", file); } catch (IOException e) { e.printStackTrace(); } catch (WriterException e) { e.printStackTrace(); } return imagePath; } /** * 解码(读取二维码图片中的文本信息) * * @param imagePath * 二维码图片路径 * @return 文本信息 */ private static String decode(String imagePath) { // 返回的文本信息 String content = ""; try { // 创建图片文件 File file = new File(imagePath); if (!file.exists()) { return content; } BufferedImage image = null; image = ImageIO.read(file); if (null == image) { return content; } // 解码 LuminanceSource source = new BufferedImageLuminanceSource(image); BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); Hashtable hints = new Hashtable(); hints.put(DecodeHintType.CHARACTER_SET, "UTF-8"); Result rs = new MultiFormatReader().decode(bitmap, hints); content = rs.getText(); } catch (IOException e) { e.printStackTrace(); } catch (ReaderException e) { e.printStackTrace(); } return content; } /** * 图片打水印 * * @param bgImage * 背景图 * @param waterImg * 水印图 * @param uniqueFlag * 生成的新图片名称中的唯一标识,用来保证生成的图片名称不重复,如果为空或为null,将使用当前时间作为标识 * @return 新图片路径 */ private static String addImageWater(String bgImage, String waterImg, String uniqueFlag) { int x = 0; int y = 0; String newImgPath = ""; if (null == uniqueFlag) { uniqueFlag = new SimpleDateFormat("yyyyMMddHHmmss") .format(new Date()); } else if (uniqueFlag.trim().length() < 1) { uniqueFlag = new SimpleDateFormat("yyyyMMddHHmmss") .format(new Date()); } try { File file = new File(bgImage); String fileName = file.getName(); Image image = ImageIO.read(file); int width = image.getWidth(null); int height = image.getHeight(null); BufferedImage bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = bufferedImage.createGraphics(); g.drawImage(image, 0, 0, width, height, null); Image waterImage = ImageIO.read(new File(waterImg)); // 水印文件 int width_water = waterImage.getWidth(null); int height_water = waterImage.getHeight(null); g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 1)); int widthDiff = width - width_water; int heightDiff = height - height_water; x = widthDiff / 2; y = heightDiff / 2; g.drawImage(waterImage, x, y, width_water, height_water, null); // 水印文件结束 g.dispose(); if (bgImage.contains(fileName)) { newImgPath = bgImage.replace(fileName, uniqueFlag + fileName); } File newImg = new File(newImgPath); ImageIO.write(bufferedImage, "png", newImg); File waterFile = new File(waterImg); if (file.exists()) { file.delete(); } if (waterFile.exists()) { waterFile.delete(); } } catch (IOException e) { e.printStackTrace(); } return newImgPath; } /** * 图片缩放 * * @param filePath * 图片路径 * @param height * 缩放到高度 * @param width * 缩放宽度 * @param fill * 比例足时是否填白 true为填白,二维码是黑白色,这里调用时建议设为true * @return 新图片路径 */ private static String resizeImg(String filePath, int width, int height, boolean fill) { String newImgPath = ""; try { double ratio = 0; // 缩放比例 File f = new File(filePath); String fileName = f.getName(); BufferedImage bi = ImageIO.read(f); Image itemp = bi.getScaledInstance(width, height, BufferedImage.SCALE_SMOOTH); if (height != 0 && width != 0) { // 计算比例 if ((bi.getHeight() > height) || (bi.getWidth() > width)) { if (bi.getHeight() > bi.getWidth()) { ratio = (new Integer(height)).doubleValue() / bi.getHeight(); } else { ratio = (new Integer(width)).doubleValue() / bi.getWidth(); } AffineTransformOp op = new AffineTransformOp( AffineTransform.getScaleInstance(ratio, ratio), null); itemp = op.filter(bi, null); } } if (fill) { BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); g.setColor(Color.white); g.fillRect(0, 0, width, height); if (width == itemp.getWidth(null)) { g.drawImage(itemp, 0, (height - itemp.getHeight(null)) / 2, itemp.getWidth(null), itemp.getHeight(null), Color.white, null); } else { g.drawImage(itemp, (width - itemp.getWidth(null)) / 2, 0, itemp.getWidth(null), itemp.getHeight(null), Color.white, null); } g.dispose(); itemp = image; } String now = new SimpleDateFormat("yyyyMMddHHmmss") .format(new Date()); if (filePath.contains(fileName)) { newImgPath = filePath.replace(fileName, now + fileName); } File newImg = new File(newImgPath); ImageIO.write((BufferedImage) itemp, "png", newImg); } catch (IOException e) { e.printStackTrace(); } return newImgPath; } /** * 图片添加边框 * * @param mainImgPath * 要加边框的图片 * @param bgImgPath * 背景图(实际上是将图片放在背景图上,只利用背景图的边框效果) * @return 制作完成的图片路径 */ private static String addWaterBorder(String mainImgPath, String bgImgPath) { String borderImgPath = ""; try { File f = new File(mainImgPath); BufferedImage bi; bi = ImageIO.read(f); // 背景图长宽都比主图多4像素,这是因为我画的背景图的边框效果的大小正好是4像素, // 主图周边比背景图少4像素正好能把背景图的边框效果完美显示出来 int width = bi.getWidth(); int height = bi.getHeight(); int bgWidth = width + 4; int bgHeight = height + 4; String now = new SimpleDateFormat("yyyyMMddHHmmss") .format(new Date()); borderImgPath = QRUtil.addImageWater(QRUtil.resizeImg(bgImgPath, bgHeight, bgWidth, true), mainImgPath, now); if (f.exists()) { f.delete(); } } catch (IOException e) { e.printStackTrace(); } return borderImgPath; } /** * @Definition: 生成常规二维码 * @author: TangWenWu * @Created date: 2014-11-25 * @param content 二维码中的内容 * @param width 二维码宽度 单位:像素 建议300 * @param height 二维码高度 单位:像素 建议300 * @param imageName 二维码图片名称 生成路径为:appName/WebRoot/指定的目录/imageName * @return */ public static String generateCommonQR(String content,int width,int height,String imageName){ String appPath = FilePathUtil.getResourcePath(); /** 部分一开始***********生成常规二维码 *************/ // 二维码存放地址 imageName = appPath+"templates/qr/"+imageName; // 生成二维码,返回的是生成好的二维码图片的所在路径 String qrImgPath = QRUtil.encode(content, width, height, imageName); /** 部分一结束***********如果生成不带图片的二维码,到这步已经完成了 *************/ return qrImgPath; } /** * @Definition: 生成带图片但图片不带边框的二维码 * @author: TangWenWu * @Created date: 2014-11-25 * @param content 二维码中的内容 * @param width 二维码宽度 单位:像素 建议300 * @param height 二维码高度 单位:像素 建议300 * @param imageName 二维码图片名称 生成路径为:appName/WebRoot/指定的目录/imageName * @return */ public static String generateOnlyImageQR(String content,int width,int height,String imageName){ String appPath = FilePathUtil.getResourcePath(); String qrImgPath = generateCommonQR(content,width,height,imageName); // 缩放水印图片,为保证二维码的读取正确,图片不超过二维码图片的五分之一,这里设为六分之一 String waterImgPath = QRUtil.resizeImg(appPath+"images/boco_big.png", width/6,height/6, true); //生成带有图片的二维码,返回的是生成好的二维码图片的所在路径 String qrImage = QRUtil.addImageWater(qrImgPath,waterImgPath,"BOCO"); return qrImage; } /** * @Definition: 生成带图片且图片带边框的二维码 * @author: TangWenWu * @Created date: 2014-11-25 * @param content 二维码中的内容 * @param width 二维码宽度 单位:像素 建议300 * @param height 二维码高度 单位:像素 建议300 * @param imageName 二维码图片名称 生成路径为:appName/WebRoot/指定的目录/imageName * @return */ public static String generateImageInBorderQR(String content,int width,int height,String imageName){ String appPath = FilePathUtil.getResourcePath(); String qrImgPath = generateCommonQR(content,width,height,imageName); // 缩放水印图片,为保证二维码的读取正确,图片不超过二维码图片的五分之一,这里设为六分之一 // d:/qr/heihei.png 这图片是要加在二维码中间的那张图 String waterImgPath = QRUtil.resizeImg(appPath+"images/boco_big.png", width / 6, height / 6, true); // d:/qr/qr_bg.png这种图片是自己画好边框光晕效果的边框底图 String tempImg = QRUtil.addWaterBorder(waterImgPath, appPath+"images/qr_bg.png"); // 生成带有边框图片的二维码,返回的是生成好的二维码图片的所在路径 String qrImage = QRUtil.addImageWater(qrImgPath, tempImg, "BOCO"); return qrImage; } public static void main(String[] args) { String path = FilePathUtil.getResourcePath(); System.out.println(path); /** 部分一开始***********生成常规二维码 *************/ // 二维码内容 String content = "http://blog.csdn.net/tangwwk"; // 二维码宽度 int width = 300; // 二维码高度 int height = 300; // 二维码存放地址 String imagePath = path+"templates/qr/"+"Site.png"; // 生成二维码,返回的是生成好的二维码图片的所在路径 String qrImgPath = QRUtil.encode(content, width, height, imagePath); /** 部分一结束***********如果生成不带图片的二维码,到这步已经完成了 *************/ /** 部分二开始***********如果生成带图片但图片不带边框的二维码,解开这部分注释 *************/ // 缩放水印图片,为保证二维码的读取正确,图片不超过二维码图片的五分之一,这里设为六分之一 // String waterImgPath = QRUtil.resizeImg("d:/qr/heihei.jpg", width/6, // height/6, true); // // //生成带有图片的二维码,返回的是生成好的二维码图片的所在路径 // String qrImage = QRUtil.addImageWater(qrImgPath, // waterImgPath,"thatway"); /** 部分二结束***********如果生成带图片但图片不带边框的二维码,解开这部分注释 *************/ /** 部分三开始(部分三不能和部分二共存)***********如果生成带图片且图片带边框的二维码,解开这部分注释 ****/ // 缩放水印图片,为保证二维码的读取正确,图片不超过二维码图片的五分之一,这里设为六分之一 // d:/qr/heihei.png 这图片是要加在二维码中间的那张图 String waterImgPath = QRUtil.resizeImg(path+"images/boco_big.png", width / 6, height / 6, true); // d:/qr/qr_bg.png这种图片是自己画好边框光晕效果的边框底图 String tempImg = QRUtil.addWaterBorder(waterImgPath, path+"images/qr_bg.png"); // 生成带有边框图片的二维码,返回的是生成好的二维码图片的所在路径 String qrImage = QRUtil.addImageWater(qrImgPath, tempImg, "tangwwk"); /** 部分三结束***********如果生成带图片且图片带边框的二维码,解开这部分注释 *************/ /******* 测试一下解码 ******/ System.out.println(QRUtil.decode(qrImage)); ; } }