diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java index 1acbf5d5fab928..424b03905c14c2 100644 --- a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java +++ b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java @@ -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; @@ -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," diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlPassword.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlPassword.java index 51a4b544305a58..14b07f1a5312f9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlPassword.java +++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlPassword.java @@ -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; @@ -88,10 +95,97 @@ public class MysqlPassword { private static final Set 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 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 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 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/ 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 loadDictionaryFromFile(String filePath) { + Set 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); @@ -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; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/GlobalVariable.java b/fe/fe-core/src/main/java/org/apache/doris/qe/GlobalVariable.java index ff2b423b8a18bc..d304c78ded4c4f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/GlobalVariable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/GlobalVariable.java @@ -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"; @@ -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. @@ -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; @@ -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; @@ -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" diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlPasswordTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlPasswordTest.java index 4cc76e146f949a..f14bbf017633d4 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlPasswordTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlPasswordTest.java @@ -18,13 +18,43 @@ package org.apache.doris.mysql; import org.apache.doris.common.AnalysisException; +import org.apache.doris.common.Config; +import org.apache.doris.qe.GlobalVariable; +import org.junit.After; import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; import java.io.UnsupportedEncodingException; public class MysqlPasswordTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private String originalDictionaryFile; + private String originalSecurityPluginsDir; + + @Before + public void setUp() { + // Save original values + originalDictionaryFile = GlobalVariable.validatePasswordDictionaryFile; + originalSecurityPluginsDir = Config.security_plugins_dir; + } + + @After + public void tearDown() { + // Restore original values + GlobalVariable.validatePasswordDictionaryFile = originalDictionaryFile; + Config.security_plugins_dir = originalSecurityPluginsDir; + } + @Test public void testMakePassword() { Assert.assertEquals("*6C8989366EAF75BB670AD8EA7A7FC1176A95CEF4", @@ -79,4 +109,311 @@ public void testCheckPasswdFail2() throws AnalysisException { Assert.fail("No exception throws"); } + // ==================== validatePlainPassword Tests ==================== + + @Test + public void testValidatePasswordDisabledPolicy() throws AnalysisException { + // When policy is DISABLED, any password should pass + GlobalVariable.validatePasswordDictionaryFile = ""; + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_DISABLED, "weak"); + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_DISABLED, ""); + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_DISABLED, null); + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_DISABLED, "test123"); + } + + @Test + public void testValidatePasswordStrongPolicyValid() throws AnalysisException { + // Valid password: 8+ chars, has digit, lowercase, uppercase, special char, no dictionary word + GlobalVariable.validatePasswordDictionaryFile = ""; + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Xk9$mN2@pL"); + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "MyP@ss1!"); + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Str0ng!Powd"); + } + + @Test + public void testValidatePasswordTooShort() { + GlobalVariable.validatePasswordDictionaryFile = ""; + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Aa1!abc"); + Assert.fail("Expected AnalysisException for password too short"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("at least 8 characters")); + } + } + + @Test + public void testValidatePasswordNullOrEmpty() { + GlobalVariable.validatePasswordDictionaryFile = ""; + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, null); + Assert.fail("Expected AnalysisException for null password"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("at least 8 characters")); + } + + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, ""); + Assert.fail("Expected AnalysisException for empty password"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("at least 8 characters")); + } + } + + @Test + public void testValidatePasswordMissingDigit() { + GlobalVariable.validatePasswordDictionaryFile = ""; + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Abcdefgh!"); + Assert.fail("Expected AnalysisException for missing digit"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("Missing: numeric")); + } + } + + @Test + public void testValidatePasswordMissingLowercase() { + GlobalVariable.validatePasswordDictionaryFile = ""; + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "ABCDEFG1!"); + Assert.fail("Expected AnalysisException for missing lowercase"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("Missing: lowercase")); + } + } + + @Test + public void testValidatePasswordMissingUppercase() { + GlobalVariable.validatePasswordDictionaryFile = ""; + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "abcdefg1!"); + Assert.fail("Expected AnalysisException for missing uppercase"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("Missing: uppercase")); + } + } + + @Test + public void testValidatePasswordMissingSpecialChar() { + GlobalVariable.validatePasswordDictionaryFile = ""; + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Abcdefg12"); + Assert.fail("Expected AnalysisException for missing special character"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("Missing: special character")); + } + } + + @Test + public void testValidatePasswordMissingMultipleTypes() { + GlobalVariable.validatePasswordDictionaryFile = ""; + try { + // Missing digit, uppercase, special char + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "abcdefghij"); + Assert.fail("Expected AnalysisException for missing multiple types"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("numeric")); + Assert.assertTrue(e.getMessage().contains("uppercase")); + Assert.assertTrue(e.getMessage().contains("special character")); + } + } + + @Test + public void testValidatePasswordBuiltinDictionaryWord() { + GlobalVariable.validatePasswordDictionaryFile = ""; + // Test various built-in dictionary words + String[] dictionaryPasswords = { + "Test@123X", // contains "test" + "Admin@123X", // contains "admin" + "Password1!", // contains "password" + "Root@1234X", // contains "root" + "User@1234X", // contains "user" + "Doris@123X", // contains "doris" + "Qwerty@12X", // contains "qwerty" + "Welcome1!X", // contains "welcome" + "Hello@123X", // contains "hello" + }; + + for (String password : dictionaryPasswords) { + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, password); + Assert.fail("Expected AnalysisException for dictionary word in: " + password); + } catch (AnalysisException e) { + Assert.assertTrue("Expected dictionary word error for: " + password, + e.getMessage().contains("dictionary word")); + } + } + } + + @Test + public void testValidatePasswordDictionaryWordCaseInsensitive() { + GlobalVariable.validatePasswordDictionaryFile = ""; + // Dictionary check should be case-insensitive + String[] caseVariants = { + "TEST@123Xy", + "TeSt@123Xy", + "tEsT@123Xy", + "ADMIN@12Xy", + "AdMiN@12Xy", + }; + + for (String password : caseVariants) { + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, password); + Assert.fail("Expected AnalysisException for case-insensitive dictionary word in: " + password); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("dictionary word")); + } + } + } + + @Test + public void testValidatePasswordWithExternalDictionary() throws IOException, AnalysisException { + // Set security_plugins_dir to temp folder + Config.security_plugins_dir = tempFolder.getRoot().getAbsolutePath(); + + // Create a temporary dictionary file in the security_plugins_dir + File dictFile = tempFolder.newFile("test_dictionary.txt"); + try (FileWriter writer = new FileWriter(dictFile)) { + writer.write("# This is a comment\n"); + writer.write("customword\n"); + writer.write(" secretkey \n"); // with spaces + writer.write("\n"); // empty line + writer.write("forbidden\n"); + } + + // Use just the filename (not full path) + GlobalVariable.validatePasswordDictionaryFile = "test_dictionary.txt"; + + // Password containing custom dictionary word should fail + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Customword1!"); + Assert.fail("Expected AnalysisException for custom dictionary word"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("customword")); + } + + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Secretkey1!"); + Assert.fail("Expected AnalysisException for custom dictionary word"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("secretkey")); + } + + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Forbidden1!"); + Assert.fail("Expected AnalysisException for custom dictionary word"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("forbidden")); + } + + // Password not containing custom dictionary words should pass + // Note: built-in words like "test" should NOT fail because we're using external dictionary + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Xk9$mN2@pL"); + } + + @Test + public void testValidatePasswordDictionaryFileNotFound() throws AnalysisException { + // Set security_plugins_dir to a valid path + Config.security_plugins_dir = tempFolder.getRoot().getAbsolutePath(); + + // When dictionary file doesn't exist, should fall back to built-in dictionary + GlobalVariable.validatePasswordDictionaryFile = "non_existent_dictionary.txt"; + + // Built-in dictionary word should still fail + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Test@123Xy"); + Assert.fail("Expected AnalysisException for built-in dictionary word"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("dictionary word")); + } + + // Valid password should pass + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Xk9$mN2@pL"); + } + + @Test + public void testValidatePasswordDictionaryFileReload() throws IOException, AnalysisException { + // Set security_plugins_dir to temp folder + Config.security_plugins_dir = tempFolder.getRoot().getAbsolutePath(); + + // Create first dictionary file + File dictFile1 = tempFolder.newFile("dict1.txt"); + try (FileWriter writer = new FileWriter(dictFile1)) { + writer.write("wordone\n"); + } + + // Use just the filename + GlobalVariable.validatePasswordDictionaryFile = "dict1.txt"; + + // Should fail for wordone + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Wordone12!"); + Assert.fail("Expected AnalysisException for wordone"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("wordone")); + } + + // Create second dictionary file with different content + File dictFile2 = tempFolder.newFile("dict2.txt"); + try (FileWriter writer = new FileWriter(dictFile2)) { + writer.write("wordtwo\n"); + } + + // Change to second dictionary file (just filename) + GlobalVariable.validatePasswordDictionaryFile = "dict2.txt"; + + // Should now pass for wordone (not in new dictionary) + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Wordone12!"); + + // Should fail for wordtwo + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Wordtwo12!"); + Assert.fail("Expected AnalysisException for wordtwo"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("wordtwo")); + } + } + + @Test + public void testValidatePasswordEmptyDictionaryFile() throws IOException, AnalysisException { + // Set security_plugins_dir to temp folder + Config.security_plugins_dir = tempFolder.getRoot().getAbsolutePath(); + + // Use just the filename + GlobalVariable.validatePasswordDictionaryFile = "empty_dict.txt"; + + // With empty dictionary, only character requirements should be checked + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Test@123X"); + Assert.fail("Expected AnalysisException for test"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("test")); + } + try { + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Admin@12X"); + Assert.fail("Expected AnalysisException for admin"); + } catch (AnalysisException e) { + Assert.assertTrue(e.getMessage().contains("admin")); + } + } + + @Test + public void testValidatePasswordDictionaryWithCommentsOnly() throws IOException, AnalysisException { + // Set security_plugins_dir to temp folder + Config.security_plugins_dir = tempFolder.getRoot().getAbsolutePath(); + + // Create a dictionary file with only comments + File dictFile = tempFolder.newFile("comments_dict.txt"); + try (FileWriter writer = new FileWriter(dictFile)) { + writer.write("# comment 1\n"); + writer.write("# comment 2\n"); + writer.write(" # comment with leading spaces\n"); + } + + // Use just the filename + GlobalVariable.validatePasswordDictionaryFile = "comments_dict.txt"; + + // Should pass since dictionary effectively has no words + MysqlPassword.validatePlainPassword(GlobalVariable.VALIDATE_PASSWORD_POLICY_STRONG, "Test@123X"); + } } diff --git a/regression-test/suites/account_p0/test_alter_user.groovy b/regression-test/suites/account_p0/test_alter_user.groovy index fc4b8a12bf2890..c0d0a26fb60f7f 100644 --- a/regression-test/suites/account_p0/test_alter_user.groovy +++ b/regression-test/suites/account_p0/test_alter_user.groovy @@ -126,11 +126,11 @@ suite("test_alter_user", "account,nonConcurrent") { sql """set global validate_password_policy=STRONG""" test { sql """set password for 'test_auth_user3' = password("12345")""" - exception "Violate password validation policy: STRONG. The password must be at least 8 characters"; + exception "Violate password validation policy: STRONG" } test { sql """set password for 'test_auth_user3' = password("12345678")""" - exception "Violate password validation policy: STRONG. The password must contain at least 3 types of numbers, uppercase letters, lowercase letters and special characters."; + exception "Violate password validation policy: STRONG" } sql """set password for 'test_auth_user3' = password('Ab1234567^')"""