Skip to content
Merged
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
14 changes: 9 additions & 5 deletions fe/fe-common/src/main/java/org/apache/doris/common/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -3393,17 +3393,17 @@ public static int metaServiceRpcRetryTimes() {
options = {"without_warmup", "async_warmup", "sync_warmup", "peer_read_async_warmup"})
public static String cloud_warm_up_for_rebalance_type = "async_warmup";

@ConfField(mutable = true, masterOnly = true, description = {"云上tablet均衡时,"
+ "同一个host内预热批次的最大tablet个数,默认10", "The max number of tablets per host "
@ConfField(mutable = true, masterOnly = true, description = {"云上 tablet 均衡时,"
+ "同一个 host 内预热批次的最大 tablet 个数,默认 10", "The max number of tablets per host "
+ "when batching warm-up requests during cloud tablet rebalancing, default 10"})
public static int cloud_warm_up_batch_size = 10;

@ConfField(mutable = true, masterOnly = true, description = {"云上tablet均衡时,"
+ "预热批次最长等待时间,单位毫秒,默认50ms", "Maximum wait time in milliseconds before a "
@ConfField(mutable = true, masterOnly = true, description = {"云上 tablet 均衡时,"
+ "预热批次最长等待时间,单位毫秒,默认 50ms", "Maximum wait time in milliseconds before a "
+ "pending warm-up batch is flushed, default 50ms"})
public static int cloud_warm_up_batch_flush_interval_ms = 50;

@ConfField(mutable = true, masterOnly = true, description = {"云上tablet均衡预热rpc异步线程池大小,默认4",
@ConfField(mutable = true, masterOnly = true, description = {"云上 tablet 均衡预热 rpc 异步线程池大小,默认 4",
"Thread pool size for asynchronous warm-up RPC dispatch during cloud tablet rebalancing, default 4"})
public static int cloud_warm_up_rpc_async_pool_size = 4;

Expand Down Expand Up @@ -3626,6 +3626,10 @@ public static int metaServiceRpcRetryTimes() {
"Authorization plugin directory"})
public static String authorization_plugins_dir = EnvUtils.getDorisHome() + "/plugins/authorization";

@ConfField(description = {"安全相关插件目录",
"Security plugin directory"})
public static String security_plugins_dir = EnvUtils.getDorisHome() + "/plugins/security";

@ConfField(description = {
"鉴权插件配置文件路径,需在 DORIS_HOME 下,默认为 conf/authorization.conf",
"Authorization plugin configuration file path, need to be in DORIS_HOME,"
Expand Down
163 changes: 150 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 @@ -18,18 +18,25 @@
package org.apache.doris.mysql;

import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.Config;
import org.apache.doris.common.ErrorCode;
import org.apache.doris.common.ErrorReport;
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.nio.file.Paths;
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 +95,97 @@ 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 configuredFileName = GlobalVariable.validatePasswordDictionaryFile;

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

// Construct full path: security_plugins_dir/<configured_file_name> and normalize for safe comparison
String configuredFilePath = Paths.get(Config.security_plugins_dir, configuredFileName)
.normalize().toString();

// 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 +383,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 (i < 3) {

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 3 types of "
+ "numbers, uppercase letters, lowercase letters and special characters.");
"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 + ".");
}

// Check for dictionary words (case-insensitive)
String foundWord = containsDictionaryWord(text);
if (foundWord != null) {
throw new AnalysisException(
"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;
}
}
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 Expand Up @@ -184,12 +197,12 @@ public final class GlobalVariable {
public static boolean enable_get_row_count_from_file_list = true;

@VariableMgr.VarAttr(name = READ_ONLY, flag = VariableMgr.GLOBAL,
description = {"仅用于兼容MySQL生态,暂无实际意义",
description = {"仅用于兼容 MySQL 生态,暂无实际意义",
"Only for compatibility with MySQL ecosystem, no practical meaning"})
public static boolean read_only = true;

@VariableMgr.VarAttr(name = SUPER_READ_ONLY, flag = VariableMgr.GLOBAL,
description = {"仅用于兼容MySQL生态,暂无实际意义",
description = {"仅用于兼容 MySQL 生态,暂无实际意义",
"Only for compatibility with MySQL ecosystem, no practical meaning"})
public static boolean super_read_only = true;

Expand All @@ -207,7 +220,7 @@ public final class GlobalVariable {

@VariableMgr.VarAttr(name = ENABLE_FETCH_ICEBERG_STATS, flag = VariableMgr.GLOBAL,
description = {
"当HMS catalog中的Iceberg表没有统计信息时,是否通过Iceberg Api获取统计信息",
"当 HMS catalog 中的 Iceberg 表没有统计信息时,是否通过 Iceberg Api 获取统计信息",
"Enable fetch stats for HMS Iceberg table when it's not analyzed."})
public static boolean enableFetchIcebergStats = false;

Expand All @@ -228,7 +241,7 @@ public final class GlobalVariable {
"控制隐式类型转换的行为,当设置为 true 时,使用新的行为。新行为更为合理。类型优先级从高到低为时间相关类型 > "
+ "数值类型 > 复杂类型 / JSON 类型 / IP 类型 > 字符串类型 > VARIANT 类型。当两个或多个不同类型的表达式"
+ "进行比较时,强制类型转换优先向高优先级类型转换。转换时尽可能保留精度,如:"
+ "当转换为时间相关类型时,当无法确定精度时,优先使用6位精度的 DATETIME 类型。"
+ "当转换为时间相关类型时,当无法确定精度时,优先使用 6 位精度的 DATETIME 类型。"
+ "当转换为数值类型时,当无法确定精度时,优先使用 DECIMAL 类型。",
"Controls the behavior of implicit type conversion. When set to true, the new behavior is used,"
+ " which is more reasonable. The type priority, from highest to lowest, is: time-related"
Expand Down
Loading
Loading