在开发过程中,我们常常需要设置一些初始化账户来供用户操作使用。这时候就需要为账户设置密码,为了让密码尽量随机,可以采用程序随机生成的方式,生成满足一定要求的密码。

1. 常见密码格式

密码要求

在一些网站的注册中,注册账户时,密码通常具有以下要求:

  1. 至少包含一个英文字母和一个数字;
  2. 至少包含一个大写字母,一个小写字母和一个数字;
  3. 至少包含一个一个字母,一个数字和一个特殊字符;
  4. 至少包含一个大写字母,一个小写字母,一个数字和一个特殊字符。

密码格式

以上四种密码在java中,可以用正则表达式进行验证,这里假设密码的最短长度为8个字符,那么对应的正则表达式如下所示:

  1. "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$"
  2. "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"
  3. "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"])[A-Za-z\\d\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"]{8,}$"
  4. "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"])[a-zA-Z0-9\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"]{8,}$"

特殊字符

一共有33个特殊字符(可以用键盘输入的),其中就包括空格。如果考虑空格,那么密码在校验过程中就会比较繁琐,因此使用剩下的32个特殊字符作为种子,用来随机生成密码。

在Java中,一些字符的匹配需要转义后才能进行正确的匹配,如:

  • \\\\匹配\
  • \\[匹配[

2.设计思路

概述

明确了密码的常见格式,以及对应的正则表达式后,就可以进行代码的编写。

由于需要随机生成密码,所以这里用到了Java内置的SecureRandom类,用它来生成随机数。

由于我们需要生成固定长度、固定格式的密码,这里首先获取密码的长度,之后便可以根据对应格式的格式来生成密码。

这里举例进行说明。

至少包含一个英文字母和一个数字

首先创建两个数组,分别包含大小写英文字母(LOWER_AND_UPPER_LETTERS),数字(NUMBERS)。

假设生成密码的长度为8.

数组创建完毕后,首先生成范围为[1,8)的随机数,这里假设生成的随机数为6,那么就创建一个循环6次的for循环,每一次循环,都在数组NUMBERS中随机取一个数值,这样循环完毕后,就有了6个随机数;

之后创建一个循环2次的for循环,每次循环在数组LOWER_AND_UPPER_LETTERS中随机取一个字符,循环结束后,就有了2个随机字符。

将循环后得到的两部分字符数组合并为一个字符数组,并调用Collections.shuffle方法对数组进行打乱,这样就得到了至少包含一个英文字母和一个数字,且长度为8位的密码。

其他格式

对于其他格式的密码生成策略,也是采用以上的思路进行生成。比如至少包含一个一个字母,一个数字和一个特殊字符,这时候密码就有三部分:

假设密码长度为8。

  1. 第一部分,随机数范围为[1,7),假设生成的值为first
  2. 第二部分,随机数范围为[1,8-first-1),假设生成的值为second
  3. 第三部分,长度为8-first-second

四部分的密码思路如下:

  1. 第一部分,随机数范围为[1,6),假设生成的值为first
  2. 第二部分,随机数范围为[1,8-first-2),假设生成的值为second
  3. 第三部分,随机数范围为[1,8-first-second-1),假设生成的值为third
  4. 第四部分,长度为8-first-second-third

之后每一部分都循环对应的次数,每一次循环都随机取给定格式字符数组中的任一个字符,将各个部分合并后,再进行打乱,这样就得到了符合要求的密码。

3.实现代码

package com.flywinter.maplebillbackend.utils;

import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Created by IntelliJ IDEA
 * User:Zhang Xingkun
 * Date:2022/6/23 16:50
 * Description:
 */
public class PasswordGenerator {

    public static final SecureRandom RANDOM = new SecureRandom();

    protected static final List<Character> NUMBERS = List.of('0', '1', '2', '3', '4', '5', '6', '7', '8', '9');
    protected static final List<Character> LOWER_LETTERS = List.of('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z');
    protected static final List<Character> UPPER_LETTERS = List.of('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z');
    protected static final List<Character> LOWER_AND_UPPER_LETTERS = mergeLists(LOWER_LETTERS, UPPER_LETTERS);
    protected static final List<Character> SPECIAL_CHARACTERS = List.of('!', '"', '#', '$', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '_', '`', '{', '|', '}', '~');

    private PasswordGenerator() {
    }

    /**
     * Generate a random password with the given length.
     * Format: ^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[\\!@#$%^&*()_+-=\[\]{};':|,.<>/?~`"])[a-zA-Z0-9\\!@#$%^&*()_+-=\[\]{};':|,.<>/?~`"]{4,}$
     *  at least 4 characters, at least one number, at least one lowercase letter, at least one uppercase letter, at least one special character.
     *  such as 2K,l  @2pL
     * @param length the length of the password
     * @return the generated password
     */
    public static String generatePassword(int length) {
        return generatePassword(length, true,true);
    }

    /**
     * Generate a random password with the given length.
     * Format:
     *  useSpecial is true ^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[\\!@#$%^&*()_+-=\[\]{};':|,.<>/?~`"])[a-zA-Z0-9\\!@#$%^&*()_+-=\[\]{};':|,.<>/?~`"]{4,}$
     *   such as 1^Lm
     *  useSpecial is false ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{4,}$
     *   such as 1kMn
     * @param length the length of the password
     * @param useSpecialCharacters whether to use special characters
     * @return the generated password
     */
    public static String generatePassword(int length, boolean useSpecialCharacters) {
        List<Character> numbers = getSpecifiedTypeLetters(NUMBERS, getRandomLength(length - 3));
        List<Character> lowerLetters = getSpecifiedTypeLetters(LOWER_LETTERS, getRandomLength(length - numbers.size() - 2));
        final int lettersSize = numbers.size() - lowerLetters.size();
        final List<Character> upperAndSpecial = hasSpecialLetters(useSpecialCharacters, length, lettersSize, UPPER_LETTERS);
        final var passwordList = mergeLists(numbers, lowerLetters, upperAndSpecial);

        return getRandomPassword(passwordList);
    }

    /**
     * Generate a random password with the given length.
     * Format:
     *  useSpecial and isCaseSensitive are true that is generatePassword(int length)
     *  usCaseSensitive is true that is generatePassword(int length, boolean useSpecialCharacters)
     *  useCaseSensitive and useSpecialCharacters are false.
     *      It will be at least 3 characters, at least one number, at least one letter(lowercase letter or uppercase letter).
     *      ^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{3,}$
     *      such as 1Km   K2p  KH2
     *  useCaseSensitive is false and useSpecialCharacters are true.
     *      It will be at least 3 characters, at least one number, at least one letter(lowercase letter or uppercase letter), at least one special character.
     *      ^(?=.*[A-Za-z])(?=.*\d)(?=.*[\\!@#$%^&*()_+-=\[\]{};':|,.<>/?~`"])[A-Za-z\d\\!@#$%^&*()_+-=\[\]{};':|,.<>/?~`"]{8,}$
     *      such as 1L,  3o@
     * @param length the length of the password
     * @param useSpecialCharacters whether to use special characters
     * @param isCaseSensitive whether is case sensitive
     * @return the generated password
     */
    public static String generatePassword(int length, boolean useSpecialCharacters, boolean isCaseSensitive) {
        if (isCaseSensitive) {
            return generatePassword(length, useSpecialCharacters);
        }

        List<Character> numbers = getSpecifiedTypeLetters(NUMBERS, getRandomLength(length - 2));
        final List<Character> letterAndSpecial = hasSpecialLetters(useSpecialCharacters, length, numbers.size(), LOWER_AND_UPPER_LETTERS);
        final var passwordList = mergeLists(numbers, letterAndSpecial);

        return getRandomPassword(passwordList);
    }

    /**
     * Generate a random password with the given list.
     * @param numbers the list of origin password characters
     * @return the random password
     */
    private static String getRandomPassword(List<Character> numbers) {
        Collections.shuffle(numbers);
        return numbers.stream().map(Object::toString).collect(Collectors.joining());
    }

    /**
     * Generate a character list with the special characters or not.
     * @param useSpecialCharacters whether to use special characters
     * @param length the length of the password
     * @param lettersSize the size of the other letters(numbers or English letters)
     * @param englishLetters the list of English letters(lowercase or uppercase or lowercase and uppercase)
     * @return password list with special characters or not
     */
    private static List<Character> hasSpecialLetters(boolean useSpecialCharacters, int length, int lettersSize, List<Character> englishLetters) {
        List<Character> specialLetters;
        List<Character> upperLetters;
        if (useSpecialCharacters) {
            final int upperLetterLength = getRandomLength(length - lettersSize - 1);
            upperLetters = getSpecifiedTypeLetters(englishLetters, upperLetterLength);
            final int specialLetterLength = length - lettersSize - upperLetters.size();
            specialLetters = getSpecifiedTypeLetters(SPECIAL_CHARACTERS, specialLetterLength);
            return mergeLists(upperLetters, specialLetters);
        }
        return getSpecifiedTypeLetters(englishLetters, length - lettersSize);
    }

    /**
     * Merge lists.
     * @param lists the lists to merge
     * @return the merged list
     */
    @SafeVarargs
    private static List<Character> mergeLists(List<Character>... lists) {
        return Stream.of(lists)
                .flatMap(List::stream)
                .collect(Collectors.toList());
    }

    /**
     * Get a random length.
     * @param max the max length
     * @return the random length
     */
    private static int getRandomLength(int max) {
        return RANDOM.nextInt(1, max);
    }

    /**
     * Get specified length random list from the given list.
     * @param characterList the source list
     * @param size the size of the random list
     * @return the random list
     */
    private static List<Character> getSpecifiedTypeLetters(List<Character> characterList, int size) {
        List<Character> tmpList = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            tmpList.add(characterList.get(RANDOM.nextInt(characterList.size())));
        }
        return tmpList;
    }

}

4.测试类

package com.flywinter.maplebillbackend.utils;

import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import static org.junit.jupiter.api.Assertions.*;

/**
 * Created by IntelliJ IDEA
 * User:Zhang Xingkun
 * Date:2022/6/23 16:51
 * Description:
 */
class PasswordGeneratorTest {

    @Test
    void should_generate_password_with_num_lower_upper_special() {
        String password = PasswordGenerator.generatePassword(8);
        assertTrue(password.matches("^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"])[a-zA-Z0-9\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"]{8,}$"));
    }

    @Test
    void should_throw_error_when_password_length_smaller_than_3() {
        assertThrows(IllegalArgumentException.class, () -> PasswordGenerator.generatePassword(3));
    }

    @Test
    void should_generate_password_with_num_lower_upper() {
        String password = PasswordGenerator.generatePassword(8, false);
        assertTrue(password.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"));
    }

    @Test
    void should_generate_password_with_num_lower_or_upper_special() {
        String password = PasswordGenerator.generatePassword(8, true, false);
        assertTrue(password.matches("^(?=.*[A-Za-z])(?=.*\\d)(?=.*[\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"])[A-Za-z\\d\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"]{8,}$"));
    }

    @Test
    void should_generate_password_with_num_lower_or_upper() {
        String password = PasswordGenerator.generatePassword(8, false, false);
        assertTrue(password.matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$"));
    }

    @Test
    void should_generate_password_with_num_lower_or_upper_16() {
        String password = PasswordGenerator.generatePassword(16, false, false);
        assertTrue(password.matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{16}$"));
    }


    @Test
    void should_generate_password_with_num_lower_upper_special_12_and_encode() {
        String password = PasswordGenerator.generatePassword(12);
        assertTrue(password.matches("^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"])[a-zA-Z0-9\\\\!@#$%^&*()_+-=\\[\\]{};':|,.<>/?~`\"]{12,}$"));
        final var bCryptPasswordEncoder = new BCryptPasswordEncoder();
        final var encode = bCryptPasswordEncoder.encode(password);
        System.out.println("password: " + password);
        System.out.println("encode: " + encode);
//        password: P42F1_6r$2$711
//        encode: $2a$10$we1KwoVzwkchAMfvRJ2NdurNk3.KzcnDcrEfrD17uT3itfnEaNVdG
    }
}

5.备注

随机数

关于随机数,实际上Java内部实现的随机数大部分是伪随机数,即可以被预测,如果随机数的种子相同,生成的随机数就是一样的。

对于Random类,默认使用时间作为种子,所以比较容易被预测。

SecureRandom类无法指定种子,而是使用RNG算法来实现的,在底层实现中,有的底层实现使用的是真正的随机数(即根据电脑CPU本身的噪音等生成,基本可以认为是真随机数),所以不容易甚至无法预测。

其实这里使用Random类也是可以的,因为默认生成的密码只要长度够长,又使用时间作为种子,基本上也是无法预测的。