在Java中使用Google Authenticator

在Java中使用Google Authenticator

Google Authenticator(谷歌身份验证器)

现在身份的验证主要是靠 用户名密码。一旦泄露,则任何人都可以登录。谷歌身份验证器,通过一种方式解决了这个问题。动态口令

动态口令不是什么稀奇复杂的东西。在生活中太多地方已经出现过。例如:将军令

它的原理
服务端先为用户生成一个密钥,把这个密钥通过某种方式(扫码,用户手动输入)录入到客户端。
这样一来客户端和服务端都存储着用户相同的一个密钥。

客户端和服务端通过相同的算法,加上相同的密钥,加上相同的的时间戳,每隔30秒可以计算出一个相同的,新的动态密码(6 - 8位长度)。

登录的时候,可以不使用固定的密码。而采用这种动态密码。
这种动态密码不具备连续性,且30秒就失效。密钥存储在客户端,整个计算过程不需要网络传输(客户端离线状态也可以使用)。安全度相对较高(密钥不会泄漏)。

客户端

谷歌已经提供了现成的客户端,也可以考虑在自己的APP中实现客户端。

Github

Java代码的实现

TOTP

它是核心的TOTP算法实现

Time-Based One-Time Password Algorithm (主要使用TOTP, 因为时间同步并不是太难的事)

import java.lang.reflect.UndeclaredThrowableException;
import java.security.GeneralSecurityException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
import java.util.TimeZone;


/**
 * This is an example implementation of the OATH
 * TOTP algorithm.
 * Visit www.openauthentication.org for more information.
 *
 * @author Johan Rydell, PortWise, Inc.
 */

public class TOTP {

    private TOTP() {}

    /**
     * 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];
        for (int i = 0; i < ret.length; i++)
            ret[i] = bArray[i+1];
        return ret;
    }

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


    /**
     * 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
     *
     * @return: a numeric String in base 10 that includes
     *              {@link truncationDigits} digits
     */

    public static String generateTOTP(String key,
            String time,
            String returnDigits){
        return generateTOTP(key, time, returnDigits, "HmacSHA1");
    }


    /**
     * 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
     *
     * @return: a numeric String in base 10 that includes
     *              {@link truncationDigits} digits
     */

    public static String generateTOTP256(String key,
            String time,
            String returnDigits){
        return generateTOTP(key, time, returnDigits, "HmacSHA256");
    }


    /**
     * 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
     *
     * @return: a numeric String in base 10 that includes
     *              {@link truncationDigits} digits
     */

    public static String generateTOTP512(String key,
            String time,
            String returnDigits){
        return generateTOTP(key, time, returnDigits, "HmacSHA512");
    }


    /**
     * 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
     *              {@link truncationDigits} digits
     */

    public static String generateTOTP(String key,
            String time,
            String returnDigits,
            String crypto){
        int codeDigits = Integer.decode(returnDigits).intValue();
        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;
    }

    public static void main(String[] args) {
        // Seed for HMAC-SHA1 - 20 bytes
        String seed = "3132333435363738393031323334353637383930";
        // Seed for HMAC-SHA256 - 32 bytes
        String seed32 = "3132333435363738393031323334353637383930" +
        "313233343536373839303132";
        // Seed for HMAC-SHA512 - 64 bytes
        String seed64 = "3132333435363738393031323334353637383930" +
        "3132333435363738393031323334353637383930" +
        "3132333435363738393031323334353637383930" +
        "31323334";
        long T0 = 0;
        long X = 30;
        long testTime[] = {59L, 1111111109L, 1111111111L,
                1234567890L, 2000000000L, 20000000000L};

        String steps = "0";
        DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        df.setTimeZone(TimeZone.getTimeZone("UTC"));

        try {
            System.out.println(
                    "+---------------+-----------------------+" +
            "------------------+--------+--------+");
            System.out.println(
                    "|  Time(sec)    |   Time (UTC format)   " +
            "| Value of T(Hex)  |  TOTP  | Mode   |");
            System.out.println(
                    "+---------------+-----------------------+" +
            "------------------+--------+--------+");

            for (int i=0; i<testTime.length; i++) {
                long T = (testTime[i] - T0)/X;
                steps = Long.toHexString(T).toUpperCase();
                while (steps.length() < 16) steps = "0" + steps;
                String fmtTime = String.format("%1$-11s", testTime[i]);
                String utcTime = df.format(new Date(testTime[i]*1000));
                System.out.print("|  " + fmtTime + "  |  " + utcTime +
                        "  | " + steps + " |");
                System.out.println(generateTOTP(seed, steps, "8",
                "HmacSHA1") + "| SHA1   |");
                System.out.print("|  " + fmtTime + "  |  " + utcTime +
                        "  | " + steps + " |");
                System.out.println(generateTOTP(seed32, steps, "8",
                "HmacSHA256") + "| SHA256 |");
                System.out.print("|  " + fmtTime + "  |  " + utcTime +
                        "  | " + steps + " |");
                System.out.println(generateTOTP(seed64, steps, "8",
                "HmacSHA512") + "| SHA512 |");

                System.out.println(
                        "+---------------+-----------------------+" +
                "------------------+--------+--------+");
            }
        }catch (final Exception e){
            System.out.println("Error : " + e);
        }
    }
}

GoogleAuthenticatorUtils

抽象出的工具类

import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.SecureRandom;

import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.Hex;

import com.google.zxing.BarcodeFormat;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;

public class GoogleAuthenticatorUtils {
	
	/**
	 * 生成随机的密钥
	 * @return
	 */
	public static String getRandomSecretKey() {
	    SecureRandom random = new SecureRandom();
	    byte[] bytes = new byte[20];
	    random.nextBytes(bytes);
	    Base32 base32 = new Base32();
	    return base32.encodeToString(bytes).toLowerCase();
	}
	
	
	/**
	 * 根据密钥,计算出当前时间的动态口令 (30s会变化一次)
	 * @param secretKey
	 * @return
	 */
	public static String getTOTPCode(String secretKey) {
	    Base32 base32 = new Base32();
	    byte[] bytes = base32.decode(secretKey);
	    String hexKey = Hex.encodeHexString(bytes);
	    long time = (System.currentTimeMillis() / 1000) / 30;
	    String hexTime = Long.toHexString(time);
	    return TOTP.generateTOTP(hexKey, hexTime, "6");
	}
	
	/**
	 * 根据密钥,生成 TOPT 密钥的 URI 字符串
	 * @param secretKey
	 * @param account
	 * @param issuer
	 * @return
	 */
	public static String getGoogleAuthenticatorBarCode(String secretKey, String account, String issuer) {
	    try {
	        return "otpauth://totp/"
		        + URLEncoder.encode(issuer + ":" + account, "UTF-8").replace("+", "%20")
		        + "?secret=" + URLEncoder.encode(secretKey, "UTF-8").replace("+", "%20")
		        + "&issuer=" + URLEncoder.encode(issuer, "UTF-8").replace("+", "%20");
	    } catch (UnsupportedEncodingException e) {
	        throw new IllegalStateException(e);
	    }
	}
	
	
	/**
	 * 根据 TOPT 密钥的 URI 字符串 生成二维码
	 * @param barCode
	 * @param outputStream
	 * @param height
	 * @param width
	 * @throws WriterException
	 * @throws IOException
	 */
	
	public static void createQRCode(String barCode, OutputStream  outputStream, int height, int width) throws WriterException, IOException {
	    BitMatrix matrix = new MultiFormatWriter().encode(barCode, BarcodeFormat.QR_CODE, width, height);
	    MatrixToImageWriter.writeToStream(matrix, "png", outputStream);
	}
}
	
	

GoogleAuthenticatorTest

演示

package io.streamer.common.utils.test;

import java.io.FileOutputStream;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

import com.google.zxing.WriterException;

public class GoogleAuthenticatorTest {
	public static void main(String[] args) throws WriterException, IOException {
		
		// 生成随机的密钥
		String secretKey = GoogleAuthenticatorUtils.getRandomSecretKey();
		System.out.println("随机密钥:" + secretKey);
		
		// 根据验证码,账户,服务商生成 TOPT 密钥的 URI
		String uri = GoogleAuthenticatorUtils.getGoogleAuthenticatorBarCode(secretKey, "747692844@qq.com", "springboot");
		System.out.println("TOPT密钥URI:" + uri);
		
		// 根据 TOPT 密钥的 URI生成二维码,存储在本地
		GoogleAuthenticatorUtils.createQRCode(uri, new FileOutputStream("D:\\google-auth.png"), 200, 200);
		
		String lastCode = null;
		while (true) {
			// 根据密钥获取此刻的动态口令
		    String code = GoogleAuthenticatorUtils.getTOTPCode(secretKey);
		    if (!code.equals(lastCode)) {
		    	System.out.println("刷新了验证码:" + code + " " + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(LocalDateTime.now()));
		    }
		    lastCode = code;
		    try {
		        Thread.sleep(1000);  // 线程暂停1秒
		    } catch (InterruptedException e) {};
		}
	}
}

生成的二维码

使用Google Authenticator客户端扫描

image

控制台输出的日志

每隔30秒就会生成一个新的口令

随机密钥:royydsit3u36dsilk33vc4rtso4pfgme
TOPT密钥URI:otpauth://totp/springboot%3A747692844%40qq.com?secret=royydsit3u36dsilk33vc4rtso4pfgme&issuer=springboot
刷新了验证码:365102 2020-02-12 11:03:52
刷新了验证码:601796 2020-02-12 11:04:00
刷新了验证码:491242 2020-02-12 11:04:30

客户端扫码后得到的动态口令

可以看到,同时间,客户端和日志中的最新的口令是匹配的。都是:491242

关于TOPT密钥URI的格式

otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}

issuer 提供服务的组织/企业(前后要保持一致)
account 账户
secret 密钥

例如,我现在使用 springboot 提供的认证服务,我的的账户是:747692844@qq.com,随机的安全密钥是:vwdjsowyebnidjjiw5uz3ygwkqjyiha3

otpauth://totp/springboot:747692844@qq.com?secret=vwdjsowyebnidjjiw5uz3ygwkqjyiha3&issuer=springboot

参考

演示代码中的 FileOutputStream 流没关闭。copy的时候,记得改改!!!