# 两步验证

# 两步验证是什么?

大多数情境下,只要用户输入了账号密码,就判定为登录成功了。但是,如果用户的账号密码泄露的话,就会给不法分子可趁之机,因此,即使账号密码输入正确了,还要再次进行验证,来保证系统的安全。传统的方法是调用短信来验证,但是调用 API 给运营商发送短信是有成本的,而且也不一定成功,因此我们需要一种更加好的方法来完善这个认证,因此 OPT 就诞生了,页面首次登录时提示一个验证码,上面有着密钥,这个密钥只会出现一次,用于给验证器进行绑定,而且验证码会随着时间变化,因此可以保证安全,而且他是基于算法生成的,不需要联网。

TOTP (Time-Based One-time Password)

# OTP 验证 核心代码

package com.example.twofactorauthdemo;
 
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Random;
 
public class TwoFactorAuthUtils {
 
    private static final int VALID_TFA_WINDOW_MILLIS = 60_000;
 
 
    /**
     * 生成两步验证 Key
     *
     * @return 两步验证 Key
     */
    public static String generateTFAKey() {
        return TimeBasedOneTimePasswordUtil.generateBase32Secret(32);
    }
 
    /**
     * 生成两步验证码
     *
     * @param tfaKey 两步验证 Key
     * @return 两步验证码
     */
    public static String generateTFACode(String tfaKey) {
        try {
            return TimeBasedOneTimePasswordUtil.generateCurrentNumberString(tfaKey);
        } catch (GeneralSecurityException e) {
            throw new RuntimeException("两步验证码生成异常");
        }
    }
 
    /**
     * 验证两步验证码
     *
     * @param tfaKey  两步验证 Key
     * @param tfaCode 两步验证码
     */
    public static void validateTFACode(String tfaKey, String tfaCode) {
        try {
            int validCode = Integer.parseInt(tfaCode);
            boolean result = TimeBasedOneTimePasswordUtil
                    .validateCurrentNumber(tfaKey, validCode, VALID_TFA_WINDOW_MILLIS);
            if (!result) {
                throw new RuntimeException("两步验证码验证错误,请确认时间是否同步");
            }
        } catch (NumberFormatException e) {
            throw new RuntimeException("两步验证码请输入数字");
        } catch (GeneralSecurityException e) {
            throw new RuntimeException("两步验证码验证异常");
        }
    }
 
    /**
     * 生成 Otp Auth Url
     *
     * @param userName 用户名
     * @param tfaKey   两步验证 Key
     * @return URL
     */
    public static String generateOtpAuthUrl(final String userName, final String tfaKey) {
        return TimeBasedOneTimePasswordUtil.generateOtpAuthUrl(userName, tfaKey);
    }
 
    static class TimeBasedOneTimePasswordUtil {
        public static final int DEFAULT_TIME_STEP_SECONDS = 30;
        private static final String BLOCK_OF_ZEROS;
        private static final int NUM_DIGITS_OUTPUT = 6;
 
        static {
            char[] chars = new char[NUM_DIGITS_OUTPUT];
            Arrays.fill(chars, '0');
            BLOCK_OF_ZEROS = new String(chars);
        }
 
        public static String generateBase32Secret(int length) {
            StringBuilder sb = new StringBuilder(length);
            Random random = new SecureRandom();
            for (int i = 0; i < length; i++) {
                int val = random.nextInt(32);
                if (val < 26) {
                    sb.append((char) ('A' + val));
                } else {
                    sb.append((char) ('2' + (val - 26)));
                }
            }
            return sb.toString();
        }
 
        public static boolean validateCurrentNumber(String base32Secret, int authNumber,
                                                    int windowMillis)
                throws GeneralSecurityException {
            return validateCurrentNumber(base32Secret, authNumber, windowMillis,
                    System.currentTimeMillis(),
                    DEFAULT_TIME_STEP_SECONDS);
        }
 
        public static boolean validateCurrentNumber(String base32Secret, int authNumber,
                                                    int windowMillis, long timeMillis,
                                                    int timeStepSeconds) throws GeneralSecurityException {
            long fromTimeMillis = timeMillis;
            long toTimeMillis = timeMillis;
            if (windowMillis > 0) {
                fromTimeMillis -= windowMillis;
                toTimeMillis += windowMillis;
            }
            long timeStepMillis = timeStepSeconds * 1000L;
            for (long millis = fromTimeMillis; millis <= toTimeMillis; millis += timeStepMillis) {
                int generatedNumber = generateNumber(base32Secret, millis, timeStepSeconds);
                if (generatedNumber == authNumber) {
                    return true;
                }
            }
            return false;
        }
 
        public static String generateCurrentNumberString(String base32Secret)
                throws GeneralSecurityException {
            return generateNumberString(base32Secret, System.currentTimeMillis(),
                    DEFAULT_TIME_STEP_SECONDS);
        }
 
        public static String generateNumberString(String base32Secret, long timeMillis,
                                                  int timeStepSeconds)
                throws GeneralSecurityException {
            int number = generateNumber(base32Secret, timeMillis, timeStepSeconds);
            return zeroPrepend(number);
        }
 
        public static int generateNumber(String base32Secret, long timeMillis, int timeStepSeconds)
                throws GeneralSecurityException {
 
            byte[] key = decodeBase32(base32Secret);
 
            byte[] data = new byte[8];
            long value = timeMillis / 1000 / timeStepSeconds;
            for (int i = 7; value > 0; i--) {
                data[i] = (byte) (value & 0xFF);
                value >>= 8;
            }
 
            // encrypt the data with the key and return the SHA1 of it in hex
            SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA1");
            // if this is expensive, could put in a thread-local
            Mac mac = Mac.getInstance("HmacSHA1");
            mac.init(signKey);
            byte[] hash = mac.doFinal(data);
 
            // take the 4 least significant bits from the encrypted string as an offset
            int offset = hash[hash.length - 1] & 0xF;
 
            // We're using a long because Java hasn't got unsigned int.
            long truncatedHash = 0;
            for (int i = offset; i < offset + 4; ++i) {
                truncatedHash <<= 8;
                // get the 4 bytes at the offset
                truncatedHash |= hash[i] & 0xFF;
            }
            // cut off the top bit
            truncatedHash &= 0x7FFFFFFF;
 
            // the token is then the last 6 digits in the number
            truncatedHash %= 1000000;
            // this is only 6 digits so we can safely case it
            return (int) truncatedHash;
        }
 
        public static String generateOtpAuthUrl(String keyId, String secret) {
            StringBuilder sb = new StringBuilder(64);
            addOtpAuthPart(keyId, secret, sb);
            return sb.toString();
        }
 
        private static void addOtpAuthPart(String keyId, String secret, StringBuilder sb) {
            sb.append("otpauth://totp/").append(keyId).append("?secret=").append(secret);
        }
 
        static String zeroPrepend(int num) {
            String numStr = Integer.toString(num);
            if (numStr.length() >= TimeBasedOneTimePasswordUtil.NUM_DIGITS_OUTPUT) {
                return numStr;
            } else {
                StringBuilder sb = new StringBuilder(TimeBasedOneTimePasswordUtil.NUM_DIGITS_OUTPUT);
                int zeroCount = TimeBasedOneTimePasswordUtil.NUM_DIGITS_OUTPUT - numStr.length();
                sb.append(BLOCK_OF_ZEROS, 0, zeroCount);
                sb.append(numStr);
                return sb.toString();
            }
        }
 
        static byte[] decodeBase32(String str) {
            // each base-32 character encodes 5 bits
            int numBytes = ((str.length() * 5) + 7) / 8;
            byte[] result = new byte[numBytes];
            int resultIndex = 0;
            int which = 0;
            int working = 0;
            for (int i = 0; i < str.length(); i++) {
                char ch = str.charAt(i);
                int val;
                if (ch >= 'a' && ch <= 'z') {
                    val = ch - 'a';
                } else if (ch >= 'A' && ch <= 'Z') {
                    val = ch - 'A';
                } else if (ch >= '2' && ch <= '7') {
                    val = 26 + (ch - '2');
                } else if (ch == '=') {
                    // special case
                    which = 0;
                    break;
                } else {
                    throw new IllegalArgumentException("Invalid base-32 character: " + ch);
                }
                /*
                 * There are probably better ways to do this but this seemed the most
                 * straightforward.
                 */
                switch (which) {
                    case 0:
                        // all 5 bits is top 5 bits
                        working = (val & 0x1F) << 3;
                        which = 1;
                        break;
                    case 1:
                        // top 3 bits is lower 3 bits
                        working |= (val & 0x1C) >> 2;
                        result[resultIndex++] = (byte) working;
                        // lower 2 bits is upper 2 bits
                        working = (val & 0x03) << 6;
                        which = 2;
                        break;
                    case 2:
                        // all 5 bits is mid 5 bits
                        working |= (val & 0x1F) << 1;
                        which = 3;
                        break;
                    case 3:
                        // top 1 bit is lowest 1 bit
                        working |= (val & 0x10) >> 4;
                        result[resultIndex++] = (byte) working;
                        // lower 4 bits is top 4 bits
                        working = (val & 0x0F) << 4;
                        which = 4;
                        break;
                    case 4:
                        // top 4 bits is lowest 4 bits
                        working |= (val & 0x1E) >> 1;
                        result[resultIndex++] = (byte) working;
                        // lower 1 bit is top 1 bit
                        working = (val & 0x01) << 7;
                        which = 5;
                        break;
                    case 5:
                        // all 5 bits is mid 5 bits
                        working |= (val & 0x1F) << 2;
                        which = 6;
                        break;
                    case 6:
                        // top 2 bits is lowest 2 bits
                        working |= (val & 0x18) >> 3;
                        result[resultIndex++] = (byte) working;
                        // lower 3 bits of byte 6 is top 3 bits
                        working = (val & 0x07) << 5;
                        which = 7;
                        break;
                    case 7:
                        // all 5 bits is lower 5 bits
                        working |= val & 0x1F;
                        result[resultIndex++] = (byte) working;
                        which = 0;
                        break;
                    default:
                        // should never happen
                        throw new IllegalArgumentException();
                }
            }
            if (which != 0) {
                result[resultIndex++] = (byte) working;
            }
            if (resultIndex != result.length) {
                result = Arrays.copyOf(result, resultIndex);
            }
            return result;
        }
    }
}

  • TwoFactorAuthUtils.generateTFAKey() 生成两步验证 Key
  • TwoFactorAuthUtils.generateTFACode(tfaKey) 生成两步验证码
  • TwoFactorAuthUtils.validateTFACode(tfaKey,tfaCode) 验证两步验证码
  • TwoFactorAuthUtils.generateOtpAuthUrl(userName, tfaKey) 生成 Otp Auth Url

# 两步验证应用

Authy 功能丰富 专为两步验证码 iOS/Android/Windows/Mac/Linux (opens new window) Chrome 扩展 (opens new window)

Google Authenticator 简单易用,但不支持密钥导出备份 iOS (opens new window) Android (opens new window)

Microsoft Authenticator 使用微软全家桶的推荐 iOS/Android (opens new window)

1Password 强大安全的密码管理付费应用 iOS/Android/Windows/Mac/Linux/ChromeOS (opens new window)

腾讯身份验证码 简单好用 Android (opens new window)

参考文献:

什么是两步验证?怎样开启二步验证?好用的身份验证器密码 APP 推荐 (opens new window)

OTP 验证 核心代码 (opens new window)