掘金 后端 ( ) • 2024-05-02 17:57

将用户的隐私信息直接保存在数据库下是十分不安全的,因此我们需要对源数据进行加密后再存储到数据库中,本篇文章我将介绍如何通过加盐加密算法+MD5摘要算法对源数据进行加密入库,以及如何在用户登录时验证加密后的数据。

1. MD5摘要算法

在讲解加盐加密算法之前,先给大家解释下什么是摘要加密MD5信息摘要算法

1.1 MD5摘要算法简介

摘要加密是一个不可逆的过程,也就是通过摘要算法加密后的数据无法还原回原来的数据。摘要加密的算法有很多种,MD5只是其中的一种摘要算法。

源数据可以通过MD5信息摘要算法(MD5 Message-Digest Algorithm),从而产生出一个128位(16字节)或 64位(8字节)的散列值(hash value),它有下面几个特点:

  1. 不同的数据对应一个唯一的加密值
  2. 源数据稍有变更就会导致加密值大幅变化
  3. 不管源数据的长度多少,加密值的长度永远不变(128位/64位)

给大家演示一下123456这样的数据会被加密成什么样的数据:

注:下面的网站显示32/16位的加密数据,指的是16进制的位数,一个16进制数相当于4个2进制数,因此下图所示的32/16位,也就是对应128/64位的散列值。

image.png

1.2 MD5加密的缺陷

但是单凭MD5进行摘要加密其实并不是很安全的,给大家分享一个MD5在线加解密的网站

我们惊奇的发现我们先前加密的数据通过这个网站居然可以解密出来:

image.png

这时大家是否觉得很奇怪,MD5摘要加密不是不可逆的过程吗,为什么可以通过加密后的数据获取到原来的值呢?如果能解密,我要这玩意有什么用呢,这不成小丑了吗!

image.png

不要着急,这时仔细看上面的一小行字:目前只能解密1~8位数字。给大家讲下解密的实现原理:

我们前面提到MD5加密算法的一个特点:不同的数据对应一个唯一的加密值,那我直接遍历数据建立一个彩虹表不就得了?

1~8位的数字只要写8个循环来遍历(0~9)的数字不就可以得到所有数据的加密值了吗?要解密,直接根据建立的彩虹表来取值不就得了吗。

知道这个解密原理相信大家应该都明白了:越简单的密码其实是越好解密的。

而加盐加密算法就是让简单的数据变得复杂,复杂到无法建立彩虹表,大大提升解密的成本,接下来就给大家介绍加盐加密算法的思想以及实际应用。

2. 加盐加密算法

如果只是将一个原食材进行烹饪,那么通过一种食材烹饪出来的所有食物味道都是相同的。为了让味道不同,厨师会往食材里加调料(盐),此时就让同一种食材有了不同的味道。

关于你加的是什么盐(salt),加了多少,这些问题是难以推敲的,所谓加盐加密算法就是起到这样的作用。

因此我们可以给源数据以某种规则拼接一个盐值(加盐),再将其使用MD5进行整体加密,再存入数据库中。

2.1 加盐加密常用类介绍

这里给大家介绍本篇文章将使用到的加盐加密的类。

2.1.1 java.uitl.UUID

使用java.util.UUID这个类可以获取一个全局唯一的随机值,我们可以用它来当作盐值。

@Test
void contextLoads() {
    String salt1 = UUID.randomUUID().toString();
    String salt2 = UUID.randomUUID().toString();
    String salt3 = UUID.randomUUID().toString();
    System.out.println(salt1);
    System.out.println(salt2);
    System.out.println(salt3);
}

成功获取到几个相对复杂并且随机的盐值:

380ab9d5-3766-4a9d-82bb-c6a7c7251a6c
e581fb89-7de5-41fd-839b-f949d74853c7
05574954-636c-48a9-9295-7fd398ab99c6

对源数据进行拼接:

String originPassword = "123456";
String saltPassword1 = salt1 + originPassword;
String saltPassword2 = salt2 + originPassword;
String saltPassword3 = salt3 + originPassword;
System.out.println(saltPassword1);
System.out.println(saltPassword2);
System.out.println(saltPassword3);

拼接后的数据(拼接了盐和源数据但是没有加密的字符串):

54ec7ee3-23e5-44b5-8745-ee86cd045e47123456
36c0f994-1b76-4175-9849-04cf8c2e4277123456
f4841888-c5fb-4e52-b3b8-779c4cdc7dda123456

2.1.2 org.springframework.util.DigestUtils

使用Spring Framework提供的DigestUtils.md5DigestAsHex(byte[] bytes)方法可以将二进制数据进行MD5加密

String sqlPassword1 = DigestUtils.md5DigestAsHex(saltPassword1.getBytes());
String sqlPassword2 = DigestUtils.md5DigestAsHex(saltPassword2.getBytes());
String sqlPassword3 = DigestUtils.md5DigestAsHex(saltPassword3.getBytes());
System.out.println(sqlPassword1);
System.out.println(sqlPassword2);
System.out.println(sqlPassword3);

加密结果(存入数据库的加密信息):

f9da3fa3d02b7a7d46236fa71ce4ac54
12d85efef07541da88b115a7ca54c0c6
d3ec98622d1d8f537c7b2301cb658dd3

了解了这两个类的用法后,我们就可以开始实现加盐加密算法了

2.2 加盐加密思想

2.1中我们已经尝试给源数据加上了一个盐值一起进行MD5加密处理,然而验证用户信息的时候又应该怎么验证用户输入的密码是否正确呢?

我们必须明确下面两个信息才可以进行验证信息:

  • 盐值:拼接的随机字符串的值
  • 加盐规则:如何拼接盐和源数据

这里我演示下如何对2.1中的第一个加密数据f9da3fa3d02b7a7d46236fa71ce4ac54进行验证

//用户输入的数据
String cinPassword = "123456";
//取出盐值
String salt = salt1;
//根据拼接规则拼接数据
String finalPassword = salt + cinPassword;
//对最终密码进行md5解密
String ret = DigestUtils.md5DigestAsHex(finalPassword.getBytes());
//对比数据库中的数据,如果相同就代表用户输入的密码是正确的
if (ret.equals(sqlPassword1)) {
    System.out.println("登录成功");
} else {
    System.out.println("密码错误请重试");
}

运行结果:

登录成功

但凡对用户的数据进行任意的修改,显示的结果都是密码错误请重试

2.2.1 加密数据

首先我们要明确,在用户输入账号密码进行登录的时候,我们作为程序员在后端只能拿到原始的密码数据,而没有盐值,给用户密码进行加盐加密之前,应该先考虑如何获取到盐值。

我们可以将盐值和加密数据一起存入到数据库的同一列中:

我们先前设置的盐值是如下数据:

7e8f9d1d-8775-4d5a-9ecb-73123aaf4248123456
10480364-9908-44a0-b528-d2ea4ca8c1c5123456
39808671-83eb-4a61-9441-e39be6985739123456

而加密数据是下面这样的:

aeddc9754d6e2d0a620310aebb532263
f8025e4f57af223059265901cfe2d72d
960910b7c78675e94c273ea23e26f39f

你说,好巧不巧,把盐值除去'-',它们都是由0~9、a~f组成的数据,将这些数据以某种拼接方式混在一起存入数据库,谁看了不懵逼?

//用户注册时获取盐值
String salt1 = UUID.randomUUID().toString().replace("-", "");
System.out.println("盐值:" + salt1);
//将盐值和密码以某种方式拼接
String originPassword = "123456";
String saltPassword1 = salt1 + originPassword;
System.out.println("盐值+源数据:" + saltPassword1);
//md5加密(盐值+源数据)
String md5Str = DigestUtils.md5DigestAsHex(saltPassword1.getBytes());
System.out.println("md5(盐值+源数据):" + md5Str);
//将md5加密后的数据,与盐值以某种拼接方式拼接后存入数据库
String sqlPassword1 = salt1+md5Str;
System.out.println("最终存入数据库的数据:" + sqlPassword1);

运行结果:

盐值:9b138f48e1c84089ad24fae4eeb70580
盐值+源数据:9b138f48e1c84089ad24fae4eeb70580123456
md5(盐值+源数据):23c2d52c2fd022fff30d7637fea63fba
最终存入数据库的数据:9b138f48e1c84089ad24fae4eeb7058023c2d52c2fd022fff30d7637fea63fba

上述步骤中程序员总共需要定义两次拼接规则:第一次是源数据和盐值的拼接方式,第二次是获取到加密数据后,还需要再去拼接加密数据和盐值。我们大可以自定义拼接规则,以提高黑客破解密码的难度。

现在黑客再需要解密的成本就提升了好几个量级,在黑客知道盐值是32位,密码是6位纯数字的前提下,就算是破译一个易建彩虹表的简单密码:

直接破译: 由于每个用户存放的盐值都不同,需要耗费很大的成本(遍历38个循环)才能建立一个彩虹表,并且只能破译出一个账号的密码。

破译盐值:

  1. 首先要去猜数据库中的64位16进制数据9b138f48e1c84089ad24fae4eeb7058023c2d52c2fd022fff30d7637fea63fba里面32位是盐值,哪32位是加密数据,这里面就有无数种排列组合的方式了
  2. 就算黑客知道了盐值是什么,还需要去猜9b138f48e1c84089ad24fae4eeb70580123456中源数据和盐值的排列组合方式
  3. 然后才能根据排列组合来建立彩虹表,这将是一个海量的数据。

2.2.2 验证数据

验证数据其实就是将用户输入的密码按照前面的方式加密,并且对比数据库中和加密的数据是否相同。

//用户输入的数据
String cinPassword = "123456";
//从数据库中取出盐值
String salt = sqlPassword1.substring(0,32);
//根据拼接规则拼接数据
String finalPassword = salt + cinPassword;
//对其进行md5加密
String md5Str2 = DigestUtils.md5DigestAsHex(finalPassword.getBytes());
//根据拼接规则拼接加密数据和盐值
String ret = salt + md5Str2;
//对比数据库中的数据,如果相同就代表用户输入的密码是正确的
if (ret.equals(sqlPassword1)) {
    System.out.println("登录成功");
} else {
    System.out.println("密码错误请重试");
}

2.3 编写加盐加密工具类

import lombok.extern.slf4j.Slf4j;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;

import java.util.UUID;

/**
 * 密码校验(加盐加密)
 */
@Slf4j
public class SecurityUtil {
    /**
     * 加密入库
     * @param password 明文密码
     * @return 最终存储在数据库中的加密信息
     */
    public static String encrypt(String password) {
        //生成内容不同的,长度固定的32位盐值
        String salt = UUID.randomUUID().toString().replace("-", "");
        //md5(盐值+密码)加密的固定32位字符串
        String md5Str =  DigestUtils.md5DigestAsHex((salt + password).getBytes());
        //最终将盐值和md5(盐值+密码)以某种形式存储在数据库中
        return salt + md5Str;
    }

    /**
     * 验证身份
     * @param password 待验证明文密码
     * @param sqlPassword 数据库中最终密码
     * @return true:验证通过 false:验证失败
     */
    public static boolean decrypt(String password, String sqlPassword) {
        if (!StringUtils.hasLength(password) || !StringUtils.hasLength(sqlPassword)) {
            return false;
        }
        if (sqlPassword.length() != 64) {
            log.error("数据库中的密码格式不对");
            return false;
        }
        // 根据存储时的规则取出盐值
        String salt = sqlPassword.substring(0, 32);
        // 使用盐值 + 待确认的密码生成待验证的加盐密码
        String md5Str =
                DigestUtils.md5DigestAsHex((salt + password).getBytes());
        String finalPassword = salt + md5Str;

        return (sqlPassword).equals(finalPassword);
    }
}