天天看點

Java實作OTP動态密碼認證demo

作者:蝸牛學技術

概念

一次性密碼(One Time Password,簡稱OTP),又稱“一次性密碼”,是指隻能使用一次的密碼。一次性密碼是根據專門算法、每隔60秒生成一個不可預測的随機數字組合,iKEY一次性密碼已在金融 (opens new window)、電信 (opens new window)、網遊 (opens new window)等領域被廣泛應用,有效地保護了使用者的安全。

一般的靜态密碼在安全性上容易因為木馬 (opens new window)與鍵盤側錄程式 (opens new window)等而被竊取,而隻要花上相當程度的時間,也有可能被暴力破解 (opens new window)。為了解決一般密碼容易遭到破解情況,是以開發出一次性密碼的解決方案。

在平時生活中,我們接觸一次性密碼的場景非常多,比如在登入賬号、找回密碼,更改密碼和轉賬操作等等這些場景,其中一些常用到的方式有:

  • 手機短信+短信驗證碼;
  • 郵件+郵件驗證碼;
  • 認證器軟體+驗證碼,比如Microsoft Authenticator App,Google Authenticator App等等;
  • 硬體+驗證碼:比如網銀的電子密碼器;

這些場景的流程一般都是在使用者提供了賬号+密碼的基礎上,讓使用者再提供一個一次性的驗證碼來提供一層額外的安全防護。通常情況下,這個驗證碼是一個6-8位的數字,隻能使用一次或者僅在很短的時間内可用(比如5分鐘以内)

#形式

OTP從技術來分有三種形式, 時間同步、事件同步、挑戰/應答。

#時間同步

原理是基于 動态令牌和 動态密碼驗證伺服器的時間比對,基于 時間同步的 令牌,一般每60秒産生一個新密碼,要求伺服器能夠十分精确的保持正确的時鐘,同時對其令牌的晶振頻率有嚴格的要求,這種技術對應的終端是硬體令牌。

#事件同步

基于事件同步的令牌,其原理是通過某一特定的事件次序及相同的種子值作為輸入,通過HASH算法中運算出一緻的密碼。

#挑戰/應答

常用于的網上業務,在網站/應答上輸入 服務端下發的 挑戰碼, 動态令牌輸入該挑戰碼,通過内置的算法上生成一個6/8位的随機數字,密碼一次有效,這種技術目前應用最為普遍,包括刮刮卡、短信密碼、動态令牌也有挑戰/應答形式。 主流的動态令牌技術是時間同步和挑戰/應答兩種形式。

#OTP基本原理

Java實作OTP動态密碼認證demo

計算OTP串的公式

OTP(K,C) = Truncate(HMAC-SHA-1(K,C))
           
  • K: 表示秘鑰串,這個密鑰的要求是每個 HOTP 的生成器都必須是唯一的。一般我們都是通過一些随機生成種子的庫來實作。
  • C: RFC 中把它稱為移動元素(moving factor)是一個 8個 byte的數值,而且需要伺服器和用戶端同步。
  • HMAC-SHA-1: 表示使用SHA-1做HMAC;
  • Truncate: 是一個函數,就是怎麼截取加密後的串,并取加密後串的哪些字段組成一個數字。

#HOTP原理

HOTP(HMAC-Based One Time Password) 即是基于 HMAC(基于Hash的消息認證碼)實作的一次性密碼。算法細節定義在RFC4226 (opens new window).

HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))           

一般規定 HOTP 的散列函數使用 SHA2,即:基于SHA-256 或者SHA-512[SHA2 (opens new window)] 的散列函數做事件同步驗證。

Java實作OTP動态密碼認證demo

步驟文解:

  1. 使用 HMAC-SHA-1 算法基于 K 和 C 生成一個20個位元組的十六進制字元串(HS)。關于如何生成這個是另外一個協定來規定的,RFC 2104 HMAC Keyed-Hashing for Message Authentication (opens new window). 實際上這裡的算法并不唯一,還可以使用 HMAC-SHA-256 和 HMAC-SHA-512 生成更長的序列。對應到協定中的算法辨別就是
  2. HS = HMAC-SHA-1(K,C) 1
  3. 選擇這個20個位元組的十六進制字元串(HS 下文使用 HS 代替 )的最後一個位元組,取其低4位數并轉化為十進制。比如圖中的例子,第二個位元組是 5a,第四位就是 a,十六進制也就是 0xa,轉化為十進制就是 10。該數字我們定義為 Offset,也就是偏移量。
  4. 根據偏移量 Offset,我們從 HS 中的第 10(偏移量)個位元組開始選取 4 個位元組,作為我們生成 OTP 的基礎資料。圖中例子就是選擇 50ef7f19,十六進制表示就是 0x50ef7f19,我們稱為 Sbits
  5. 将上一步4個位元組的十六進制字元串 Sbits 轉化為十進制,然後用該十進制數對 10的Digit次幂 進行取模運算。其原理很簡單根據取模運算的性質,比如 比10大的數 MOD 10 結果必然是 0到9, MOD 100 結果必然是 0-99。圖中的例子,50ef7f19 轉化為十進制為 1357872921,然後如果需要6位 OTP 驗證碼,則 1357872921 MOD 10^6 = 872921。 872921 就是我們最終生成的 OTP。
  6. 這一步可能還需要注意一點就是圖中案例 Digit 不能超過10,因為即使超過10,1357872921 取模後也不會超過10位了。是以如果我們希望擷取更長的驗證碼,需要在三步中拿到更多的十六進制位元組,進而得到更大的十進制數。這個十進制決定了生成的 OTP 編碼的長度。

#TOTP原理

TOTP 算法的關鍵在于如何更具目前時間和時間視窗計算出計數,也就是如何根據目前時間和 X 來計算 HOTP 算法中的 C。

Java實作OTP動态密碼認證demo

HOTP 算法中的 C 是使用目前 Unix 時間戳 減去初始計數時間戳,然後除以時間視窗而獲得的。

C = (T - T0) / X;           
  • T 為目前時間
  • T0 從哪個時間開始,一般取值為 0.
  • X 表示時間步數,也就是說多長時間産生一個動态密碼,這個時間間隔就是時間步數X,比如30秒;

#代碼實作

package com.springboot.demo.util;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.Base64;
import org.springframework.stereotype.Component;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Objects;

@Slf4j
@Component
public class GoogleGeneratorUtil {
    // 生成的key長度( Generate secret key length)
    public static final int SECRET_SIZE = 10;

    public static final String SEED = "22150146801713967E8g";
    // Java實作随機數算法
    public static final String RANDOM_NUMBER_ALGORITHM = "SHA1PRNG";
    // 最多可偏移的時間
    private static int OTP_WINDOW_SIZE = 1; // default 1 - max 17

    /**
     * 建立密鑰
     *
     * @return {@link String}
     */
    public static String generateSecretKey() throws NoSuchAlgorithmException {
        SecureRandom sr = null;
        try {
            sr = SecureRandom.getInstance(RANDOM_NUMBER_ALGORITHM);
            sr.setSeed(Base64.decodeBase64(SEED));
            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
            log.error("otp生成SecretKey失敗", e);
            throw e;
        }
    }

    /**
     * 這個format不可以修改,身份驗證器無法識别二維碼
     * 二維碼 url 格式
     * @param user
     * @param secret
     * @return
     */
    public static String getQRBarcodeStr(String user, String secret) {
        String format = "otpauth://totp/Otpdemo:%s?secret=%s&issuer=Otpdemo";
        return String.format(format, user, secret);
    }

    public static boolean check_code(String secret, String 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 is used to check codes generated in the near past.
        // You can use this value to tune how far you're willing to go.
        for (int i = -OTP_WINDOW_SIZE; i <= OTP_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 ();
                log.error("失敗", e);
                throw new RuntimeException(e.getMessage(), e);
                // return false;
            }
            if (Objects.equals(code, addZero(hash))) {
                return true;
            }
        }
        // The validation code is invalid.
        return false;
    }

    public 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;
    }

    public static String addZero(long code) {
        return String.format("%06d", code);
    }

//    @NacosValue(value = "${otp.window.size}", autoRefreshed = true)
    int otpWindowSize = 1;
    public void setOtpWindowSize(int otpWindowSize) {
        if (otpWindowSize > 17 || otpWindowSize < 0) {
            throw new RuntimeException("otp.window.size最大值是17,最小為1");
        }
        OTP_WINDOW_SIZE = otpWindowSize;
    }


}
           

測試驗證

import com.springboot.demo.util.GoogleGeneratorUtil;
import com.springboot.demo.util.QrCodeUtil;
import org.apache.commons.codec.binary.Base32;

/**
 * @author li
 * @version 1.0
 * @date 2023/05/11 09:58
 */
public class testOtp {
    public static void main(String[] args) throws Exception {
        //生成秘鑰
        String secret = GoogleGeneratorUtil.generateSecretKey();
        //生成認證url
        String qrcode = GoogleGeneratorUtil.getQRBarcodeStr("test", secret);
        //二維碼Base64
        String qrcodeBase64 = QrCodeUtil.encode(qrcode);

        System.out.println(qrcode);
        System.out.println(qrcodeBase64);

        //生成6位otp驗證碼
        Base32 codec = new Base32();
        byte[] decodedKey = codec.decode(secret);
        long t = (System.currentTimeMillis() / 1000L) / 30L;
        int realOtpCode = GoogleGeneratorUtil.verify_code(decodedKey,t+1);
        //校驗otp6位驗證碼
        String otpCode = GoogleGeneratorUtil.addZero(realOtpCode);
        System.out.println("otpCode: "+  otpCode);
        //校驗otp6位數字是否正确
        Boolean flag = GoogleGeneratorUtil.check_code(secret,otpCode,System.currentTimeMillis());
        System.out.println(flag);
    }

}
           

繼續閱讀