Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 144 additions & 13 deletions fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlPassword.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,18 @@
import org.apache.doris.qe.GlobalVariable;

import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -88,10 +93,93 @@ public class MysqlPassword {
private static final Set<Character> complexCharSet;
public static final int MIN_PASSWORD_LEN = 8;

/**
* Built-in dictionary of common weak password words.
* Used as fallback when no external dictionary file is configured.
* Password containing any of these words (case-insensitive) will be rejected under STRONG policy.
*/
private static final Set<String> BUILTIN_DICTIONARY_WORDS = ImmutableSet.of(
// Common password words
"password", "passwd", "pass", "pwd", "secret",
// User/role related
"admin", "administrator", "root", "user", "guest", "login", "master", "super",
// Test/demo related
"test", "testing", "demo", "sample", "example", "temp", "temporary",
// System/database related
"system", "server", "database", "mysql", "doris", "oracle", "postgres",
// Common weak patterns
"qwerty", "abc", "letmein", "welcome", "hello", "monkey", "dragon", "iloveyou",
"trustno", "sunshine", "princess", "football", "baseball", "soccer"
);

// Lazy-loaded dictionary from external file
private static volatile Set<String> loadedDictionaryWords = null;
// The file path that was used to load the dictionary (for detecting changes)
private static volatile String loadedDictionaryFilePath = null;
// Lock object for thread-safe lazy loading
private static final Object DICTIONARY_LOAD_LOCK = new Object();

static {
complexCharSet = "~!@#$%^&*()_+|<>,.?/:;'[]{}".chars().mapToObj(c -> (char) c).collect(Collectors.toSet());
}

/**
* Get the dictionary words to use for password validation.
* If an external dictionary file is configured, load it lazily.
* Otherwise, use the built-in dictionary.
*
* @return the set of dictionary words (all in lowercase)
*/
private static Set<String> getDictionaryWords() {
String configuredFilePath = GlobalVariable.validatePasswordDictionaryFile;

// If no file is configured, use built-in dictionary
if (Strings.isNullOrEmpty(configuredFilePath)) {
return BUILTIN_DICTIONARY_WORDS;
}

// Check if we need to (re)load the dictionary
// Double-checked locking for thread safety
if (loadedDictionaryWords == null || !configuredFilePath.equals(loadedDictionaryFilePath)) {
synchronized (DICTIONARY_LOAD_LOCK) {
if (loadedDictionaryWords == null || !configuredFilePath.equals(loadedDictionaryFilePath)) {
loadedDictionaryWords = loadDictionaryFromFile(configuredFilePath);
loadedDictionaryFilePath = configuredFilePath;
}
}
}

return loadedDictionaryWords != null ? loadedDictionaryWords : BUILTIN_DICTIONARY_WORDS;
}

/**
* Load dictionary words from an external file.
* Each line in the file is treated as one dictionary word.
* Empty lines and lines starting with '#' are ignored.
*
* @param filePath path to the dictionary file
* @return set of dictionary words (all converted to lowercase), or null if loading fails
*/
private static Set<String> loadDictionaryFromFile(String filePath) {
Set<String> words = new HashSet<>();
try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
// Skip empty lines and comments
if (!line.isEmpty() && !line.startsWith("#")) {
words.add(line.toLowerCase());
}
}
LOG.info("Loaded {} words from password dictionary file: {}", words.size(), filePath);
return words;
} catch (IOException e) {
LOG.warn("Failed to load password dictionary file: {}. Using built-in dictionary. Error: {}",
filePath, e.getMessage());
return null;
}
}

public static byte[] createRandomString(int len) {
byte[] bytes = new byte[len];
random.nextBytes(bytes);
Expand Down Expand Up @@ -289,31 +377,74 @@ public static byte[] checkPassword(String passwdString) throws AnalysisException
return passwd;
}

/**
* Validate plain text password according to MySQL's validate_password policy.
* For STRONG policy, the password must meet all of the following requirements:
* 1. At least MIN_PASSWORD_LEN (8) characters long
* 2. Contains at least 1 digit
* 3. Contains at least 1 lowercase letter
* 4. Contains at least 1 uppercase letter
* 5. Contains at least 1 special character
* 6. Does not contain any dictionary words (case-insensitive)
*/
public static void validatePlainPassword(long validaPolicy, String text) throws AnalysisException {
if (validaPolicy == GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG) {
if (Strings.isNullOrEmpty(text) || text.length() < MIN_PASSWORD_LEN) {
throw new AnalysisException(
"Violate password validation policy: STRONG. The password must be at least 8 characters");
"Violate password validation policy: STRONG. "
+ "The password must be at least " + MIN_PASSWORD_LEN + " characters.");
}

int i = 0;
if (text.chars().anyMatch(Character::isDigit)) {
i++;
StringBuilder missingTypes = new StringBuilder();

if (text.chars().noneMatch(Character::isDigit)) {
missingTypes.append("numeric, ");
}
if (text.chars().anyMatch(Character::isLowerCase)) {
i++;
if (text.chars().noneMatch(Character::isLowerCase)) {
missingTypes.append("lowercase, ");
}
if (text.chars().anyMatch(Character::isUpperCase)) {
i++;
if (text.chars().noneMatch(Character::isUpperCase)) {
missingTypes.append("uppercase, ");
}
if (text.chars().anyMatch(c -> complexCharSet.contains((char) c))) {
i++;
if (text.chars().noneMatch(c -> complexCharSet.contains((char) c))) {
missingTypes.append("special character, ");
}

if (missingTypes.length() > 0) {
// Remove trailing ", "
missingTypes.setLength(missingTypes.length() - 2);
throw new AnalysisException(
"Violate password validation policy: STRONG. "
+ "The password must contain at least one character from each of the following types: "
+ "numeric, lowercase, uppercase, and special characters. "
+ "Missing: " + missingTypes + ".");
}
if (i < 3) {

// Check for dictionary words (case-insensitive)
String foundWord = containsDictionaryWord(text);
if (foundWord != null) {
throw new AnalysisException(
"Violate password validation policy: STRONG. The password must contain at least 3 types of "
+ "numbers, uppercase letters, lowercase letters and special characters.");
"Violate password validation policy: STRONG. "
+ "The password contains a common dictionary word '" + foundWord + "', "
+ "which makes it easy to guess. Please choose a more secure password.");
}
}
}

/**
* Check if the password contains any dictionary word (case-insensitive).
* Uses either the external dictionary file (if configured) or the built-in dictionary.
*
* @param password the password to check
* @return the found dictionary word, or null if none found
*/
private static String containsDictionaryWord(String password) {
String lowerPassword = password.toLowerCase();
for (String word : getDictionaryWords()) {
if (lowerPassword.contains(word)) {
return word;
}
}
return null;
}
}
13 changes: 13 additions & 0 deletions fe/fe-core/src/main/java/org/apache/doris/qe/GlobalVariable.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ public final class GlobalVariable {
public static final long VALIDATE_PASSWORD_POLICY_DISABLED = 0;
public static final long VALIDATE_PASSWORD_POLICY_STRONG = 2;

public static final String VALIDATE_PASSWORD_DICTIONARY_FILE = "validate_password_dictionary_file";

public static final String SQL_CONVERTER_SERVICE_URL = "sql_converter_service_url";
public static final String ENABLE_AUDIT_PLUGIN = "enable_audit_plugin";
public static final String AUDIT_PLUGIN_MAX_BATCH_BYTES = "audit_plugin_max_batch_bytes";
Expand Down Expand Up @@ -139,6 +141,17 @@ public final class GlobalVariable {
@VariableMgr.VarAttr(name = VALIDATE_PASSWORD_POLICY, flag = VariableMgr.GLOBAL)
public static long validatePasswordPolicy = 0;

@VariableMgr.VarAttr(name = VALIDATE_PASSWORD_DICTIONARY_FILE, flag = VariableMgr.GLOBAL,
description = {"密码验证字典文件路径。文件为纯文本格式,每行一个词。"
+ "当 validate_password_policy 为 STRONG(2) 时,密码中不能包含字典中的任何词(不区分大小写)。"
+ "如果为空,则使用内置字典。",
"Path to the password validation dictionary file. "
+ "The file should be plain text with one word per line. "
+ "When validate_password_policy is STRONG(2), "
+ "the password cannot contain any word from the dictionary "
+ "(case-insensitive). If empty, a built-in dictionary will be used."})
public static volatile String validatePasswordDictionaryFile = "";

// If set to true, the db name of TABLE_SCHEMA column in tables in information_schema
// database will be shown as `ctl.db`. Otherwise, show only `db`.
// This is used to compatible with some MySQL tools.
Expand Down
Loading