TOTP动态令牌(Java版本)


TOTP动态令牌原理简述

TOTP 是Time-based One-Time Password的简写,表示基于时间戳算法的一次性密码。在相同时间内生成的口令都是一样的,一般每60秒或者30秒产生一个新口令;因此要求客户端和服务端必须保证精确的时钟,这样客户端和服务端基于时间生成的动态口令才能一致。

通常用于登陆时的动态密码验证、银行转账动态密码等基于时间有效性验证的应用场景。

注意:这种动态令牌并不能保证安全,因为客户端是独立生成动态口令的,因此一旦客户端代码泄漏,那么攻击者就可以自己生成动态令牌了;此时服务端是无法知晓令牌被破解,所有应当随机或定时更换密钥等信息。

其计算公式为:

TOTP(K, TC) = Truncate(HMAC-SHA-1(K, TC))
  • K: HMAC-SHA-1加密的密钥串,表示使用SHA-1做HMAC(当然也可以使用SHA-256等);
  • TC:基于时间戳计算得出;简单来说就是先计算出时间间隔,再计算经过了多个周期(也就是我们设置的令牌有效期的倍数);公式为 TC = (T - T0) / T1, 其中T为当前时间,T0为起始时间(通常为0),T1位令牌有效时间。
  • Truncate:是一个函数,用于截取加密后的字符串。

Truncate函数:

  • 取加密后的最后一个字节的的低4位,得到offset;
  • 取加密后的字节数组下标为offset、offset+1、offset+2、offset+3的4个字节通过移位操作让其刚好是int最大位数32,binary;
  • 根据需要的长度对binary取模, opt;
  • 以字符串方式返回opt,如果opt长度不够再补足长度;

算法实现

public class TOTP {

    /**
     * 共享密钥
     */
    private final String secretKey;

    /**
     * 时间步长 单位:毫秒 作为口令变化的时间周期
     */
    private final long step;

    /**
     * 转码位数 [1-8]
     */
    private final int codeDigits;

    /**
     * 初始化时间
     */
    private static final long INITIAL_TIME = 0;

    /**
     * 柔性时间回溯,单位毫秒
     */
    private final long flexibilityTime;

    /**
     * 数子量级;这个用于后面计算令牌的长度
     */
    private static final int[] DIGITS_POWER = {1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000};

    private TOTP(String secretKey, long step, long flexibilityTime, int codeDigits) {
        this.secretKey = secretKey;
        this.step = step;
        this.flexibilityTime = flexibilityTime;
        this.codeDigits = codeDigits;
    }

    public static TotpBuilder builder() {
        return new TOTP.TotpBuilder();
    }

    static class TotpBuilder {

        /**
         * 共享密钥
         */
        private String secretKey;

        /**
         * 时间步长 单位:毫秒 作为口令变化的时间周期
         */
        private long step = 2 * 60 * 1000;

        /**
         * 柔性时间回溯,单位毫秒
         */
        private long flexibilityTime = 30 * 1000;

        /**
         * 转码位数 [1-8]
         */
        private int codeDigits = 8;

        public TotpBuilder secretKey(String secretKey){
            this.secretKey = secretKey;
            return this;
        }

        public TotpBuilder step(long step){
            this.step = step;
            return this;
        }

        public TotpBuilder flexibilityTime(long flexibilityTime){
            this.flexibilityTime = flexibilityTime;
            return this;
        }

        public TotpBuilder codeDigits(int codeDigits){
            if(codeDigits<0 || codeDigits>8){
                throw new RuntimeException("the codeDigits only super 0~8.");
            }
            this.codeDigits = codeDigits;
            return this;
        }

        public TOTP build(){
            return new TOTP(this.secretKey, this.step, this.flexibilityTime, this.codeDigits);
        }

    }

    /**
     * 生成一次性密码
     *
     * @param code 共享密码
     * @return String
     */
    public String generateTOTP(String code) {
        return generateTOTP(timestamp(), code, false);
    }

    /**
     * 生成一次性密码
     *
     * @param code        共享密码
     * @param flexibility 是否是生成柔性令牌
     * @return String
     */
    private String generateTOTP(long now, String code, boolean flexibility) {
        String tc = null;
        if (flexibility) {
            tc = Long.toHexString(timeFactor(now - this.flexibilityTime)).toUpperCase();
        } else {
            tc = Long.toHexString(timeFactor(now)).toUpperCase();
        }
        return generateTOTPHmacSHA256(code + this.secretKey, tc);
    }

    public long timestamp(){
        return System.currentTimeMillis();
    }

    /**
     * 口令验证
     *
     * @param code 账户
     * @param totp 待验证的口令
     * @return 符合返回true
     */
    public boolean verify(String code, String totp) {
        String temp = generateTOTP(timestamp(), code, false);
        return temp.equals(totp);
    }

    /**
     * 柔性口令验证
     *
     * @param code 账户
     * @param totp 待验证的口令
     * @return 符合返回true
     */
    public boolean verifyFlexibility(String code, String totp) {
        long now = timestamp();
        String temp = generateTOTP(now, code, false);
        if (temp.equals(totp)) {
            return true;
        }
        temp = generateTOTP(now, code, true);
        return temp.equals(totp);
    }

    /**
     * 获取动态因子
     *
     * @param targetTime 指定时间
     * @return long
     */
    private long timeFactor(long targetTime) {
        return (targetTime - INITIAL_TIME) / this.step;
    }

    /**
     * 生成TOTP
     *
     * @param key    密码
     * @param tc   时间
     * @return 返回生成的结果
     */
    private String generateTOTPHmacSHA256(String key, String tc) {
        StringBuilder timeBuilder = new StringBuilder(tc);
        while (timeBuilder.length() < 16) {
            timeBuilder.insert(0, "0");
        }
        tc = timeBuilder.toString();
        byte[] msg = hexStr2Bytes(tc);
        byte[] k = key.getBytes();
        byte[] hash = hmacSha("HmacSHA256", k, msg);
        return truncate(hash);
    }

    /**
     * 哈希加密
     *
     * @param crypto   加密算法
     * @param keyBytes 密钥数组
     * @param text     加密内容
     * @return byte[]
     */
    private byte[] hmacSha(String crypto, byte[] keyBytes, byte[] text) {
        try {
            Mac hmac;
            hmac = Mac.getInstance(crypto);
            SecretKeySpec macKey = new SecretKeySpec(keyBytes, "AES");
            hmac.init(macKey);
            return hmac.doFinal(text);
        } catch (GeneralSecurityException gse) {
            throw new UndeclaredThrowableException(gse);
        }
    }

    private byte[] hexStr2Bytes(String hex) {
        byte[] bArray = new BigInteger("10" + hex, 16).toByteArray();
        byte[] ret = new byte[bArray.length - 1];
        System.arraycopy(bArray, 1, ret, 0, ret.length);
        return ret;
    }

    /**
     * 截断函数
     *
     * @param target 20字节的字符串
     * @return String
     */
    private String truncate(byte[] target) {
        StringBuilder result;
        // 0xf = 15、0x7f = 127、0xff = 255
        // 取加密后的最后一个字节的低4位,得到offset
        int offset = target[target.length - 1] & 0xf;
        // 取加密后的字节数组下标为offset、offset+1、offset+2、offset+3的字节通过移位操作让其刚好是int最大位数32
        int binary = ((target[offset] & 0x7f) << 24)
                | ((target[offset + 1] & 0xff) << 16)
                | ((target[offset + 2] & 0xff) << 8) | (target[offset + 3] & 0xff);

        // 根据需要的令牌位数进行取模运算
        int otp = binary % DIGITS_POWER[codeDigits];
        result = new StringBuilder(Integer.toString(otp));
        // 如果位数不够再在前面补0
        while (result.length() < codeDigits) {
            result.insert(0, "0");
        }
        return result.toString();
    }
} 

使用示例:

// 有效期为30s,令牌长度为6;柔性时间10秒
TOTP totp = TOTP.builder()
        .secretKey("33174@cB2%8df&34ce3ab17b965a2A64T4f")
        .step(30 * 1000)
        .flexibilityTime(10 * 1000)
        .codeDigits(6)
        .build();

// 直接验证,不允许柔性时间
String token = totp.generateTOTP("1025");
System.out.println(token);
Thread.sleep(20*1000);
boolean verify = totp.verify("1025", token);
System.out.println(token+"---"+verify);

// 使用柔性时间验证
String token1 = totp.generateTOTP("1025");
System.out.println(token1);
Thread.sleep(20*1000);
verify = totp.verifyFlexibility("1025", token1);
System.out.println(token1+"---"+verify);

注意这个算法的时间很重要,一旦时间不同步那么将直接导致验证失败。因此最好专门用台服务器来做授时的功能。


特别提醒:扫码关注微信订阅号'起岸星辰',实时掌握IT业界技术资讯! 转载请保留原文中的链接!
 上一篇
阿里云OSS文件上传前端搭配之后端的活 阿里云OSS文件上传前端搭配之后端的活
阿里云OSS前端通过服务端签名后直传和STS临时授权访问OSS的方式进行文件上传、分片上传、断点续传的实现
2021-06-05
下一篇 
基于雪花算法生成分布式ID(Golang版) 基于雪花算法生成分布式ID(Golang版)
雪花算法(SnowFlake算法),是 Twitter 开源的分布式 id 生成算法。其核心思想就是 使用一个 64 bit 的 long 型的数字作为全局唯一 id。
2021-06-01
  目录