• SpringBoot项目添加2FA双因素身份认证


    什么是 2FA(双因素身份验证)?

    双因素身份验证(2FA)是一种安全系统,要求用户提供两种不同的身份验证方式才能访问某个系统或服务。国内普遍做短信验证码这种的用的比较少,不过在国外的网站中使用双因素身份验证的还是很多的。用户通过使用验证器扫描二维码,就能在app上获取登录的动态口令,进一步加强了账户的安全性。

    主要步骤

    pom.xml中增加依赖

    
    <dependency>
        <groupId>commons-codecgroupId>
        <artifactId>commons-codecartifactId>
        <version>1.15version>
    dependency>
    
    <dependency>
        <groupId>org.iherusgroupId>
        <artifactId>qrext4jartifactId>
        <version>1.3.1version>
    dependency>
    

    用户表中增加secretKey列

    为用户绑定secretKey字段,用以生成二维码及后期校验
    image

    工具类

    谷歌身份验证器工具类

    
    /**
     * 谷歌身份验证器工具类
     */
    public class GoogleAuthenticator {
    
        /**
         * 时间前后偏移量
         * 用于防止客户端时间不精确导致生成的TOTP与服务器端的TOTP一直不一致
         * 如果为0,当前时间为 10:10:15
         * 则表明在 10:10:00-10:10:30 之间生成的TOTP 能校验通过
         * 如果为1,则表明在
         * 10:09:30-10:10:00
         * 10:10:00-10:10:30
         * 10:10:30-10:11:00 之间生成的TOTP 能校验通过
         * 以此类推
         */
        private static int WINDOW_SIZE = 0;
    
        /**
         * 加密方式,HmacSHA1、HmacSHA256、HmacSHA512
         */
        private static final String CRYPTO = "HmacSHA1";
    
        /**
         * 生成密钥,每个用户独享一份密钥
         *
         * @return
         */
        public static String getSecretKey() {
            SecureRandom random = new SecureRandom();
            byte[] bytes = new byte[20];
            random.nextBytes(bytes);
            Base32 base32 = new Base32();
            String secretKey = base32.encodeToString(bytes);
            // make the secret key more human-readable by lower-casing and
            // inserting spaces between each group of 4 characters
            return secretKey.toUpperCase();
        }
    
        /**
         * 生成二维码内容
         *
         * @param secretKey 密钥
         * @param account   账户名
         * @param issuer    网站地址(可不写)
         * @return
         */
        public static String getQrCodeText(String secretKey, String account, String issuer) {
            String normalizedBase32Key = secretKey.replace(" ", "").toUpperCase();
            try {
                return "otpauth://totp/"
                        + URLEncoder.encode((!StringUtils.isEmpty(issuer) ? (issuer + ":") : "") + account, "UTF-8").replace("+", "%20")
                        + "?secret=" + URLEncoder.encode(normalizedBase32Key, "UTF-8").replace("+", "%20")
                        + (!StringUtils.isEmpty(issuer) ? ("&issuer=" + URLEncoder.encode(issuer, "UTF-8").replace("+", "%20")) : "");
            } catch (UnsupportedEncodingException e) {
                throw new IllegalStateException(e);
            }
        }
    
        /**
         * 获取验证码
         *
         * @param secretKey
         * @return
         */
        public static String getCode(String secretKey) {
            String normalizedBase32Key = secretKey.replace(" ", "").toUpperCase();
            Base32 base32 = new Base32();
            byte[] bytes = base32.decode(normalizedBase32Key);
            String hexKey = Hex.encodeHexString(bytes);
            long time = (System.currentTimeMillis() / 1000) / 30;
            String hexTime = Long.toHexString(time);
            return TOTP.generateTOTP(hexKey, hexTime, "6", CRYPTO);
        }
    
        /**
         * 检验 code 是否正确
         *
         * @param secret 密钥
         * @param code   code
         * @param time   时间戳
         * @return
         */
        public static boolean checkCode(String secret, long code, long time) {
            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 = (time / 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.
            long hash;
            for (int i = -WINDOW_SIZE; i <= WINDOW_SIZE; ++i) {
                try {
                    hash = verifyCode(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();
                    throw new RuntimeException(e.getMessage());
                }
                if (hash == code) {
                    return true;
                }
            }
            return false;
        }
    
        /**
         * 根据时间偏移量计算
         *
         * @param key
         * @param t
         * @return
         * @throws NoSuchAlgorithmException
         * @throws InvalidKeyException
         */
        private static long verifyCode(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, CRYPTO);
            Mac mac = Mac.getInstance(CRYPTO);
            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 truncatedHash;
        }
    
        public static void main(String[] args) {
            for (int i = 0; i < 100; i++) {
                String secretKey = getSecretKey();
                System.out.println("secretKey:" + secretKey);
                String code = getCode(secretKey);
                System.out.println("code:" + code);
                boolean b = checkCode(secretKey, Long.parseLong(code), System.currentTimeMillis());
                System.out.println("isSuccess:" + b);
            }
        }
    }
    

    二维码工具类

    /**
     * 验证码生成工具类
     */
    public class TOTP {
    
        private static final int[] DIGITS_POWER = {1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000};
    
        /**
         * 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];
            System.arraycopy(bArray, 1, ret, 0, ret.length);
            return ret;
        }
    
        /**
         * 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
         */
        public static String generateTOTP(String key, String time, String returnDigits, String crypto) {
            int codeDigits = Integer.decode(returnDigits);
            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;
        }
    }
    

    Service

    @Transactional(isolation = Isolation.REPEATABLE_READ, propagation = Propagation.REQUIRED)
    @Service
    public class TwoFAService {
        @Autowired
        private UserMapper userMapper;
    
       /**
        * 获取SecureKey
        */
        public String getSecureKey(Integer userId) {
            User user = userMapper.selectUserById(userId);
            return user.getSecretKey();
        }
    
        /**
         * 更新secureKey
         */
        public Integer updateSecureKey(Integer userId, String secureKey) {
            return userMapper.updateSecureKeyById(userId, secureKey);
        }
    
       /**
        * 校验动态码
        */
        public boolean chek2FACode(User user, String twoFACode) throws Exception {
            String secretKey = user.getSecretKey();
            // 没绑定设备就先验证通过
            if(secretKey == null || secretKey.isEmpty()) {
                return true;
            } else  {
               if(twoFACode.isEmpty()) { throw new Exception("已绑定设备,请输入动态码"); }
               boolean checkRes = GoogleAuthenticator.checkCode(secretKey, Long.parseLong(twoFACode), System.currentTimeMillis());
               if(!checkRes) {
                   throw new Exception("动态码错误");
               } else {
                   return true;
               }
            }
        }
    }
    

    Controller

    用户登录中增加两步验证:

    @Controller
    @RequestMapping(value = "/mgr")
    public class UserController {
        @Autowired
        private UserService userService;
        @Autowired
        private LogService logService;
        @Autowired
        private TwoFAService twoFAService;
    
        /**
         * @Description: 用户登录
         */
        @RequestMapping(value = "/user/login", method = RequestMethod.POST)
        @ResponseBody
        public GlobalResult login(String userCode, String userPwd, String twoFACode) {
            try {
                UsernamePasswordToken token = new UsernamePasswordToken(userCode, userPwd);
                Subject subject = SecurityUtils.getSubject();
                subject.login(token);
    
                // 2FA验证
                User user = (User) subject.getPrincipal();
                twoFAService.chek2FACode(user, twoFACode);
    
                Log log = new Log();
                .......
            }
        }
    }
    

    两步验证的Controler

    @RestController
    @RequestMapping(value = "/2fa")
    public class TwoFAController {
        @Autowired
        private TwoFAService twoFAService;
    
        /**
         * 生成二维码信息对象
         */
        @GetMapping("/getQrcode")
        public QrCodeResponse getQrcode(@RequestParam("userId") Integer userId, @RequestParam("userCode") String userCode, HttpServletResponse response) throws Exception {
            try {
                String secretKey = twoFAService.getSecureKey(userId);
                QrCodeResponse qrCodeResponse = new QrCodeResponse();
                if(secretKey == null || secretKey.isEmpty()) {
                    secretKey = GoogleAuthenticator.getSecretKey();
                    qrCodeResponse.setBind(false);
                    // userMapper.updateSecureKeyById(userId, secretKey);
                } else {
                    qrCodeResponse.setBind(true);
                }
    
                // 生成二维码内容
                String qrCodeText = GoogleAuthenticator.getQrCodeText(secretKey, userCode, "suggest-mgr");
                // 以流的形式返回生成二维码输出
                // new SimpleQrcodeGenerator().generate(qrCodeText).toStream(response.getOutputStream());
                BufferedImage image = new SimpleQrcodeGenerator().generate(qrCodeText).getImage();
                // 将图片转换为Base64字符串
                String base64Image = convertImageToBase64(image);
                qrCodeResponse.setQrCodeText(secretKey);
                qrCodeResponse.setBase64Image(base64Image);
    
                return qrCodeResponse;
            } catch (Exception e) {
                // 处理异常
                e.printStackTrace();
                return null; // 或者返回适当的错误信息
            }
        }
    
        /**
         * 更新SecretKey
         * @param userId
         * @param secretKey
         */
        @GetMapping("/updateSecretKey")
        public void updateSecretKey(@RequestParam("userId") Integer userId, @RequestParam("secretKey") String secretKey) {
            twoFAService.updateSecureKey(userId, secretKey);
        }
    
        /**
         * 获取新的secretKey 重置用
         * @param userId
         * @param userCode
         * @return
         */
        @GetMapping("/getNewSecretKey")
        public QrCodeResponse getNewSecretKey(@RequestParam("userId") Integer userId, @RequestParam("userCode") String userCode, HttpServletResponse response) throws Exception {
            try {
                String secretKey = secretKey = GoogleAuthenticator.getSecretKey();
                QrCodeResponse qrCodeResponse = new QrCodeResponse();
                qrCodeResponse.setBind(false);
    
                // 生成二维码内容
                String qrCodeText = GoogleAuthenticator.getQrCodeText(secretKey, userCode, "xxx-site");
                BufferedImage image = new SimpleQrcodeGenerator().generate(qrCodeText).getImage();
                // 将图片转换为Base64字符串
                String base64Image = convertImageToBase64(image);
                qrCodeResponse.setQrCodeText(secretKey);
                qrCodeResponse.setBase64Image(base64Image);
    
                // 返回包含qrCodeText和Base64编码图片的信息
                return qrCodeResponse;
            } catch (Exception e) {
                // 处理异常
                e.printStackTrace();
                return null; // 或者返回适当的错误信息
            }
        }
    
        /**
         * 将图片文件流转为base64
         */
        private String convertImageToBase64(BufferedImage image) {
            try {
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ImageIO.write(image, "png", baos);
                byte[] imageBytes = baos.toByteArray();
                return Base64.getEncoder().encodeToString(imageBytes);
            } catch (Exception e) {
                // 处理异常
                return "";
            }
        }
    
        static public class QrCodeResponse {
            private String secretKey;
            private String base64Image;
            private boolean isBind;
    
            public String getSecretKey() {
                return secretKey;
            }
    
            public void setSecretKeyt(String secretKey) {
                this.secretKey = secretKey;
            }
    
            public String getBase64Image() {
                return base64Image;
            }
    
            public void setBase64Image(String base64Image) {
                this.base64Image = base64Image;
            }
    
            public boolean isBind() {
                return isBind;
            }
    
            public void setBind(boolean bind) {
                isBind = bind;
            }
        }
    }
    

    常用2FA验证工具

    1. Google Authenticator: google play, apple store
    2. Microsoft Authenticator: google play , apple store
    3. AuthenticatorPro(开源):https://github.com/jamie-mh/AuthenticatorPro

    __EOF__

  • 本文作者: 会飞的一棵树
  • 本文链接: https://www.cnblogs.com/flytree/p/18157098
  • 关于博主: 评论和私信会在第一时间回复。或者直接私信我。
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 声援博主: 如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。
  • 相关阅读:
    HTML期末作业,基于html实现中国脸谱传统文化网站设计(5个页面)
    leetcode-279. 完全平方数
    基于Python机器学习、深度学习提升气象、海洋、水文领域实践应用
    每天五分钟机器学习:从数学向量角度理解支持向量机为何是大间距分类器
    基于OT与CRDT协同算法的文档划词评论能力实现
    Lambda表达式 ( Java 8新特性 )
    互换性与测量技术基础总复习题(答案)
    vuex学习记录
    达梦DEM监控配置
    Spring源码分析(四) Aop全流程
  • 原文地址:https://www.cnblogs.com/flytree/p/18157098