最近项目有需要配合谷歌身份验证器来完成业务,功能已经实现,记录下。
一、谷歌身份验证器
Google身份验证器 Google Authenticator 是谷歌推出的基于时间的一次性密码(Time-based One-time Password,简称TOTP),只需要在手机上安装该APP,就可以生成一个随着时间变化的一次性密码,用于帐户验证。
谷歌身份验证器最早是谷歌为了减少 Gmail 邮箱遭受恶意攻击而推出的两步验证方式,后来被很多网站支持。 开启谷歌身份验证之后,登录账户,除了输入用户名和密码,还需要输入谷歌验证器上的动态密码。
谷歌验证器上的动态密码,也称为一次性密码,密码按照时间或使用次数不断动态变化(默认 30 秒变更一次)。它和很多银行发行的动态口令卡类似,可以断网使用,只不过前者是谷歌推出的一个 App,后者是专门的一个硬件。
大家都知道我们平常登录一个网站的时候,会输入账号、密码,有些也会输入短信验证码(也是为了提高安全性),有些网站除了以上这些之外,还需要输入一次动态口令才能验证成功。这个动态口令就是Google身份验证器每隔30s会动态生成一个6位数的数字。它的作用是:对你的账号进行“二步验证”保护,或者说做一个双重身份验证,来达到提升安全级别的目的。
二、谷歌验证 (Google Authenticator) 的实现原理
实现Google Authenticator功能需要服务器端和客户端的支持。服务器端负责密钥的生成、验证一次性密码是否正确。客户端记录密钥后生成一次性密码。
2.1 用户需要开启Google Authenticator服务时
服务器随机生成一个类似于『DPI45HKISEXU6HG7』的密钥,并且把这个密钥保存在数据库中;
在页面上显示一个二维码,内容是一个URI地址(otpauth://totp/账号?secret=密钥),如:otpauth://totp/kisexu@gmail.com?secret=DPI45HCEBCJK6HG7 (二维码自动识别)
客户端扫描二维码,把密钥『DPI45HKISEXU6HG7』保存在客户端 (手机上的Google APP)。
2.2 用户需要登录时
客户端每30秒使用密钥『DPI45HKISEXU6HG7』和时间戳通过一种『算法』生成一个6位数字的一次性密码,如『684060』。
用户登录时输入一次性密码『684060』。
服务器端使用保存在数据库中的密钥『DPI45HKISEXU6HG7』和时间戳通过同一种『算法』生成一个6位数字的一次性密码。如果算法相同、密钥相同,又是同一个时间(时间戳相同),那么客户端和服务器计算出的一次性密码是一样的。服务器验证时如果一样,就登录成功了。
这种『算法』是公开的,所以服务器端也有很多开源的实现。
本质上是基于共享密钥的身份认证,当你从银行领取一个动态令牌时,已经做过了 密钥分发,Google Authenticator 的二维码绑定过程其实就是 密钥分发 的过程而已。实现方式主要分为两种:HOTP,TOTP,国内主要使用TOTP,因为时间同步并不是太难的事。
原理请参看RFC4226:https://www.ietf.org/rfc/rfc4226.txt
客户端和服务器事先协商好一个密钥K,用于一次性密码的生成过程,此密钥不被任何第三方所知道。此外,客户端和服务器各有一个计数器C,并且事先将计数值同步。
进行验证时,客户端对密钥和计数器的组合(K,C)使用HMAC(Hash-based Message Authentication Code)算法计算一次性密码
公式如下:HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
上面采用了HMAC-SHA-1,当然也可以使用HMAC-MD5等。
HMAC算法得出的值位数比较多,不方便用户输入,因此需要截断成为一组不太长十进制数(例如6位)。计算完成之后客户端计数器C计数值加1。用户将这一组十进制数输入并且提交之后,服务器端同样的计算,并且与用户提交的数值比较,如果相同,则验证通过,服务器端将计数值C增加1。如果不相同,则验证失败。
三、Java代码实现
3.1 Controller
为了方便看,services层代码逻辑我也整合过来了
import com.xx.untils.GoogleAuthenticator; import com.xx.untils.GoogleGenerator; import com.xx.untils.QrCodeUtils; import io.swagger.annotations.Api; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import static com.xx.untils.RandomUtils.getNum; /** * 谷歌验证-Controller * @ClassName AsurPlusController * @Author Blue Email:2113438464@qq.com * @Date 2022 */ @Api(tags = "谷歌验证") @RestController @RequestMapping("/asurplus") @CrossOrigin @Slf4j public class AsurplusController { /** * 生成 Google 密钥,两种方式任选一种 * @return 密钥字符 */ @ApiOperation(value = "获取Google 密钥") @GetMapping("/getSecretKey") public String getSecretKey() { return GoogleAuthenticator.getSecretKey(); } /** * 生成 Google 密钥后 转二维码 转base64Pic,两种方式任选一种 * 可以先请求getSecretKey()方法后,获得密钥字符后,将密钥字符做为参数 调用本方法 * @param secretKey 密钥 * @return base64 */ @ApiOperation(value = "获取Google 二维码") @ApiImplicitParam(name = "secretKey",value = "密钥",required = true) @GetMapping("/getQrcodes") public String getQrcodes(@RequestParam String secretKey) throws Exception { Long num = getNum(5);//随机生成五位的码,当做账号名 String base64Pic = QrCodeUtils.creatRrCode(GoogleAuthenticator.getQrCodeText(secretKey,num.toString(),""), 200,200); return base64Pic; } /** * 获取Google code * @param secretKey 密钥 * @return 验证码 */ @ApiOperation(value = "获取Google code") @ApiImplicitParam(name = "secretKey",value = "密钥",required = true) @GetMapping("/getCode") public String getCode(@RequestParam("secretKey") String secretKey) { return GoogleAuthenticator.getCode(secretKey); } /** * 验证Google code 是否正确 * @param secretKey 密钥 * @param code 验证码 * @return Boolean */ @ApiOperation(value = "验证Google code 是否正确") @ApiImplicitParams(value = { @ApiImplicitParam(name = "secretKey",value = "密钥",required = true), @ApiImplicitParam(name = "code",value = "验证码",required = true) }) @GetMapping("/checkCode") public Boolean checkCode(@RequestParam("secretKey") String secretKey, @RequestParam("code") String code) { return GoogleAuthenticator.checkCode(secretKey, Long.parseLong(code), System.currentTimeMillis()); } /** * 判断是否绑定谷歌验证 * @param addr 业务用户标识 * @return Result */ @ApiOperation(value = "判断是否绑定谷歌验证") @ApiImplicitParam(name = "addr",value = "业务用户标识",required = true) @PostMapping(value = "/google") public Result Google(@RequestParam String addr) { // 业务代码,可以根据自己的场景进行修改 Users users1 = usersService.getBaseMapper().selectOne(new LambdaQueryWrapper<Users>().eq(Users::getAddr, addr)); if (StringUtils.isEmpty(users1.getGoogleToken())){ return Result.succeed(Result.fail("没有绑定谷歌验证")); }else{ return Result.succeed(Result.succeed("true")); } } /** * 绑定谷歌 * @param addr 业务用户标识 * @param googleToken 密钥字符 * @return Result */ @ApiOperation("绑定谷歌") @ApiImplicitParams(value = { @ApiImplicitParam(name = "addr",value = "业务用户标识",required = true), @ApiImplicitParam(name = "googleToken",value = "密钥字符",required = true) }) @PostMapping(value = "/googleSave") public Result googleSave(@RequestParam(required = false) String addr,@RequestParam(required = false) String googleToken,@RequestParam(required = false) String code) { // 安全参数 if (StringUtils.isEmpty(addr)){ return Result.succeed(Result.fail("用户地址不能为空")); } if (StringUtils.isEmpty(googleToken)){ return Result.succeed(Result.fail("谷歌验证不能为空")); } if (StringUtils.isEmpty(code)){ return Result.succeed(Result.fail("验证码不能为空")); } // 根据用户地址查询用户,业务需求,根id主键一个作用 Users users = usersService.getBaseMapper().selectOne(new LambdaQueryWrapper<Users>().eq(Users:getAddr, addr)); if (ObjectUtil.isNotNull(users)) { // Users实体类和数据表中的两个属性 需要自己创建 分别为: // googleToken 存放 谷歌验证的token // googleStatus谷歌验证状态 0未绑定 1绑定 if ("1".equals(users.getGoogleStatus())){ return Result.fail("用户已绑定过谷歌验证"); } log.info("googleSave()-googleToken=="+googleToken); log.info("googleSave()-code=="+code); // 验证Google code 是否正确 boolean b = GoogleAuthenticator.checkCode(googleToken, Long.parseLong(code), System.currentTimeMillis()); if (!b){ return Result.succeed(Result.fail("绑定的秘钥不正确")); } // 修改 users.setGoogleStatus("1"); users.setGoogleToken(googleToken); users.setupdatedAt(new Date()); if (usersService.getBaseMapper().updateById(users)>0){ return Result.succeed(Result.succeed("绑定成功")); }else{ return Result.succeed(Result.succeed("绑定失败")); } }else{ return Result.succeed(Result.fail("绑定地址不存在")); } } }
3.2 untils
谷歌身份验证器工具类
import org.apache.commons.codec.binary.Base32; import org.apache.commons.codec.binary.Hex; import org.springframework.util.StringUtils; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; /** * 谷歌身份验证器工具类 * @ClassName GoogleAuthenticator * @Author Blue Email:2113438464@qq.com * @Date 2022 */ public class GoogleAuthenticator { /** * 时间前后偏移量 * 用于防止客户端时间不精确导致生成的TOTP与服务器端的TOTP一直不一致 * 如果为0,当前时间为 10:10:15 * 则表明在 10:10:00-10:10:30 之间生成的TOTP 能校验通过 * 如果为1,则表明在 * 10:09:30-10:10:00 * 10:10:00-10:10:30 * 10:10:30-10:11:00 之间生成的TOTP 能校验通过 * 以此类推 */ private static int WINDOW_SIZE = 0; /** * 加密方式,HmacSHA1、HmacSHA256、HmacSHA512 */ private static final String CRYPTO = "HmacSHA1"; /** * 生成密钥,每个用户独享一份密钥 * @return */ public static String getSecretKey() { SecureRandom random = new SecureRandom(); // byte[] bytes = new byte[20]; byte[] bytes = new byte[10]; random.nextBytes(bytes); Base32 base32 = new Base32(); String secretKey = base32.encodeToString(bytes); // make the secret key more human-readable by lower-casing and // inserting spaces between each group of 4 characters return secretKey.toUpperCase(); } /** * 生成二维码内容 * @param secretKey 密钥 * @param account 账户名 * @param issuer 网站地址(可不写) * @return */ public static String getQrCodeText(String secretKey, String account, String issuer) { String normalizedBase32Key = secretKey.replace(" ", "").toUpperCase(); try { return "otpauth://totp/" + URLEncoder.encode((!StringUtils.isEmpty(issuer) ? (issuer + ":") : "") + account, "UTF-8").replace("+", "%20") + "?secret=" + URLEncoder.encode(normalizedBase32Key, "UTF-8").replace("+", "%20") + (!StringUtils.isEmpty(issuer) ? ("&issuer=" + URLEncoder.encode(issuer, "UTF-8").replace("+", "%20")) : ""); } catch (UnsupportedEncodingException e) { throw new IllegalStateException(e); } } /** * 获取验证码 * @param secretKey * @return */ public static String getCode(String secretKey) { String normalizedBase32Key = secretKey.replace(" ", "").toUpperCase(); Base32 base32 = new Base32(); byte[] bytes = base32.decode(normalizedBase32Key); String hexKey = Hex.encodeHexString(bytes); long time = (System.currentTimeMillis() / 1000) / 30; String hexTime = Long.toHexString(time); return TOTP.generateTOTP(hexKey, hexTime, "6", CRYPTO); } /** * 检验 code 是否正确 * @param secret 密钥 * @param code code * @param time 时间戳 * @return */ public static boolean checkCode(String secret, long code, long time) { 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 = (time / 1000L) / 30L; // Window is used to check codes generated in the near past. // You can use this value to tune how far you're willing to go. long hash; for (int i = -WINDOW_SIZE; i <= WINDOW_SIZE; ++i) { try { hash = verifyCode(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; } } return false; } /** * 根据时间偏移量计算 * @param key * @param t * @return * @throws NoSuchAlgorithmException * @throws InvalidKeyException */ private static long verifyCode(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, CRYPTO); Mac mac = Mac.getInstance(CRYPTO); 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 truncatedHash; } public static void main(String[] args) { for (int i = 0; i < 100; i++) { String secretKey = getSecretKey(); System.out.println("secretKey:" + secretKey); String code = getCode(secretKey); System.out.println("code:" + code); boolean b = checkCode(secretKey, Long.parseLong(code), System.currentTimeMillis()); System.out.println("isSuccess:" + b); } } }
图片转换工具类
import com.google.zxing.BarcodeFormat; import com.google.zxing.EncodeHintType; import com.google.zxing.MultiFormatWriter; import com.google.zxing.WriterException; import com.google.zxing.common.BitMatrix; import org.apache.commons.codec.binary.Base64; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Hashtable; /** * URL转Base64二维码 * @ClassName QrCodeUtils * @Author Blue Email:2113438464@qq.com * @Date 2022 */ public class QrCodeUtils { @SuppressWarnings({ "rawtypes", "unchecked" }) public static String creatRrCode(String contents, int width, int height) { String base64 = ""; Hashtable hints = new Hashtable(); hints.put(EncodeHintType.CHARACTER_SET, "utf-8"); try { BitMatrix bitMatrix = new MultiFormatWriter().encode(contents, BarcodeFormat.QR_CODE, width, height, hints); // 1、读取文件转换为字节数组 ByteArrayOutputStream out = new ByteArrayOutputStream(); BufferedImage image = toBufferedImage(bitMatrix); //转换成png格式的IO流 ImageIO.write(image, "png", out); byte[] bytes = out.toByteArray(); // 2、将字节数组转为二进制 base64 = Base64.encodeBase64String(bytes).trim(); } catch (WriterException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } return base64; } /** * image流数据处理 */ private static BufferedImage toBufferedImage(BitMatrix matrix) { int width = matrix.getWidth(); int height = matrix.getHeight(); BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { image.setRGB(x, y, matrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF); } } return image; } public static void main(String[] args) { // 测试代码 String base64Pic = QrCodeUtils.creatRrCode("http://zf.thxyy.cn/weixinmpPlus/byCodePay/list?dd=JC2101080005&ts=1610080940", 200,200); System.out.println(base64Pic); } }