2022年了,密码该如何保存都不会?!

5,701 阅读9分钟

这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战

大家新年好呀,我是小黑。

我们在开发应用时,只要涉及到用户,登录注册功能则是必不可少的。 但是,并不是所有人都能做好登录注册功能。比如最基本的密码应该如何保存?应该用哪种加密方式对密码进行加密都不是很清楚。

一旦出现数据库泄漏,密码外泄等问题,会对用户造成极大的损失。

密码该如何保存?

如果我们要在服务器中对用户进行身份验证,我们需要完成以下的步骤:

  • 获取到要登录用户的用户名和密码;
  • 根据用户名在数据库中查找到用户;
  • 比较用户提供的密码和数据库中的密码是否一致。

那我们应该如何存储用户的密码呢?我们来看看都有哪些方式,以及存在的问题。

明文保存

将用户的密码以明文方式保存。

很显然,有点常识的人都应该知道,密码不能用明文保存的。但是话又说回来,系统都是由人开发的,开发系统的人可能并不专业。比如之前某个大型中文开发者社区,因为数据库泄露,导致大批用户的密码泄漏,而他们的密码就是明文保存的。

HASH保存

使用Hash函数计算出密码的hash值保存,可以解决密码直接暴露的问题。

Hash函数是一个单向函数,不能通过结果值反向得出原始值,Hash函数可以将一串密码转换成一个固定长度的字符串。

  • 在用户注册时,将用户的密码使用Hash函数计算出Hash值后保存到数据库;
  • 当用户登录时,对用户提交的密码使用相同的Hash函数计算出Hash值,和数据库中的Hash值进行比较。

这样可以避免让攻击者直接获取到用户的密码明文,攻击者想通过暴力攻击将字符串计算出hash值则需要花费巨大的精力,并且Hash值越长破解难度越大。

但是通过彩虹表攻击,攻击者仍然可以成功破解。 彩虹表是一个包含许多提前计算出Hash值的表,其中包含数百万个密码对应的hash值,对于一些简单密码可以非常快的破解。

所以,如果你不确定你注册的服务是采用哪种方式保存的密码,尽量将密码复杂度设置高一些。

加盐Hash

为了防止彩虹表攻击,可以使用Hash算法加盐处理。

是在进行Hash计算时,和原始密码拼接在一起进行计算的一个随机序列。

  • 用户注册时,将密码和盐值组合后进行Hash计算,得到密码结果保存在数据库中;
  • 当用户在登录验证时,将原始密码加盐后进行Hash计算,得到结果值和数据库中的密码进行比较。

因为彩虹表中的密码和加盐后的密码不一样,可以防止彩虹表攻击。如果盐值足够长并且随机,那么就可以保证在彩虹表中不能找到和密码相同的hash值。

但是,由于攻击者是有可能获取到盐值的,攻击者可以调整彩虹表生成的算法,用获取到的盐值计算出新的彩虹表,同样可以获取到密码。虽然计算一个新的彩虹表花费的时间巨大,但是随着硬件条件越来越好,要计算出一张彩虹表会变得越来越容易。

所以,使用Hash算法加盐处理,可以保证密码不被快速破解,但是还不够安全。

密码加密函数

Hash函数设计的初衷并不仅仅是对密码进行Hash计算,所以Hash函数的运算速度非常快,但是这样一来,攻击者也能快速计算hash值,进行暴力破解。

为了解决这个问题,我们可以让Hash加密函数变慢

我们只要让密码加密的时间在用户能接受的时间内,尽量的慢,这样攻击者蛮力破解将会花费无限的时间。

有以下一些专门用来加密密码的算法:

  • bcrypt
  • scrypt
  • PBKDF2
  • argon2

这些算法使用一些复杂的加密算法,并会故意让计算变慢。

工作因子

可以通过在算法中配置工作因子,来调整加密函数计算时间的缓慢程度。

每个密码加密算法都有自己的工作因子。工作因子影响密码编码的速度。例如,bcrypt有参数strength,该算法将使2的strength次方来计算哈希值。数字越大,编码越慢。

使用Spring Security加密密码

现在让我们看看 Spring Security 如何支持这些算法,以及我们如何使用它们加密密码。

PasswordEncoder

在Spring Security 中有一个PasswordEncoder接口。所有密码编码器都实现了该接口。

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);

    boolean matches(CharSequence rawPassword, String encodedPassword);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

该接口中有两个方法:

encode()方法用户将明文密码转换为密文形式;

matches()方法用户将明文密码与密文密码进行比较。

BCryptPasswordEncoder

String plainPassword = "123456";
// 工作因子
int strength = 10;
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(strength, new SecureRandom());
String encodedPassword = bCryptPasswordEncoder.encode(plainPassword);
System.out.println(encodedPassword);

BCryptPasswordEncoder中的参数strength是密码加密算法的工作因子,Spring Security中的默认值为10

在创建时指定SecureRandom作为随机加盐生成器。

$2a$10$pYxXvggEgN7znYKofHIr/uRTw.dsYeW9mbxzNMSNOoGIYZU8twXNG

Pbkdf2PasswordEncoder

PBKDF2 算法不是为密码编码专门设计的,而是为了从密码中派生出密钥而设计的。当我们想用密码对某些数据进行加密时,通常需要密钥,但密码的强度不足以用作加密密钥。

String plainPassword = "123456";
//加密秘钥
String pepper = "小黑说JAVA";
// 哈希次数
int iterations = 200000;
// 哈希长度
int hashWidth = 256;

Pbkdf2PasswordEncoder pbkdf2PasswordEncoder = new Pbkdf2PasswordEncoder(pepper, iterations, hashWidth);
pbkdf2PasswordEncoder.setEncodeHashAsBase64(true);
String encodedPassword = pbkdf2PasswordEncoder.encode(plainPassword);
System.out.println(encodedPassword);

Pbkdf2PasswordEncoder会多次在普通密码上运行哈希算法。我们可以定义输出的hash长度,并额外使用pepper让密码编码更安全。

WnCG4wMZFHPAD9DGg+SChNceQqbeAZRQyf2OHCK5WKdYBRzbeAGsQg==

Pbkdf2PasswordEncoder默认会执行185000哈希计算,默认的哈希长度为256。

SCryptPasswordEncoder

SCryptPasswordEncoder算法可以配置CPU和内存成本,通过这两项配置可以让攻击者破解密码的难度更大。

String plainPassword = "123456";
// cpu消耗
int cpuCost = (int) Math.pow(2, 14);
// 内存消耗
int memoryCost = 8;
// currently not supported by Spring Security
int parallelization = 1;
// 秘钥长度
int keyLength = 32;
// 盐值长度
int saltLength = 64;

SCryptPasswordEncoder sCryptPasswordEncoder = new SCryptPasswordEncoder(
    cpuCost,
    memoryCost,
    parallelization,
    keyLength,
    saltLength);
String encodedPassword = sCryptPasswordEncoder.encode(plainPassword);
System.out.println(encodedPassword);

输出结果如下:

$e0801$PgZZvXdDjbxMZJi4eidFCHblUdvwOT/n0FZFyCWIHloqL6Wkbk7bAJ2nwVIWsW9PJTodncEtok1qcaWR+u+pZg==$lcqK7ACDTv8gG3ZwGoz0X7rn4EnZvnEcZ7rS0Qq31Ng=

Argon2PasswordEncoder

Argon2算法是2015 年密码哈希竞赛的获胜者。该算法也允许我们调整 CPU 和内存成本。该算法将所有参数保存在结果字符串中。

int saltLength = 16;
int hashLength = 32;
int parallelism = 1;
int memory = 4096;
int iterations = 3;

Argon2PasswordEncoder argon2PasswordEncoder = new Argon2PasswordEncoder(
    saltLength,
    hashLength,
    parallelism,
    memory,
    iterations);
String encodePassword = argon2PasswordEncoder.encode(plainPassword);

输出结果如下:

$argon2id$v=19$m=4096,t=3,p=1$uft4b+crs6tiwOhDnuFsIg$d/GXjYZnEw+/ubVnPqNeQDFX32GRYe+yTwuwydXLjos

在Spring Boot中设置PasswordEncoder

接下来,为了能更好的了解PasswordEncoder在Spring Boot中如何应用,我们先来开发一个Rest Api接口,并且配置Spring Security支持基于密码验证。

配置PasswordEncoder

首先,我们创建一个需要Spring Security保护的Rest API:

@RestController
public class BlogRest {

    @GetMapping(path = "/blogs")
    public List<Blog> blogs() {
        return Lists.newArrayList(new Blog("hello world", "小黑说Java"));
    }
}

我们需要/blogs接口的访问需要经过用户身份的验证。因此,我们使用 Spring Security 配置:

/**
 * @author 小黑说Java
 * @ClassName SecurityConfiguration
 * @Description
 * @date 2022/2/3
 **/
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
            .csrf()
            .disable()
            .authorizeRequests()
            .antMatchers("/registration")
            .permitAll()
            .anyRequest()
            .authenticated()
            .and()
            .httpBasic();
    }
    // other codes.
}
  • 该配置表示除了/registration 外,其他的请求路径都需要进行身份验证;
  • 每当向应用程序发送 HTTP 请求时,Spring Security 都会检查Header是否包含Authorization: Basic <credentials>.
  • 如果未设置Header,则服务器会返回 401
  • 如果 Spring Security 找到对应Header,它将进行身份验证。

Spring Security 在进行身份验证时,需要从数据库中查询用户名、密码信息,需要提供一个UserDetailsService接口的实现类,实现该接口中的loadUserByUsername方法。所以我们定义如下接口DatabaseUserDetailsService:

/**
 * @author 小黑说Java
 * @ClassName DataBaseUserDetailService
 * @Description
 * @date 2022/2/3
 **/
@Service
@Transactional
public class DataBaseUserDetailService implements UserDetailsService {

    private final UserDAO userDAO;

    private final UserMapper userMapper;

    public DataBaseUserDetailService(UserDAO userDAO, UserMapper userMapper) {
        this.userDAO = userDAO;
        this.userMapper = userMapper;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userDAO.selectByUserName(username);
        return userMapper.toUserDetails(user);
    }
}

Spring Security中的AuthenticationProvider接口的实现在身份验证时将使用UserDetailsService来执行身份验证逻辑。

AuthenticationProvider接口的实现有很多,因为我们的用户信息存在数据库中,所以我们使用DaoAuthenticationProvider

@Configuration
@EnableWebSecurity
class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final DatabaseUserDetailsService databaseUserDetailsService;

    // constructor ...

    @Bean
    public AuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        // 设置密码加密器
        provider.setPasswordEncoder(passwordEncoder2());
        // 设置用户信息查询服务
        provider.setUserDetailsService(this.databaseUserDetailsService);
        return provider;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // ...

}

到这里,我们已经完成了Spring Security的配置,如果客户端发送带有基本身份验证的Header的HTTP请求,Spring Security将读取该Header信息,根据username获取数据库中的用户信息,并使用BCryptPasswordEncoder 进行密码验证,如果一致则验证通过。如果不一致,服务器将响应 401。

用户注册服务

在验证用户身份之前,我们需要先在数据库中保存用户,也就是用户需要先注册账号。那么我们来实现一个用户注册的接口:

@RestController
public class UserRest {

    private final UserRegistrationService userRegistrationService;

    public UserRest(UserRegistrationService userRegistrationService) {
        this.userRegistrationService = userRegistrationService;
    }

    @PostMapping("/registration")
    @ResponseStatus(code = HttpStatus.CREATED)
    public void register(@RequestBody UserDTO user) {
        // 注册用户
        userRegistrationService.register(user);
    }
}

按照我们对Spring Security规则的定义,/registration路径的访问不需要进行身份验证。

我们在register方法中调用userRegistrationService.register(user)进行用户注册。

@Service
@Transactional
public class UserRegistrationService {

    private final UserDAO userDAO;

    private final PasswordEncoder passwordEncoder;

    public UserRegistrationService(UserDAO userDAO, PasswordEncoder passwordEncoder) {
        this.userDAO = userDAO;
        this.passwordEncoder = passwordEncoder;
    }

    public void register(UserDTO userDTO) {
        User user = new User();
        user.setUserStatus(UserStatusEnum.INFORCE.getStatus());
        user.setUsername(userDTO.getUsername());
        user.setPassword(passwordEncoder.encode(userDTO.getPassword()));
        userDAO.insert(user);
    }
}

在进行用户注册时,我们通过PasswordEncoder将用户提供的明文密码进行加密后,保存到数据库中。

小结

以上就是本期的主要内容,我们讲了密码应该如何在系统中保存,最没有常识和安全意识的就是明文保存;使用Hash算法保存会被彩虹表攻击,同样也不可取;而使用加盐Hash加密虽然能一定程度地降低彩虹表攻击的可能性,但是随着硬件性能的发展,同样可能被彩虹表攻击,所以我们应该选择一些特定的密码加密算法。比如 Bcrypt,Pbkdf2,Scrypt,Argon2等。

最后我们通过SpringBoot+Spring Security完成了一个用户登录和注册的功能。

希望本文能对你有所帮助,写文不易,需要一点正反馈,可以的话点个赞吧。

我是小黑,一名在互联网“苟且”的程序员

流水不争先,贵在滔滔不绝