基于TOTP 实现多因素认证(MFA);集成Microsoft Authenticator Google Authenticator实现多因素认证

一、介绍

  基于TOTP实现多因素认证,这其实就是登录时候的一个验证码,由于国内用的主流的是手机号短信验证,所以对这种方式了解的较少。目前github的登录验证集成了这种验证方式,可以上去把玩一下,更熟悉流程。

   使用流程: 比如github,找到github设置页面,绑定手机app验证这个东西,他会给你一个QR码,你使用Microsoft Authenticator 或者Google Authenticator这种app扫描QR码之后,输入app显示的验证码,进行github身份绑定。下次登陆时,除了输入账号密码外,还需要打开Microsoft Authenticator APP,找到该账号的6位验证码(30秒一刷新),输入到github网站即可验证登录成功。

   实现流程: 对于你的网站想要集成这个功能,你需要考虑这些:
    1. 生成QR码供用户扫描绑定
    2. 验证TOTP验证码是否正确
    3. 提供恢复渠道(当用户无法使用设备时, 通过输入恢复码即可登录)

  当然,对应的前端页面也要有,比如绑定页面,恢复码的展示页面,还要提供下载,让用户更方便保存。

二、实现

  这个东西主要就是:生成个密钥给Microsoft Authenticator APP ,他会根据密钥和当前的时间,来生成六位数的验证码,那么你肯定要将密钥保存好,在代码中根据相同的TOTP算法,也生成一个六位数的验证码。当用户查看app的验证码,输入后,能够和你生成的验证码匹配上,即验证通过,登录成功。
下面是实现代码,其实主要就是工具类,一个用于MFA所有相关的生成,一个是AES加密工具类,剩下的就是和你的业务相关,你可以自己设计。

  ①你可以使用getQrCode方法生成QR码给用户扫,同时将密钥和用户绑定在一起存入数据库;
  ②你可以使用verifyCode方法来验证用户输入的验证码是否正确(将数据库用户的密钥和用户输入的验证码当参数传入,ps:你数据库的密钥和Microsoft Authenticator APP上的密钥是一致的,因为扫QR码时候已经给你扫走了,所以能够验证 验证码的正确性)。
  ③你可以使用generateRecoverKey方法生成一组恢复码,给用户提供备用登录渠道。
  ④当然 这些密钥和恢复码等敏感的东西,都可以使用AesEncryptionUtil中的算法加密,(你可以使用generateEncryptionKey生成一个加密密钥和用户进行绑定)

  至于登录时候的逻辑去验证一下app的验证码或者数据库的恢复码就行。

1.引包:

<!-- ZXing Dependency -->
<dependency>
     <groupId>com.google.zxing</groupId>
     <artifactId>core</artifactId>
     <version>3.4.1</version>
 </dependency>
 <dependency>
     <groupId>com.google.zxing</groupId>
     <artifactId>javase</artifactId>
     <version>3.4.1</version>
 </dependency>

2.我的两个工具类(核心:‘生成QR码’和‘验证TOTP验证码’):

① MFA工具类

import com.google.zxing.BarcodeFormat;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.ttap.framework.exception.BusinessException;
import org.apache.commons.codec.binary.Base32;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class MfaUtil {
    private static final String QR_TITLE = "APPLICATION NAME";
    private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz";
    private static final int TIME_STEP = 30; // 时间步长
    private static final int TOTP_LENGTH = 6; // TOTP密钥长度
    private static final int CODE_LENGTH = 5; 
    private static final int RECOVER_CODE_GROUP_LENGTH = 10;

    /**
     * 生成18位的TOTP密钥,需要放到QR码中
     *
     * @return length 18, example: IPJDUKPNDCTFVJ4DQJ
     */
    public static String generateSecretKey() {
        final int KEY_SIZE_BYTES = 20;
        byte[] bytes = new byte[KEY_SIZE_BYTES];
        SecureRandom random = new SecureRandom();
        random.nextBytes(bytes);
        Base32 base32 = new Base32();
        return base32.encodeToString(bytes).substring(0, 18);
    }

    /**
     * 生成加密密钥,用于对数据库的关键数据进行加密(TOTP验证码或恢复码)
     *
     * @return length 32, example: MDKA6Z76W7RC6QYUF3S26JSJ6VMEBAGD
     */
    public static String generateEncryptionKey() {
        final int KEY_SIZE_BYTES = 20;
        byte[] bytes = new byte[KEY_SIZE_BYTES];
        SecureRandom random = new SecureRandom();
        random.nextBytes(bytes);
        Base32 base32 = new Base32();
        return base32.encodeToString(bytes);
    }

    /**
     * 生成一组10个恢复码
     *
     * @return example: [q51cA-R041w, fg7Fs-R701a ...]
     */
    public static List<String> generateRecoverKeys() {
        return IntStream.range(0, RECOVER_CODE_GROUP_LENGTH)
                .mapToObj(i -> generateRecoverKey())
                .collect(Collectors.toList());
    }

    /**
     * 生成一个恢复码
     *
     * @return example: q51cA-R041w
     */
    private static String generateRecoverKey() {
        SecureRandom random = new SecureRandom();
        StringBuilder code = new StringBuilder(CODE_LENGTH * 2 + 1);
        for (int i = 0; i < CODE_LENGTH; i++) {
            code.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
        }
        code.append("-");
        for (int i = 0; i < CODE_LENGTH; i++) {
            code.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
        }
        return code.toString();
    }

    /**
     * 生成QR码,返回的base64,里面包含用户名和密钥信息
     *
     * @param loginName
     * @param secretKey
     * @return qr base64 code
     */
    public static String getQrCode(String loginName, String secretKey) {
        String base64Image = null;
        try {
            // 1.Generate QR code content
            String qrCodeText = getQrCodeText(secretKey, loginName, QR_TITLE);
            int width = 200;
            int height = 200;
            try {
                // Convert the URL to BitMatrix
                QRCodeWriter qrCodeWriter = new QRCodeWriter();
                BitMatrix bitMatrix = qrCodeWriter.encode(qrCodeText, BarcodeFormat.QR_CODE, width, height);

                // 2.Generate QR code image
                ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                MatrixToImageWriter.writeToStream(bitMatrix, "png", outputStream);

                // 3.Gets the byte array of the image and converts it to a string using Base64 encoding
                byte[] imageData = outputStream.toByteArray();
                base64Image = java.util.Base64.getEncoder().encodeToString(imageData);
                return base64Image;
            } catch (WriterException e) {
                throw new BusinessException("Failed to generate QR code image.");
            } catch (Exception e) {
                throw new BusinessException("Unexpected error:" + e.getMessage());
            }
        } catch (BusinessException e) {
            throw new BusinessException("Abnormal QR code generation:{0}", e.getMessage());
        }
    }

    /**
     * 设置QR码中的信息:密钥、用户名等
     *
     * @param secretKey
     * @param account
     * @param issuer
     * @return
     */
    private static String getQrCodeText(String secretKey, String account, String issuer) {
        String normalizedBase32Key = secretKey.replace(" ", "").toUpperCase();
        String url = null;
        url = "otpauth://totp/"
                + URLEncoder.encode((!issuer.isEmpty() ? (issuer + ":") : "") + account, StandardCharsets.UTF_8).replace("+", "%20")
                + "?secret=" + URLEncoder.encode(normalizedBase32Key, StandardCharsets.UTF_8).replace("+", "%20")
                + (!issuer.isEmpty() ? ("&issuer=" + URLEncoder.encode(issuer, StandardCharsets.UTF_8).replace("+", "%20")) : "");
        return url;
    }

    /**
     * TOTP验证验证码是否正确,用处:用户登录时需要输入验证码进行验证。
     * 该验证码来自于APP,将该用户输入的验证码和该用户的TOTP密钥传入这个方法,就能知道用户输入的是否正确。
     *
     * @param secret  某个用户的TOTP密钥
     * @param code  该用户输入的验证码
     * @return
     */
    public static boolean verifyCode(String secret, String code) {
        long timeWindow = Instant.now().getEpochSecond() / TIME_STEP;
        Base32 base32 = new Base32();
        byte[] secretBytes = base32.decode(secret);
        try {
            Mac mac = Mac.getInstance("HmacSHA1");
            SecretKeySpec key = new SecretKeySpec(secretBytes, "HmacSHA1");
            mac.init(key);
            byte[] hash = mac.doFinal(longToBytes(timeWindow));

            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 % (int) Math.pow(10, TOTP_LENGTH);

            return otp == Integer.parseInt(code);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            e.printStackTrace();
            return false;
        }
    }

    private static byte[] longToBytes(long value) {
        byte[] result = new byte[8];
        for (int i = 7; i >= 0; i--) {
            result[i] = (byte) (value & 0xFF);
            value >>= 8;
        }
        return result;
    }
}

②AES加密工具类:


import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

public class AesEncryptionUtil {

    public static String encryptAES(String data, String secret) {
        try {
            SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(), "AES");
            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
            byte[] encryptedBytes = cipher.doFinal(data.getBytes());
            return Base64.getUrlEncoder().withoutPadding().encodeToString(encryptedBytes);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "";
    }

    public static String decryptAES(String encryptedData, String secret) {
        try {
            SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(), "AES");
            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
            cipher.init(Cipher.DECRYPT_MODE, secretKey);
            byte[] decryptedBytes = cipher.doFinal(Base64.getUrlDecoder().decode(encryptedData));
            return new String(decryptedBytes);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "";
    }
}

   3. 建表字段参考:
这个表和业务相关的,就是让你参考一下,可以自行设计,主要就是这些字段。

# 用户和totp密钥对应的表
CREATE TABLE user_mfa_secret (
  "id" int8 NOT NULL,
  "user_id" int8,
  "secret_key" varchar(100),     // TOTP密钥
  "refresh_secret_key" varchar(100),  // TOTP临时密钥。因为每次刷新QR码都需要有一个临时密钥,当用户确认绑定时将该临时密钥赋值到TOTP密钥字段。
  "is_enable" int4, // 用户是否启用TOTP的MFA验证
  "encryption_key" varchar(100), // 加密密钥。恢复码和TOTP密钥不能明文存储在数据库,我将需要加密的东西和此key混合进行AES算法加密
  "created_at" timestamp(6),
  "updated_at" timestamp(6),
   PRIMARY KEY ("id")
);
# 用户和恢复码对应的表(恢复码不和密钥绑定,而是跟随用户)
CREATE TABLE user_mfa_recover (
  "id" int8 NOT NULL,
  "user_id" int8,
  "recover_key" varchar(100) ,  // 恢复码,用户不能够使用TOTP的验证码登录时,使用这个登录。
  "is_used" int4,
  "version" int4, // 恢复码的版本,由于我的业务,我设置了 当一组十个恢复码使用完毕后,自动生成新的一组恢复码,用版本进行了区分。
  "created_at" timestamp(6),
  "updated_at" timestamp(6),
   PRIMARY KEY ("id")
);

其他

QR码的绑定图示:
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值