Java集成谷歌身份验证器

谷歌身份验证器

最近项目有需要配合谷歌身份验证器来完成业务,功能已经实现,记录下。

一、谷歌身份验证器

Google身份验证器 Google Authenticator 是谷歌推出的基于时间的一次性密码(Time-based One-time Password,简称TOTP),只需要在手机上安装该APP,就可以生成一个随着时间变化的一次性密码,用于帐户验证。

谷歌身份验证器最早是谷歌为了减少 Gmail 邮箱遭受恶意攻击而推出的两步验证方式,后来被很多网站支持。 开启谷歌身份验证之后,登录账户,除了输入用户名和密码,还需要输入谷歌验证器上的动态密码。


谷歌验证器上的动态密码,也称为一次性密码,密码按照时间或使用次数不断动态变化(默认 30 秒变更一次)。它和很多银行发行的动态口令卡类似,可以断网使用,只不过前者是谷歌推出的一个 App,后者是专门的一个硬件。

大家都知道我们平常登录一个网站的时候,会输入账号、密码,有些也会输入短信验证码(也是为了提高安全性),有些网站除了以上这些之外,还需要输入一次动态口令才能验证成功。这个动态口令就是Google身份验证器每隔30s会动态生成一个6位数的数字。它的作用是:对你的账号进行“二步验证”保护,或者说做一个双重身份验证,来达到提升安全级别的目的。

二、谷歌验证 (Google Authenticator) 的实现原理

实现Google Authenticator功能需要服务器端和客户端的支持。服务器端负责密钥的生成、验证一次性密码是否正确。客户端记录密钥后生成一次性密码。

2.1 用户需要开启Google Authenticator服务时

  1. 服务器随机生成一个类似于『DPI45HKISEXU6HG7』的密钥,并且把这个密钥保存在数据库中;
  2. 在页面上显示一个二维码,内容是一个URI地址(otpauth://totp/账号?secret=密钥),如:otpauth://totp/kisexu@gmail.com?secret=DPI45HCEBCJK6HG7 (二维码自动识别)
  3. 客户端扫描二维码,把密钥『DPI45HKISEXU6HG7』保存在客户端 (手机上的Google APP)。

2.2 用户需要登录时

  1. 客户端每30秒使用密钥『DPI45HKISEXU6HG7』和时间戳通过一种『算法』生成一个6位数字的一次性密码,如『684060』。
  2. 用户登录时输入一次性密码『684060』。
  3. 服务器端使用保存在数据库中的密钥『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层代码逻辑我也整合过来了

代码语言:javascript
复制
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 (&#34;1&#34;.equals(users.getGoogleStatus())){
           return Result.fail(&#34;用户已绑定过谷歌验证&#34;);
       }
       log.info(&#34;googleSave()-googleToken==&#34;+googleToken);
       log.info(&#34;googleSave()-code==&#34;+code);
      
       // 验证Google code 是否正确
       boolean b = GoogleAuthenticator.checkCode(googleToken, Long.parseLong(code), System.currentTimeMillis());
       if (!b){
           return Result.succeed(Result.fail(&#34;绑定的秘钥不正确&#34;));
       }
      
       // 修改
       users.setGoogleStatus(&#34;1&#34;);
       users.setGoogleToken(googleToken);
       users.setupdatedAt(new Date());
      
       if (usersService.getBaseMapper().updateById(users)&gt;0){
           return Result.succeed(Result.succeed(&#34;绑定成功&#34;));
       }else{
           return Result.succeed(Result.succeed(&#34;绑定失败&#34;));
       }
      

      }else{
      return Result.succeed(Result.fail("绑定地址不存在"));
      }
      }

}

3.2 untils

谷歌身份验证器工具类

代码语言:javascript
复制
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-- &gt; 0; value &gt;&gt;&gt;= 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] &amp; 0xF;
    // We&#39;re using a long because Java hasn&#39;t got unsigned int.
    long truncatedHash = 0;
    for (int i = 0; i &lt; 4; ++i) {
        truncatedHash &lt;&lt;= 8;
        // We are dealing with signed bytes:
        // we just keep the first byte.
        truncatedHash |= (hash[offset + i] &amp; 0xFF);
    }
    truncatedHash &amp;= 0x7FFFFFFF;
    truncatedHash %= 1000000;
    return truncatedHash;
}

public static void main(String[] args) {
    for (int i = 0; i &lt; 100; i++) {
        String secretKey = getSecretKey();
        System.out.println(&#34;secretKey:&#34; + secretKey);
        String code = getCode(secretKey);
        System.out.println(&#34;code:&#34; + code);
        boolean b = checkCode(secretKey, Long.parseLong(code), System.currentTimeMillis());
        System.out.println(&#34;isSuccess:&#34; + b);
    }
}

}

图片转换工具类

代码语言:javascript
复制
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, &#34;utf-8&#34;);
    
     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, &#34;png&#34;, 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);
    }
    }

验证码生成工具类

代码语言:java
复制
package com.qhzx.ycheng.untils;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.lang.reflect.UndeclaredThrowableException;
import java.math.BigInteger;
import java.security.GeneralSecurityException;

/**

  • 验证码生成工具类
    */
    public class TOTP {

    // 0 1 2 3 4 5 6 7 8
    private static final int[] DIGITS_POWER = {1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000};

    /**

    • This method uses the JCE to provide the crypto algorithm. HMAC computes a
    • Hashed Message Authentication Code with the crypto hash algorithm as a
    • parameter.
    • @param crypto : the crypto algorithm (HmacSHA1, HmacSHA256, HmacSHA512)
    • @param keyBytes : the bytes to use for the HMAC key
    • @param text : the message or text to be authenticated
      */
      private static byte[] hmac_sha(String crypto, byte[] keyBytes, byte[] text) {
      try {
      Mac hmac;
      hmac = Mac.getInstance(crypto);
      SecretKeySpec macKey = new SecretKeySpec(keyBytes, "RAW");
      hmac.init(macKey);
      return hmac.doFinal(text);
      } catch (GeneralSecurityException gse) {
      throw new UndeclaredThrowableException(gse);
      }
      }

    /**

    • This method converts a HEX string to Byte[]

    • @param hex : the HEX string

    • @return: a byte array
      */
      private static byte[] hexStr2Bytes(String hex) {
      // Adding one byte to get the right conversion
      // Values starting with "0" can be converted
      byte[] bArray = new BigInteger("10" + hex, 16).toByteArray();

      // Copy all the REAL bytes, not the "first"
      byte[] ret = new byte[bArray.length - 1];
      System.arraycopy(bArray, 1, ret, 0, ret.length);
      return ret;
      }

    /**

    • This method generates a TOTP value for the given set of parameters.

    • @param key : the shared secret, HEX encoded

    • @param time : a value that reflects a time

    • @param returnDigits : number of digits to return

    • @param crypto : the crypto function to use

    • @return: a numeric String in base 10 that includes
      */
      public static String generateTOTP(String key, String time, String returnDigits, String crypto) {
      int codeDigits = Integer.decode(returnDigits);
      String result = null;

      // Using the counter
      // First 8 bytes are for the movingFactor
      // Compliant with base RFC 4226 (HOTP)
      while (time.length() < 16) {
      time = "0" + time;
      }

      // Get the HEX in a Byte[]
      byte[] msg = hexStr2Bytes(time);
      byte[] k = hexStr2Bytes(key);
      byte[] hash = hmac_sha(crypto, k, msg);

      // put selected bytes into result int
      int offset = hash[hash.length - 1] & 0xf;

      int binary = ((hash[offset] & 0x7f) << 24)
      | ((hash[offset + 1] & 0xff) << 16)
      | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff);

      int otp = binary % DIGITS_POWER[codeDigits];

      result = Integer.toString(otp);
      while (result.length() < codeDigits) {
      result = "0" + result;
      }
      return result;
      }
      }

OK,像Java集合谷歌验证网上有很多案例,但大多数都是照搬,无法使用,本案例已通过测试并完整使用过。