This is an automated email from the ASF dual-hosted git repository.
jiangtian pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb.git
The following commit(s) were added to refs/heads/master by this push:
new 781a3c98dce Feature/login lock manager (#16494)
781a3c98dce is described below
commit 781a3c98dce0925b529868990ee7bb7119bcceeb
Author: Hongzhi Gao <[email protected]>
AuthorDate: Tue Sep 30 17:37:48 2025 +0800
Feature/login lock manager (#16494)
* implement loginlockmanager
* Implemented SQL 'ALTER USER username[@ip] ACCOUNT UNLOCK'
* merge master
* fix "Too many files with unapproved license"
* added LoginLockManagerIT
* fix unlock sql definition
* fix LoginLockManager.EXEMPT_USERS;
* fix it/ut
* set failedLoginAttempts=1
* ignore login lock manager it
* ignore login lock manager it
* fix login lock manager it
---
.../iotdb/auth/it/IoTDBLoginLockManagerIT.java | 179 +++++++
.../relational/it/schema/IoTDBDatabaseIT.java | 4 +-
.../org/apache/iotdb/db/qp/sql/IdentifierParser.g4 | 4 +-
.../org/apache/iotdb/db/qp/sql/IoTDBSqlParser.g4 | 10 +-
.../antlr4/org/apache/iotdb/db/qp/sql/SqlLexer.g4 | 9 +
.../iotdb/db/auth/ClusterAuthorityFetcher.java | 47 +-
.../org/apache/iotdb/db/auth/LoginLockManager.java | 387 +++++++++++++++
.../java/org/apache/iotdb/db/conf/IoTDBConfig.java | 36 ++
.../iotdb/db/protocol/session/SessionManager.java | 31 +-
.../db/queryengine/plan/parser/ASTVisitor.java | 16 +
.../relational/security/AccessControlImpl.java | 1 +
.../security/TreeAccessCheckVisitor.java | 1 +
.../sql/ast/RelationalAuthorStatement.java | 13 +
.../plan/relational/sql/parser/AstBuilder.java | 19 +
.../plan/relational/type/AuthorRType.java | 1 +
.../db/queryengine/plan/statement/AuthorType.java | 5 +
.../queryengine/plan/statement/StatementType.java | 1 +
.../plan/statement/sys/AuthorStatement.java | 13 +
.../apache/iotdb/db/auth/LoginLockManagerTest.java | 538 +++++++++++++++++++++
.../plan/parser/StatementGeneratorTest.java | 5 +
.../db/relational/grammar/sql/RelationalSql.g4 | 29 ++
21 files changed, 1342 insertions(+), 7 deletions(-)
diff --git
a/integration-test/src/test/java/org/apache/iotdb/auth/it/IoTDBLoginLockManagerIT.java
b/integration-test/src/test/java/org/apache/iotdb/auth/it/IoTDBLoginLockManagerIT.java
new file mode 100644
index 00000000000..1fc09e026d5
--- /dev/null
+++
b/integration-test/src/test/java/org/apache/iotdb/auth/it/IoTDBLoginLockManagerIT.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iotdb.auth.it;
+
+import org.apache.iotdb.cli.it.AbstractScriptIT;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
+import org.apache.iotdb.isession.ISession;
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.ClusterIT;
+import org.apache.iotdb.itbase.category.LocalStandaloneIT;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.io.IOException;
+
+@RunWith(IoTDBTestRunner.class)
+@Category({LocalStandaloneIT.class, ClusterIT.class})
+public class IoTDBLoginLockManagerIT extends AbstractScriptIT {
+
+ private static String ip;
+
+ private static String port;
+
+ private static String sbinPath;
+
+ private static String libPath;
+
+ private static String homePath;
+
+ @Before
+ public void setUp() throws Exception {
+ IoTDBDescriptor.getInstance().getConfig().setPasswordLockTimeMinutes(1);
+ EnvFactory.getEnv().initClusterEnvironment();
+ ip = EnvFactory.getEnv().getIP();
+ port = EnvFactory.getEnv().getPort();
+ sbinPath = EnvFactory.getEnv().getSbinPath();
+ libPath = EnvFactory.getEnv().getLibPath();
+ homePath =
+ libPath.substring(0, libPath.lastIndexOf(File.separator + "lib" +
File.separator + "*"));
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ EnvFactory.getEnv().cleanClusterEnvironment();
+ }
+
+ @Ignore
+ @Test
+ public void testExemptUser() throws Exception {
+ // root login success
+ String loginSuccessMsg =
+ "Msg: org.apache.iotdb.jdbc.IoTDBSQLException: 700: Error occurred
while parsing SQL to physical plan: line 1:8 no viable alternative at input
'SELECT 1'";
+ login("root", "root", new String[] {loginSuccessMsg}, 1);
+
+ /* root will not be locked */
+ String authFailedMsg = "ErrorCan't execute sql because801: Authentication
failed.";
+ for (int i = 0; i < 5; i++) {
+ login("root", "wrong", new String[] {authFailedMsg}, 1);
+ }
+ // account was not locked
+ login("root", "root", new String[] {loginSuccessMsg}, 1);
+ }
+
+ @Ignore
+ @Test
+ public void testUnlockManual() throws Exception {
+ ISession session = EnvFactory.getEnv().getSessionConnection();
+ // create test account 'test'
+ session.executeNonQueryStatement("CREATE USER test 'test'");
+ // TEST login success
+ String loginSuccessMsg =
+ "Msg: org.apache.iotdb.jdbc.IoTDBSQLException: 700: Error occurred
while parsing SQL to physical plan: line 1:8 no viable alternative at input
'SELECT 1'";
+ login("test", "test", new String[] {loginSuccessMsg}, 1);
+
+ /* test unlock user lock */
+ String authFailedMsg = "ErrorCan't execute sql because801: Authentication
failed.";
+ for (int i = 0; i < 5; i++) {
+ login("test", "wrong", new String[] {authFailedMsg}, 1);
+ }
+ // account was locked
+ login("test", "test", new String[] {authFailedMsg}, 1);
+ // unlock user-lock manual
+ session.executeNonQueryStatement("ALTER USER test ACCOUNT UNLOCK");
+ login("test", "test", new String[] {loginSuccessMsg}, 1);
+
+ /* test unlock user-ip lock */
+ for (int i = 0; i < 5; i++) {
+ login("test", "wrong", new String[] {authFailedMsg}, 1);
+ }
+ // account was locked
+ login("test", "test", new String[] {authFailedMsg}, 1);
+ // unlock user-lock manual
+ session.executeNonQueryStatement("ALTER USER test @ '127.0.0.1' ACCOUNT
UNLOCK");
+ login("test", "test", new String[] {loginSuccessMsg}, 1);
+ }
+
+ protected void login(String username, String password, String[]
expectOutput, int statusCode)
+ throws IOException {
+ String os = System.getProperty("os.name").toLowerCase();
+ if (os.startsWith("windows")) {
+ loginOnWindows(username, password, expectOutput, statusCode);
+ } else {
+ loginOnUnix(username, password, expectOutput, statusCode);
+ }
+ }
+
+ protected void loginOnWindows(
+ String username, String password, String[] expectOutput, int statusCode)
throws IOException {
+ ProcessBuilder builder =
+ new ProcessBuilder(
+ "cmd.exe",
+ "/c",
+ sbinPath + File.separator + "windows" + File.separator +
"start-cli.bat",
+ "-h",
+ ip,
+ "-p",
+ port,
+ "-u",
+ username,
+ "-pw",
+ password,
+ "-e",
+ "\"SELECT 1\"",
+ "&",
+ "exit",
+ "%^errorlevel%");
+ builder.environment().put("IOTDB_HOME", homePath);
+ testOutput(builder, expectOutput, statusCode);
+ }
+
+ protected void loginOnUnix(
+ String username, String password, String[] expectOutput, int statusCode)
throws IOException {
+ ProcessBuilder builder =
+ new ProcessBuilder(
+ "bash",
+ sbinPath + File.separator + "start-cli.sh",
+ "-h",
+ ip,
+ "-p",
+ port,
+ "-u",
+ username,
+ "-pw",
+ password,
+ "-e",
+ "\"SELECT 1\"");
+ builder.environment().put("IOTDB_HOME", homePath);
+ testOutput(builder, expectOutput, statusCode);
+ }
+
+ @Override
+ protected void testOnWindows() {}
+
+ @Override
+ protected void testOnUnix() {}
+}
diff --git
a/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/IoTDBDatabaseIT.java
b/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/IoTDBDatabaseIT.java
index 0d09fc3a8a0..463a9f7e8b6 100644
---
a/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/IoTDBDatabaseIT.java
+++
b/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/IoTDBDatabaseIT.java
@@ -604,7 +604,7 @@ public class IoTDBDatabaseIT {
statement.executeQuery(
"select * from information_schema.keywords where reserved > 0
limit 1"),
"word,reserved,",
- Collections.singleton("AINODE,1,"));
+ Collections.singleton("ACCOUNT,1,"));
}
try (final Connection connection =
@@ -722,7 +722,7 @@ public class IoTDBDatabaseIT {
statement.executeQuery(
"select * from information_schema.keywords where reserved > 0
limit 1"),
"word,reserved,",
- Collections.singleton("AINODE,1,"));
+ Collections.singleton("ACCOUNT,1,"));
TestUtils.assertResultSetEqual(
statement.executeQuery("select distinct(status) from
information_schema.nodes"),
diff --git
a/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IdentifierParser.g4
b/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IdentifierParser.g4
index 418c3b047e2..0cc5e6faf8b 100644
---
a/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IdentifierParser.g4
+++
b/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IdentifierParser.g4
@@ -32,7 +32,8 @@ identifier
// List of keywords, new keywords that can be used as identifiers should be
added into this list. For example, 'not' is an identifier but can not be used
as an identifier in node name.
keyWords
- : ADD
+ : ACCOUNT
+ | ADD
| AFTER
| ALIAS
| ALIGN
@@ -261,6 +262,7 @@ keyWords
| TTL
| UNLINK
| UNLOAD
+ | UNLOCK
| UNSET
| UPDATE
| UPSERT
diff --git
a/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IoTDBSqlParser.g4
b/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IoTDBSqlParser.g4
index 8d75981f41a..14a82e77872 100644
---
a/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IoTDBSqlParser.g4
+++
b/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IoTDBSqlParser.g4
@@ -81,7 +81,7 @@ dmlStatement
;
dclStatement
- : createUser | createRole | alterUser | renameUser | grantUser | grantRole
| grantRoleToUser
+ : createUser | createRole | alterUser | renameUser | grantUser | grantRole
| grantRoleToUser | alterUserAccountUnlock
| revokeUser | revokeRole | revokeRoleFromUser | dropUser | dropRole
| listUser | listRole | listPrivilegesUser | listPrivilegesRole
;
@@ -1064,6 +1064,11 @@ renameUser
: ALTER USER username=usernameWithRoot RENAME TO newUsername=identifier
;
+// ---- Alter User Account Unlock
+alterUserAccountUnlock
+ : ALTER USER userName=usernameWithRootWithOptionalHost ACCOUNT UNLOCK
+ ;
+
// Grant User Privileges
grantUser
: GRANT privileges ON prefixPath (COMMA prefixPath)* TO USER
userName=identifier (grantOpt)?
@@ -1147,6 +1152,9 @@ usernameWithRoot
| identifier
;
+usernameWithRootWithOptionalHost
+ : usernameWithRoot (AT host=STRING_LITERAL)?
+ ;
/**
* 5. Utility Statements
diff --git
a/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/SqlLexer.g4
b/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/SqlLexer.g4
index 4a5708817a7..0efaf9fac38 100644
--- a/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/SqlLexer.g4
+++ b/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/SqlLexer.g4
@@ -37,6 +37,10 @@ WS
// Common Keywords
+ACCOUNT
+ : A C C O U N T
+ ;
+
ADD
: A D D
;
@@ -962,6 +966,10 @@ UNLOAD
: U N L O A D
;
+UNLOCK
+ : U N L O C K
+ ;
+
UNSET
: U N S E T
;
@@ -1210,6 +1218,7 @@ OPERATOR_NOT : '!';
* 4. Constructors Symbols
*/
+AT : '@';
DOT : '.';
COMMA : ',';
SEMI: ';';
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/auth/ClusterAuthorityFetcher.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/auth/ClusterAuthorityFetcher.java
index f22a91a4c0a..b7b3815caca 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/auth/ClusterAuthorityFetcher.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/auth/ClusterAuthorityFetcher.java
@@ -52,6 +52,8 @@ import
org.apache.iotdb.db.protocol.client.ConfigNodeClientManager;
import org.apache.iotdb.db.protocol.client.ConfigNodeInfo;
import org.apache.iotdb.db.queryengine.plan.execution.config.ConfigTaskResult;
import
org.apache.iotdb.db.queryengine.plan.relational.sql.ast.RelationalAuthorStatement;
+import org.apache.iotdb.db.queryengine.plan.relational.type.AuthorRType;
+import org.apache.iotdb.db.queryengine.plan.statement.StatementType;
import org.apache.iotdb.db.queryengine.plan.statement.sys.AuthorStatement;
import org.apache.iotdb.rpc.RpcUtils;
import org.apache.iotdb.rpc.TSStatusCode;
@@ -420,13 +422,54 @@ public class ClusterAuthorityFetcher implements
IAuthorityFetcher {
@Override
public SettableFuture<ConfigTaskResult> operatePermission(AuthorStatement
authorStatement) {
- return operatePermissionInternal(authorStatement, false);
+ return handleAccountUnlock(
+ authorStatement,
+ authorStatement.getUserName(),
+ false,
+ () -> onOperatePermissionSuccess(authorStatement));
}
@Override
public SettableFuture<ConfigTaskResult> operatePermission(
RelationalAuthorStatement authorStatement) {
- return operatePermissionInternal(authorStatement, true);
+ return handleAccountUnlock(
+ authorStatement,
+ authorStatement.getUserName(),
+ true,
+ () -> onOperatePermissionSuccess(authorStatement));
+ }
+
+ private SettableFuture<ConfigTaskResult> handleAccountUnlock(
+ Object authorStatement, String username, boolean isRelational, Runnable
successCallback) {
+
+ if (isUnlockStatement(authorStatement, isRelational)) {
+ SettableFuture<ConfigTaskResult> future = SettableFuture.create();
+ User user = getUser(username);
+ if (user == null) {
+ future.setException(
+ new IoTDBException(
+ String.format("User %s does not exist", username),
+ TSStatusCode.USER_NOT_EXIST.getStatusCode()));
+ return future;
+ }
+ String loginAddr =
+ isRelational
+ ? ((RelationalAuthorStatement) authorStatement).getLoginAddr()
+ : ((AuthorStatement) authorStatement).getLoginAddr();
+
+ LoginLockManager.getInstance().unlock(user.getUserId(), loginAddr);
+ successCallback.run();
+ future.set(new ConfigTaskResult(TSStatusCode.SUCCESS_STATUS));
+ return future;
+ }
+ return operatePermissionInternal(authorStatement, isRelational);
+ }
+
+ private boolean isUnlockStatement(Object statement, boolean isRelational) {
+ if (isRelational) {
+ return ((RelationalAuthorStatement) statement).getAuthorType() ==
AuthorRType.ACCOUNT_UNLOCK;
+ }
+ return ((AuthorStatement) statement).getType() ==
StatementType.ACCOUNT_UNLOCK;
}
private SettableFuture<ConfigTaskResult> queryPermissionInternal(
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/auth/LoginLockManager.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/auth/LoginLockManager.java
new file mode 100644
index 00000000000..51ecb9ad825
--- /dev/null
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/auth/LoginLockManager.java
@@ -0,0 +1,387 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.auth;
+
+import org.apache.iotdb.commons.auth.entity.User;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.ConcurrentMap;
+
+public class LoginLockManager {
+ private static final Logger LOGGER =
LoggerFactory.getLogger(LoginLockManager.class);
+
+ // Configuration parameters
+ private final int failedLoginAttempts;
+ private final int failedLoginAttemptsPerUser;
+ private final int passwordLockTimeMinutes;
+
+ // Lock records storage (in-memory only)
+ private final ConcurrentMap<Long, UserLockInfo> userLocks = new
ConcurrentHashMap<>();
+ private final ConcurrentMap<String, UserLockInfo> userIpLocks = new
ConcurrentHashMap<>();
+
+ // Exempt users who should never be locked (only valid if request is from
local host)
+ private static final Set<Long> EXEMPT_USERS;
+
+ static {
+ Set<Long> tempSet = new HashSet<>();
+ tempSet.add((long) AuthorityChecker.SUPER_USER_ID); // root userid
+ tempSet.add(User.INTERNAL_SECURITY_ADMIN);
+ EXEMPT_USERS = Collections.unmodifiableSet(tempSet);
+ }
+
+ public LoginLockManager() {
+ this(
+ IoTDBDescriptor.getInstance().getConfig().getFailedLoginAttempts(),
+
IoTDBDescriptor.getInstance().getConfig().getFailedLoginAttemptsPerUser(),
+
IoTDBDescriptor.getInstance().getConfig().getPasswordLockTimeMinutes());
+ }
+
+ public LoginLockManager(
+ int failedLoginAttempts, int failedLoginAttemptsPerUser, int
passwordLockTimeMinutes) {
+ // Set and validate failedLoginAttempts (IP level)
+ if (failedLoginAttempts == -1) {
+ this.failedLoginAttempts = -1; // Completely disable IP-level
restrictions
+ } else {
+ this.failedLoginAttempts = failedLoginAttempts >= 1 ?
failedLoginAttempts : 5;
+ }
+
+ // Set and validate failedLoginAttemptsPerUser (user level)
+ if (failedLoginAttemptsPerUser == -1) {
+ // If IP-level is enabled, user-level cannot be disabled
+ if (this.failedLoginAttempts != -1) {
+ this.failedLoginAttemptsPerUser = 1000; // Default user-level value
+ LOGGER.error(
+ "User-level login attempts cannot be disabled when IP-level is
enabled. "
+ + "Setting user-level attempts to default (1000)");
+ } else {
+ this.failedLoginAttemptsPerUser = -1; // Both are disabled
+ }
+ } else {
+ this.failedLoginAttemptsPerUser =
+ failedLoginAttemptsPerUser >= 1 ? failedLoginAttemptsPerUser : 1000;
+ }
+
+ // Set and validate passwordLockTimeMinutes (default 10, minimum 1)
+ this.passwordLockTimeMinutes = passwordLockTimeMinutes >= 1 ?
passwordLockTimeMinutes : 10;
+
+ // Log final effective configuration
+ LOGGER.info(
+ "Login lock manager initialized with: IP-level attempts={}, User-level
attempts={}, Lock time={} minutes",
+ this.failedLoginAttempts == -1 ? "disabled" : this.failedLoginAttempts,
+ this.failedLoginAttemptsPerUser == -1 ? "disabled" :
this.failedLoginAttemptsPerUser,
+ this.passwordLockTimeMinutes);
+ }
+
+ /** Inner class to store user lock information */
+ static class UserLockInfo {
+ // Deque to store timestamps of failed attempts (milliseconds)
+ private final Deque<Long> failureTimestamps = new
ConcurrentLinkedDeque<>();
+
+ void addFailureTime(long timestamp) {
+ failureTimestamps.addLast(timestamp);
+ }
+
+ void removeOldFailures(long cutoffTime) {
+ // Remove timestamps older than cutoffTime
+ while (!failureTimestamps.isEmpty() && failureTimestamps.peekFirst() <
cutoffTime) {
+ failureTimestamps.pollFirst();
+ }
+ }
+
+ int getFailureCount() {
+ return failureTimestamps.size();
+ }
+ }
+
+ /**
+ * Check if user or user@ip is locked
+ *
+ * @param userId user ID
+ * @param ip IP address
+ * @return true if locked, false otherwise
+ */
+ public boolean checkLock(long userId, String ip) {
+ cleanExpiredLocks(); // Clean expired records (no failures in window)
+
+ // Exempt users are never locked if request is from localhost
+ if (EXEMPT_USERS.contains(userId) && isFromLocalhost(ip)) {
+ return false;
+ }
+
+ // Check user@ip lock (failures in window)
+ if (failedLoginAttempts != -1) {
+ String userIpKey = buildUserIpKey(userId, ip);
+ UserLockInfo userIpLock = userIpLocks.get(userIpKey);
+ if (userIpLock != null) {
+ long now = System.currentTimeMillis();
+ long cutoffTime = now - (passwordLockTimeMinutes * 60 * 1000L);
+ userIpLock.removeOldFailures(cutoffTime);
+ if (userIpLock.getFailureCount() >= failedLoginAttempts) {
+ return true;
+ }
+ }
+ }
+
+ // Check global user lock (failures in window)
+ if (failedLoginAttemptsPerUser != -1) {
+ UserLockInfo userLock = userLocks.get(userId);
+ if (userLock != null) {
+ long now = System.currentTimeMillis();
+ long cutoffTime = now - (passwordLockTimeMinutes * 60 * 1000L);
+ userLock.removeOldFailures(cutoffTime);
+ return userLock.getFailureCount() >= failedLoginAttemptsPerUser;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Record a failed login attempt
+ *
+ * @param userId user ID
+ * @param ip IP address
+ */
+ public void recordFailure(long userId, String ip) {
+ // Exempt users from localhost don't get locked
+ if (EXEMPT_USERS.contains(userId) && isFromLocalhost(ip)) {
+ return;
+ }
+
+ long now = System.currentTimeMillis();
+ long cutoffTime = now - (passwordLockTimeMinutes * 60 * 1000L);
+
+ // Handle user@ip failures in sliding window
+ if (failedLoginAttempts != -1) {
+ String userIpKey = buildUserIpKey(userId, ip);
+ userIpLocks.compute(
+ userIpKey,
+ (key, existing) -> {
+ if (existing == null) {
+ existing = new UserLockInfo();
+ }
+ // Remove failures outside of sliding window
+ existing.removeOldFailures(cutoffTime);
+ // Record this failure
+ existing.addFailureTime(now);
+ // Check if threshold reached (log only when it just reaches)
+ int failCountIp = existing.getFailureCount();
+ if (failCountIp >= failedLoginAttempts && failCountIp ==
failedLoginAttempts) {
+ LOGGER.info("IP '{}' locked for user ID '{}'", ip, userId);
+ }
+ return existing;
+ });
+ }
+
+ // Handle global user failures in sliding window
+ if (failedLoginAttemptsPerUser != -1) {
+ userLocks.compute(
+ userId,
+ (key, existing) -> {
+ if (existing == null) {
+ existing = new UserLockInfo();
+ }
+ // Remove failures outside of sliding window
+ existing.removeOldFailures(cutoffTime);
+ // Record this failure
+ existing.addFailureTime(now);
+ // Check if threshold reached (log only when it just reaches)
+ int failCountUser = existing.getFailureCount();
+ if (failCountUser >= failedLoginAttemptsPerUser
+ && failCountUser == failedLoginAttemptsPerUser) {
+ LOGGER.info(
+ "User ID '{}' locked due to {} failed attempts",
+ userId,
+ failedLoginAttemptsPerUser);
+ }
+ return existing;
+ });
+ }
+
+ // Check for potential attacks
+ if (failedLoginAttempts != -1 || failedLoginAttemptsPerUser != -1) {
+ checkForPotentialAttacks(userId, ip);
+ }
+ }
+
+ /**
+ * Clear failure records after successful login
+ *
+ * @param userId user ID
+ * @param ip IP address
+ */
+ public void clearFailure(long userId, String ip) {
+ String userIpKey = buildUserIpKey(userId, ip);
+ userIpLocks.remove(userIpKey);
+ userLocks.remove(userId);
+ }
+
+ /**
+ * Unlock user or user@ip
+ *
+ * @param userId user ID (required)
+ * @param ip IP address (optional)
+ */
+ public void unlock(long userId, String ip) {
+ if (ip == null || ip.isEmpty()) {
+ // Unlock global user lock
+ userLocks.remove(userId);
+ // Also remove all IP locks for this user
+ userIpLocks.keySet().removeIf(key -> key.startsWith(userId + "@"));
+ LOGGER.info("User ID '{}' unlocked (manual)", userId);
+ } else {
+ // Unlock specific user@ip lock
+ String userIpKey = buildUserIpKey(userId, ip);
+ userIpLocks.remove(userIpKey);
+ LOGGER.info("IP '{}' for user ID '{}' unlocked (manual)", ip, userId);
+ }
+ }
+
+ /** Clean up expired locks (no failures in the sliding window) */
+ public void cleanExpiredLocks() {
+ long now = System.currentTimeMillis();
+ long cutoffTime = now - (passwordLockTimeMinutes * 60 * 1000L);
+
+ // Clean expired user locks
+ userLocks
+ .entrySet()
+ .removeIf(
+ entry -> {
+ UserLockInfo info = entry.getValue();
+ // Remove outdated failures
+ info.removeOldFailures(cutoffTime);
+ if (info.getFailureCount() == 0) {
+ LOGGER.info("User ID '{}' unlocked (expired)", entry.getKey());
+ return true;
+ }
+ return false;
+ });
+
+ // Clean expired user@ip locks
+ userIpLocks
+ .entrySet()
+ .removeIf(
+ entry -> {
+ UserLockInfo info = entry.getValue();
+ // Remove outdated failures
+ info.removeOldFailures(cutoffTime);
+ if (info.getFailureCount() == 0) {
+ String[] parts = entry.getKey().split("@");
+ LOGGER.info("IP '{}' for user ID '{}' unlocked (expired)",
parts[1], parts[0]);
+ return true;
+ }
+ return false;
+ });
+ }
+
+ // Helper methods
+ private String buildUserIpKey(long userId, String ip) {
+ return userId + "@" + ip;
+ }
+
+ private void checkForPotentialAttacks(long userId, String ip) {
+ // Check if IP is locked by many users
+ Set<Long> usersForIp = new HashSet<>();
+ for (String key : userIpLocks.keySet()) {
+ if (key.endsWith("@" + ip)) {
+ usersForIp.add(Long.parseLong(key.split("@")[0]));
+ }
+ }
+
+ if (usersForIp.size() > 50) {
+ LOGGER.warn("IP '{}' locked by {} different users → potential attack",
ip, usersForIp.size());
+ }
+
+ // Check if user has many IP locks
+ Set<String> ipsForUser = new HashSet<>();
+ for (String key : userIpLocks.keySet()) {
+ if (key.startsWith(userId + "@")) {
+ ipsForUser.add(key.split("@")[1]);
+ }
+ }
+
+ if (ipsForUser.size() > 100) {
+ LOGGER.warn("User ID '{}' has {} IP locks → potential attack", userId,
ipsForUser.size());
+ }
+ }
+
+ public static LoginLockManager getInstance() {
+ return LoginLockManagerHelper.INSTANCE;
+ }
+
+ private static class LoginLockManagerHelper {
+ private static final LoginLockManager INSTANCE = new LoginLockManager();
+
+ private LoginLockManagerHelper() {}
+ }
+
+ /**
+ * Check if an IP address belongs to localhost (loopback or any local
network interface).
+ *
+ * @param ip The IP address as string.
+ * @return true if the IP is local, false otherwise. Note: Network interface
addresses are
+ * reacquired each time to account for possible address changes.
+ */
+ private boolean isFromLocalhost(String ip) {
+ try {
+ if (ip == null || ip.isEmpty()) {
+ return false;
+ }
+ InetAddress remote = InetAddress.getByName(ip);
+
+ // Case 1: Explicit loopback address (127.0.0.1 or ::1)
+ if (remote.isLoopbackAddress()) {
+ return true;
+ }
+
+ // Case 2: Compare against all local network interface addresses
+ Enumeration<NetworkInterface> nics =
NetworkInterface.getNetworkInterfaces();
+ while (nics.hasMoreElements()) {
+ NetworkInterface nic = nics.nextElement();
+ if (!nic.isUp()) {
+ continue; // Skip inactive interfaces
+ }
+ Enumeration<InetAddress> addrs = nic.getInetAddresses();
+ while (addrs.hasMoreElements()) {
+ InetAddress localAddr = addrs.nextElement();
+ if (remote.equals(localAddr)) {
+ return true; // Remote address matches one of the local addresses
+ }
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.warn("Failed to check if IP address={} is up", ip, e);
+ return false; // In case of error, assume non-local
+ }
+ return false;
+ }
+}
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java
index 4ffaf3560d2..74a966eacea 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/conf/IoTDBConfig.java
@@ -521,6 +521,18 @@ public class IoTDBConfig {
*/
private long maxExpiredTime = 2_592_000_000L;
+ /** The maximum number of consecutive failed login attempts for a specific
user@address */
+ private int failedLoginAttempts = -1;
+
+ /**
+ * The maximum number of consecutive failed login attempts for a specific
user (global) Note: Must
+ * be enabled if failed_login_attempts is enabled
+ */
+ private int failedLoginAttemptsPerUser = -1;
+
+ /** The lock time duration (in minutes) after reaching failed login attempts
threshold */
+ private int passwordLockTimeMinutes = 10;
+
/**
* The expired device ratio. If the number of expired device in one tsfile
exceeds this value,
* then expired data of this tsfile will be cleaned by compaction.
@@ -4189,4 +4201,28 @@ public class IoTDBConfig {
boolean includeNullValueInWriteThroughputMetric) {
this.includeNullValueInWriteThroughputMetric =
includeNullValueInWriteThroughputMetric;
}
+
+ public int getFailedLoginAttempts() {
+ return failedLoginAttempts;
+ }
+
+ public void setFailedLoginAttempts(int failedLoginAttempts) {
+ this.failedLoginAttempts = failedLoginAttempts;
+ }
+
+ public int getFailedLoginAttemptsPerUser() {
+ return failedLoginAttemptsPerUser;
+ }
+
+ public void setFailedLoginAttemptsPerUser(int failedLoginAttemptsPerUser) {
+ this.failedLoginAttemptsPerUser = failedLoginAttemptsPerUser;
+ }
+
+ public int getPasswordLockTimeMinutes() {
+ return passwordLockTimeMinutes;
+ }
+
+ public void setPasswordLockTimeMinutes(int passwordLockTimeMinutes) {
+ this.passwordLockTimeMinutes = passwordLockTimeMinutes;
+ }
}
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/session/SessionManager.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/session/SessionManager.java
index fde28d760d5..bae81487f4f 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/session/SessionManager.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/session/SessionManager.java
@@ -21,6 +21,7 @@ package org.apache.iotdb.db.protocol.session;
import org.apache.iotdb.common.rpc.thrift.TSStatus;
import org.apache.iotdb.commons.audit.UserEntity;
+import org.apache.iotdb.commons.auth.entity.User;
import org.apache.iotdb.commons.conf.CommonDescriptor;
import org.apache.iotdb.commons.conf.IoTDBConstant;
import org.apache.iotdb.commons.exception.IoTDBRuntimeException;
@@ -33,6 +34,10 @@ import org.apache.iotdb.commons.utils.AuthUtils;
import org.apache.iotdb.commons.utils.CommonDateTimeUtils;
import org.apache.iotdb.db.audit.DNAuditLogger;
import org.apache.iotdb.db.auth.AuthorityChecker;
+import org.apache.iotdb.db.auth.BasicAuthorityCache;
+import org.apache.iotdb.db.auth.ClusterAuthorityFetcher;
+import org.apache.iotdb.db.auth.IAuthorityFetcher;
+import org.apache.iotdb.db.auth.LoginLockManager;
import org.apache.iotdb.db.conf.IoTDBDescriptor;
import org.apache.iotdb.db.protocol.basic.BasicOpenSessionResp;
import org.apache.iotdb.db.protocol.thrift.OperationType;
@@ -56,6 +61,7 @@ import org.apache.iotdb.service.rpc.thrift.TSLastDataQueryReq;
import org.apache.iotdb.service.rpc.thrift.TSProtocolVersion;
import org.apache.commons.lang3.StringUtils;
+import org.apache.ratis.util.MemoizedSupplier;
import org.apache.tsfile.read.common.block.TsBlock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -99,6 +105,9 @@ public class SessionManager implements SessionManagerMBean {
public static final TSProtocolVersion CURRENT_RPC_VERSION =
TSProtocolVersion.IOTDB_SERVICE_PROTOCOL_V3;
+ private static final MemoizedSupplier<IAuthorityFetcher> authorityFetcher =
+ MemoizedSupplier.valueOf(() -> new ClusterAuthorityFetcher(new
BasicAuthorityCache()));
+
protected SessionManager() {
// singleton
String mbeanName =
@@ -239,6 +248,19 @@ public class SessionManager implements SessionManagerMBean
{
return openSessionResp;
}
+ User user = authorityFetcher.get().getUser(username);
+ boolean enableLoginLock = user != null;
+ LoginLockManager loginLockManager = LoginLockManager.getInstance();
+ if (enableLoginLock
+ && loginLockManager.checkLock(user.getUserId(),
session.getClientAddress())) {
+ // Generic authentication error
+ openSessionResp
+ .sessionId(-1)
+ .setMessage("Authentication failed.")
+ .setCode(TSStatusCode.WRONG_LOGIN_PASSWORD.getStatusCode());
+ return openSessionResp;
+ }
+
TSStatus loginStatus = AuthorityChecker.checkUser(username, password);
if (loginStatus.getCode() == TSStatusCode.SUCCESS_STATUS.getStatusCode()) {
// check the version compatibility
@@ -248,7 +270,8 @@ public class SessionManager implements SessionManagerMBean {
.setCode(TSStatusCode.INCOMPATIBLE_VERSION.getStatusCode())
.setMessage("The version is incompatible, please upgrade to " +
IoTDBConstant.VERSION);
} else {
- long userId = AuthorityChecker.getUserId(username).orElse(-1L);
+ User tmpUser = AuthorityChecker.getUser(username);
+ long userId = tmpUser == null ? -1 : tmpUser.getUserId();
session.setSqlDialect(sqlDialect);
supplySession(session, userId, username, ZoneId.of(zoneId),
clientVersion);
String logInMessage = "Login successfully";
@@ -298,9 +321,15 @@ public class SessionManager implements SessionManagerMBean
{
openSessionResp.getMessage(),
username,
session);
+ if (enableLoginLock) {
+ loginLockManager.clearFailure(tmpUser.getUserId(),
session.getClientAddress());
+ }
}
} else {
openSessionResp.sessionId(-1).setMessage(loginStatus.message).setCode(loginStatus.code);
+ if (enableLoginLock) {
+ loginLockManager.recordFailure(user.getUserId(),
session.getClientAddress());
+ }
}
return openSessionResp;
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/ASTVisitor.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/ASTVisitor.java
index 4960ae0ff49..8c13ef59f61 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/ASTVisitor.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/ASTVisitor.java
@@ -2523,6 +2523,22 @@ public class ASTVisitor extends
IoTDBSqlParserBaseVisitor<Statement> {
return authorStatement;
}
+ // Unlock User
+ @Override
+ public Statement
visitAlterUserAccountUnlock(IoTDBSqlParser.AlterUserAccountUnlockContext ctx) {
+ String usernameWithRootWithOptionalHost =
ctx.usernameWithRootWithOptionalHost().getText();
+ String[] parts = usernameWithRootWithOptionalHost.split("@", 2);
+ String username = parts[0];
+ String host = parts.length > 1 ? parts[1] : null;
+
+ AuthorStatement authorStatement = new
AuthorStatement(AuthorType.ACCOUNT_UNLOCK);
+ authorStatement.setUserName(parseIdentifier(username));
+ if (host != null) {
+ authorStatement.setLoginAddr(parseStringLiteral(host));
+ }
+ return authorStatement;
+ }
+
// Grant User Privileges
@Override
public Statement visitGrantUser(IoTDBSqlParser.GrantUserContext ctx) {
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/AccessControlImpl.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/AccessControlImpl.java
index a33c38037bb..ad5e748e781 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/AccessControlImpl.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/AccessControlImpl.java
@@ -431,6 +431,7 @@ public class AccessControlImpl implements AccessControl {
case GRANT_ROLE_SYS:
case REVOKE_USER_SYS:
case REVOKE_ROLE_SYS:
+ case ACCOUNT_UNLOCK:
auditEntity
.setAuditLogOperation(AuditLogOperation.DDL)
.setPrivilegeType(PrivilegeType.SECURITY);
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/TreeAccessCheckVisitor.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/TreeAccessCheckVisitor.java
index 1a97456d784..52303cc6042 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/TreeAccessCheckVisitor.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/TreeAccessCheckVisitor.java
@@ -605,6 +605,7 @@ public class TreeAccessCheckVisitor extends
StatementVisitor<TSStatus, TreeAcces
case GRANT_USER:
case GRANT_ROLE:
case REVOKE_ROLE:
+ case ACCOUNT_UNLOCK:
context
.setAuditLogOperation(AuditLogOperation.DDL)
.setPrivilegeType(PrivilegeType.SECURITY);
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/RelationalAuthorStatement.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/RelationalAuthorStatement.java
index 27325cb5352..74e93acaad0 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/RelationalAuthorStatement.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/RelationalAuthorStatement.java
@@ -55,6 +55,7 @@ public class RelationalAuthorStatement extends Statement {
private boolean grantOption;
private long executedByUserId;
private String newUsername = "";
+ private String loginAddr;
public RelationalAuthorStatement(
AuthorRType authorType,
@@ -183,6 +184,14 @@ public class RelationalAuthorStatement extends Statement {
this.newUsername = newUsername;
}
+ public String getLoginAddr() {
+ return loginAddr;
+ }
+
+ public void setLoginAddr(String loginAddr) {
+ this.loginAddr = loginAddr;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -190,6 +199,7 @@ public class RelationalAuthorStatement extends Statement {
RelationalAuthorStatement that = (RelationalAuthorStatement) o;
return grantOption == that.grantOption
&& authorType == that.authorType
+ && Objects.equals(loginAddr, that.loginAddr)
&& Objects.equals(database, that.database)
&& Objects.equals(tableName, that.tableName)
&& Objects.equals(userName, that.userName)
@@ -244,6 +254,7 @@ public class RelationalAuthorStatement extends Statement {
case REVOKE_USER_SYS:
case REVOKE_USER_ROLE:
case RENAME_USER:
+ case ACCOUNT_UNLOCK:
return QueryType.WRITE;
case LIST_ROLE:
case LIST_USER:
@@ -338,6 +349,8 @@ public class RelationalAuthorStatement extends Statement {
public TSStatus checkStatementIsValid(String currentUser) {
switch (authorType) {
+ case ACCOUNT_UNLOCK:
+ break;
case CREATE_USER:
if (AuthorityChecker.SUPER_USER.equals(userName)) {
return AuthorityChecker.getTSStatus(
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
index 8f859ffe6f7..1d2993057ce 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
@@ -304,7 +304,9 @@ import static
org.apache.iotdb.commons.schema.table.column.TsTableColumnCategory
import static
org.apache.iotdb.commons.udf.builtin.relational.TableBuiltinScalarFunction.DATE_BIN;
import static
org.apache.iotdb.db.queryengine.plan.execution.config.TableConfigTaskVisitor.DATABASE_NOT_SPECIFIED;
import static
org.apache.iotdb.db.queryengine.plan.parser.ASTVisitor.parseDateTimeFormat;
+import static
org.apache.iotdb.db.queryengine.plan.parser.ASTVisitor.parseIdentifier;
import static
org.apache.iotdb.db.queryengine.plan.parser.ASTVisitor.parseNodeString;
+import static
org.apache.iotdb.db.queryengine.plan.parser.ASTVisitor.parseStringLiteral;
import static
org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AnchorPattern.Type.PARTITION_END;
import static
org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AnchorPattern.Type.PARTITION_START;
import static
org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AsofJoinOn.constructAsofJoinOn;
@@ -1741,6 +1743,23 @@ public class AstBuilder extends
RelationalSqlBaseVisitor<Node> {
return stmt;
}
+ // Unlock User
+ @Override
+ public Node visitAlterUserAccountUnlockStatement(
+ RelationalSqlParser.AlterUserAccountUnlockStatementContext ctx) {
+ String usernameWithRootWithOptionalHost =
ctx.usernameWithRootWithOptionalHost().getText();
+ String[] parts = usernameWithRootWithOptionalHost.split("@", 2);
+ String username = parts[0];
+ String host = parts.length > 1 ? parts[1] : null;
+
+ RelationalAuthorStatement stmt = new
RelationalAuthorStatement(AuthorRType.ACCOUNT_UNLOCK);
+ stmt.setUserName(parseIdentifier(username));
+ if (host != null) {
+ stmt.setLoginAddr(parseStringLiteral(host));
+ }
+ return stmt;
+ }
+
@Override
public Node
visitRenameUserStatement(RelationalSqlParser.RenameUserStatementContext ctx) {
RelationalAuthorStatement stmt = new
RelationalAuthorStatement(AuthorRType.RENAME_USER);
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/type/AuthorRType.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/type/AuthorRType.java
index 8bbff6a718c..ea0d649d272 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/type/AuthorRType.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/type/AuthorRType.java
@@ -52,4 +52,5 @@ public enum AuthorRType {
LIST_ROLE_PRIV,
// Remind to renew the convert codes in ConfigNodeRPCServiceProcessor
RENAME_USER,
+ ACCOUNT_UNLOCK
}
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/AuthorType.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/AuthorType.java
index 3e3ad61fcd7..59949440950 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/AuthorType.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/AuthorType.java
@@ -35,6 +35,7 @@ public enum AuthorType {
LIST_ROLE,
LIST_USER_PRIVILEGE,
LIST_ROLE_PRIVILEGE,
+ ACCOUNT_UNLOCK,
// Remind to renew the convert codes in ConfigNodeRPCServiceProcessor
RENAME_USER,
;
@@ -79,6 +80,8 @@ public enum AuthorType {
return LIST_ROLE_PRIVILEGE;
case 15:
return RENAME_USER;
+ case 16:
+ return ACCOUNT_UNLOCK;
default:
return null;
}
@@ -123,6 +126,8 @@ public enum AuthorType {
return 14;
case RENAME_USER:
return 15;
+ case ACCOUNT_UNLOCK:
+ return 16;
default:
return -1;
}
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementType.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementType.java
index 0836f6f4a35..4284a484d57 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementType.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementType.java
@@ -27,6 +27,7 @@ package org.apache.iotdb.db.queryengine.plan.statement;
public enum StatementType {
NULL,
+ ACCOUNT_UNLOCK,
AUTHOR,
LOAD_DATA,
CREATE_USER,
diff --git
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/sys/AuthorStatement.java
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/sys/AuthorStatement.java
index a899152efcf..afbef6f7900 100644
---
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/sys/AuthorStatement.java
+++
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/sys/AuthorStatement.java
@@ -49,6 +49,7 @@ public class AuthorStatement extends Statement implements
IConfigStatement {
private boolean grantOpt;
private long executedByUserId;
private String newUsername = "";
+ private String loginAddr;
/**
* Constructor with AuthorType.
@@ -107,6 +108,9 @@ public class AuthorStatement extends Statement implements
IConfigStatement {
case RENAME_USER:
this.setType(StatementType.RENAME_USER);
break;
+ case ACCOUNT_UNLOCK:
+ this.setType(StatementType.ACCOUNT_UNLOCK);
+ break;
default:
throw new IllegalArgumentException("Unknown authorType: " +
authorType);
}
@@ -151,6 +155,14 @@ public class AuthorStatement extends Statement implements
IConfigStatement {
this.password = password;
}
+ public String getLoginAddr() {
+ return loginAddr;
+ }
+
+ public void setLoginAddr(String loginAddr) {
+ this.loginAddr = loginAddr;
+ }
+
public String getNewPassword() {
return newPassword;
}
@@ -220,6 +232,7 @@ public class AuthorStatement extends Statement implements
IConfigStatement {
case REVOKE_USER_ROLE:
case UPDATE_USER:
case RENAME_USER:
+ case ACCOUNT_UNLOCK:
queryType = QueryType.WRITE;
break;
case LIST_USER:
diff --git
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/auth/LoginLockManagerTest.java
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/auth/LoginLockManagerTest.java
new file mode 100644
index 00000000000..845e1b4578c
--- /dev/null
+++
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/auth/LoginLockManagerTest.java
@@ -0,0 +1,538 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.auth;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.net.InetAddress;
+import java.util.Deque;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class LoginLockManagerTest {
+
+ private LoginLockManager lockManager;
+ private static final long TEST_USER_ID = 1001L;
+ private static final long OTHER_USER_ID = 2002L;
+ private static final String TEST_IP = "192.168.1.1";
+ private static final String ANOTHER_IP = "10.0.0.1";
+ private static final long EXEMPT_USER_ID = 0L; // root
+
+ private final int failedLoginAttempts = 3;
+ private final int failedLoginAttemptsPerUser = 5;
+ private final int passwordLockTimeMinutes = 1;
+
+ @Before
+ public void setUp() {
+ lockManager =
+ new LoginLockManager(
+ failedLoginAttempts, failedLoginAttemptsPerUser,
passwordLockTimeMinutes);
+ }
+
+ // -------------------- Configuration --------------------
+ @Test
+ public void testAllConfigScenarios() {
+ // 1. Test normal configuration with recommended defaults
+ LoginLockManager normal = new LoginLockManager(5, 1000, 10);
+ assertEquals(5, getField(normal, "failedLoginAttempts"));
+ assertEquals(1000, getField(normal, "failedLoginAttemptsPerUser"));
+ assertEquals(10, getField(normal, "passwordLockTimeMinutes"));
+
+ // 2. Test disabling both IP-level and user-level restrictions
+ LoginLockManager disableBoth = new LoginLockManager(-1, -1, 10);
+ assertEquals(-1, getField(disableBoth, "failedLoginAttempts"));
+ assertEquals(-1, getField(disableBoth, "failedLoginAttemptsPerUser"));
+
+ // 3. Test enabling only user-level restriction
+ LoginLockManager userOnly = new LoginLockManager(-1, 5, 10);
+ assertEquals(-1, getField(userOnly, "failedLoginAttempts"));
+ assertEquals(5, getField(userOnly, "failedLoginAttemptsPerUser"));
+
+ // 4. Test that enabling IP-level automatically enables user-level with
default value
+ LoginLockManager ipEnable = new LoginLockManager(3, -1, 10);
+ assertEquals(3, getField(ipEnable, "failedLoginAttempts"));
+ assertEquals(1000, getField(ipEnable, "failedLoginAttemptsPerUser"));
+
+ // 5. Test invalid IP attempts value falls back to default (5)
+ LoginLockManager invalidIp = new LoginLockManager(0, -1, 10);
+ assertEquals(5, getField(invalidIp, "failedLoginAttempts"));
+
+ // 6. Test invalid user attempts value falls back to default (1000)
+ LoginLockManager invalidUser = new LoginLockManager(-1, 0, 10);
+ assertEquals(1000, getField(invalidUser, "failedLoginAttemptsPerUser"));
+
+ // 7. Test invalid lock time value falls back to default (10 minutes)
+ LoginLockManager invalidTime = new LoginLockManager(5, 1000, 0);
+ assertEquals(10, getField(invalidTime, "passwordLockTimeMinutes"));
+ }
+
+ private int getField(LoginLockManager manager, String fieldName) {
+ try {
+ java.lang.reflect.Field field =
LoginLockManager.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return (int) field.get(manager);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ // ---------------- Basic Functionality ----------------
+
+ @Test
+ public void testInitialStateNotLocked() {
+ assertFalse("New user should not be locked",
lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testSingleFailureNotLocked() {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ assertFalse("Single failure should not lock",
lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testIpLockAfterMaxAttempts() {
+ for (int i = 0; i < failedLoginAttempts; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ assertTrue(
+ "User@IP should be locked after max attempts",
+ lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testGlobalUserLockAfterMaxAttempts() {
+ for (int i = 0; i < failedLoginAttemptsPerUser; i++) {
+ lockManager.recordFailure(TEST_USER_ID, "ip" + i);
+ }
+ assertTrue(
+ "User should be locked globally after max attempts",
+ lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testExemptUsersLocalIpNeverLocked() throws Exception {
+ // Use loopback as local IP
+ String localIp = InetAddress.getLoopbackAddress().getHostAddress();
+
+ for (int i = 0; i < 20; i++) {
+ lockManager.recordFailure(EXEMPT_USER_ID, localIp);
+ }
+ assertFalse(
+ "Exempt user with local IP should never be locked",
+ lockManager.checkLock(EXEMPT_USER_ID, localIp));
+ }
+
+ @Test
+ public void testExemptUsersRemoteIpCanBeLocked() {
+ for (int i = 0; i < failedLoginAttempts; i++) {
+ lockManager.recordFailure(EXEMPT_USER_ID, TEST_IP);
+ }
+ assertTrue(
+ "Exempt user with non-local IP should still be locked",
+ lockManager.checkLock(EXEMPT_USER_ID, TEST_IP));
+ }
+
+ // -------------------- User-Level Locking --------------------
+ @Test
+ public void testUserLevelOLocking() {
+ LoginLockManager userOnlyManager = new LoginLockManager(-1, 10, 1);
+
+ for (int i = 0; i < 10; i++) {
+ userOnlyManager.recordFailure(TEST_USER_ID, "ip" + i); // Different IPs
each time
+ }
+ // Should be locked
+ assertTrue(userOnlyManager.checkLock(TEST_USER_ID, null));
+ for (int i = 0; i < 10; i++) {
+ assertTrue(userOnlyManager.checkLock(TEST_USER_ID, "ip" + i)); //
Different IPs each time
+ }
+ }
+
+ // ---------------- Unlock Logic ----------------
+
+ @Test
+ public void testManualUnlock() {
+ for (int i = 0; i < failedLoginAttempts; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ assertTrue(lockManager.checkLock(TEST_USER_ID, TEST_IP));
+
+ lockManager.unlock(TEST_USER_ID, TEST_IP);
+ assertFalse(
+ "User should be unlocked after manual unlock",
+ lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testGlobalManualUnlock() {
+ for (int i = 0; i < failedLoginAttemptsPerUser; i++) {
+ lockManager.recordFailure(TEST_USER_ID, "ip" + i);
+ }
+ assertTrue(lockManager.checkLock(TEST_USER_ID, TEST_IP));
+
+ lockManager.unlock(TEST_USER_ID, null);
+ assertFalse(
+ "User should be unlocked after manual global unlock",
+ lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testClearFailureResetsCounters() {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+
+ lockManager.clearFailure(TEST_USER_ID, TEST_IP);
+
+ for (int i = 0; i < failedLoginAttempts - 1; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ assertFalse(
+ "Counters should be reset after clear",
lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testEmptyIpInUnlock() {
+ lockManager.unlock(TEST_USER_ID, "");
+ // no exception expected
+ }
+
+ // ---------------- Multi-user Multi-IP ----------------
+
+ @Test
+ public void testDifferentUsersAreIndependent() {
+ for (int i = 0; i < failedLoginAttempts; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ assertTrue(lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ assertFalse("Other user should not be locked",
lockManager.checkLock(OTHER_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testDifferentIpsCountSeparately() {
+ for (int i = 0; i < failedLoginAttempts - 1; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ for (int i = 0; i < failedLoginAttempts - 1; i++) {
+ lockManager.recordFailure(TEST_USER_ID, ANOTHER_IP);
+ }
+ assertFalse(
+ "Neither IP should lock user individually",
lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ // ---------------- Cleaning Logic ----------------
+
+ @Test
+ public void testCleanExpiredLocks() {
+ for (int i = 0; i < failedLoginAttempts; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ assertTrue(lockManager.checkLock(TEST_USER_ID, TEST_IP));
+
+ try {
+ Field field = LoginLockManager.class.getDeclaredField("userIpLocks");
+ field.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ConcurrentMap<String, LoginLockManager.UserLockInfo> userIpLocks =
+ (ConcurrentMap<String, LoginLockManager.UserLockInfo>)
field.get(lockManager);
+
+ LoginLockManager.UserLockInfo lockInfo = userIpLocks.get(TEST_USER_ID +
"@" + TEST_IP);
+
+ Field tsField =
LoginLockManager.UserLockInfo.class.getDeclaredField("failureTimestamps");
+ tsField.setAccessible(true);
+
+ @SuppressWarnings("unchecked")
+ Deque<Long> timestamps = (Deque<Long>) tsField.get(lockInfo);
+
+ timestamps.clear();
+ timestamps.add(System.currentTimeMillis() - (passwordLockTimeMinutes *
60 * 1000L) - 5000);
+
+ } catch (Exception e) {
+ fail("Failed to modify failure timestamps via reflection: " +
e.getMessage());
+ }
+
+ lockManager.cleanExpiredLocks();
+ assertFalse(
+ "User should be unlocked after cleaning",
lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testNotCleanIfRecentFailureExists() {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ lockManager.cleanExpiredLocks();
+ assertFalse(
+ "Should still keep recent failure record",
lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ // ---------------- Sliding Window Logic ----------------
+
+ @Test
+ public void testLockWithinTimeWindow() throws InterruptedException {
+ for (int i = 0; i < failedLoginAttempts; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ Thread.sleep(200); // small delay but still within window
+ }
+ assertTrue(
+ "User should be locked within time window",
lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testFailuresOutsideWindowNotCounted() throws Exception {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+
+ // hack timestamps to simulate old failure
+ try {
+ Field field = LoginLockManager.class.getDeclaredField("userIpLocks");
+ field.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ConcurrentMap<String, LoginLockManager.UserLockInfo> userIpLocks =
+ (ConcurrentMap<String, LoginLockManager.UserLockInfo>)
field.get(lockManager);
+
+ LoginLockManager.UserLockInfo lockInfo = userIpLocks.get(TEST_USER_ID +
"@" + TEST_IP);
+
+ Field tsField =
LoginLockManager.UserLockInfo.class.getDeclaredField("failureTimestamps");
+ tsField.setAccessible(true);
+
+ @SuppressWarnings("unchecked")
+ Deque<Long> timestamps = (Deque<Long>) tsField.get(lockInfo);
+
+ timestamps.clear();
+ timestamps.add(System.currentTimeMillis() - (passwordLockTimeMinutes *
60 * 1000L) - 5000);
+
+ } catch (Exception e) {
+ fail("Reflection modification failed: " + e.getMessage());
+ }
+
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ assertFalse(
+ "Old failures should not count toward new lock",
+ lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ // ---------------- Configuration and Invalid Input ----------------
+
+ @Test
+ public void testInvalidConfigurationDefaults() {
+ {
+ LoginLockManager invalidConfigManager = new LoginLockManager(-1, 0, -1);
+ for (int i = 0; i < 1000; i++) {
+ invalidConfigManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ assertTrue(
+ "Should use default config when invalid values provided",
+ invalidConfigManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+ {
+ LoginLockManager invalidConfigManager = new LoginLockManager(-3, 0, -1);
+ for (int i = 0; i < 5; i++) {
+ invalidConfigManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ assertTrue(
+ "Should use default config when invalid values provided",
+ invalidConfigManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+ }
+
+ @Test
+ public void testNullIpHandling() {
+ lockManager.recordFailure(TEST_USER_ID, null);
+ assertFalse("Null IP should not cause exception",
lockManager.checkLock(TEST_USER_ID, null));
+ }
+
+ @Test
+ public void testNegativeUserId() {
+ long negativeUserId = -123L;
+ for (int i = 0; i < failedLoginAttempts; i++) {
+ lockManager.recordFailure(negativeUserId, TEST_IP);
+ }
+ assertTrue(
+ "Negative user ID should still lock",
lockManager.checkLock(negativeUserId, TEST_IP));
+ }
+
+ // ---------------- Concurrency Stress Tests ----------------
+
+ @Test
+ public void testConcurrentFailuresCauseLock() throws InterruptedException {
+ // Test configuration: 10 threads each making 2 failure attempts
+ int threadCount = 10;
+ int attemptsPerThread = 2;
+ int totalAttempts = threadCount * attemptsPerThread; // Should exceed lock
threshold
+
+ // Task definition: each thread records multiple failures
+ Runnable recordTask =
+ () -> {
+ for (int i = 0; i < attemptsPerThread; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ };
+
+ // Thread management: create and start all threads
+ Thread[] threads = new Thread[threadCount];
+ for (int i = 0; i < threadCount; i++) {
+ threads[i] = new Thread(recordTask);
+ threads[i].start();
+ }
+
+ // Synchronization: wait for all threads to complete
+ for (Thread t : threads) {
+ t.join();
+ }
+
+ // Verification: should be locked after concurrent attempts
+ assertTrue(
+ "System should be locked after " + totalAttempts + " concurrent
failure attempts",
+ lockManager.checkLock(TEST_USER_ID, TEST_IP));
+ }
+
+ @Test
+ public void testConcurrentDifferentUsersIndependent() throws
InterruptedException {
+ // Test configuration: 500 test users with threshold attempts each
+ int userCount = 500; // Large number to stress test isolation
+ int attemptsPerUser = failedLoginAttempts; // Exactly reaching threshold
+
+ // Task definition: simulate failures across multiple users
+ Runnable userFailureTask =
+ () -> {
+ for (int u = 0; u < userCount; u++) {
+ for (int i = 0; i < attemptsPerUser; i++) {
+ lockManager.recordFailure(TEST_USER_ID + u, TEST_IP);
+ }
+ }
+ };
+
+ // Concurrent execution: two threads working on the same user set
+ Thread t1 = new Thread(userFailureTask);
+ Thread t2 = new Thread(userFailureTask);
+ t1.start();
+ t2.start();
+ t1.join();
+ t2.join();
+
+ // Verification: each user should be locked independently
+ for (int u = 0; u < userCount; u++) {
+ assertTrue(
+ "User " + u + " should be locked independently without affecting
others",
+ lockManager.checkLock(TEST_USER_ID + u, TEST_IP));
+ }
+ }
+
+ @Test
+ public void testConcurrentClearAndFailure() throws InterruptedException {
+ // Setup: create locked state first
+ for (int i = 0; i < failedLoginAttempts; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ assertTrue(
+ "Precondition: user should be locked before concurrency test",
+ lockManager.checkLock(TEST_USER_ID, TEST_IP));
+
+ // Task definitions:
+ // Clear task: resets the failure count
+ Runnable clearTask =
+ () -> {
+ lockManager.clearFailure(TEST_USER_ID, TEST_IP);
+ // Artificial delay to increase race condition probability
+ try {
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ }
+ };
+
+ // Failure task: records additional failures
+ Runnable failTask =
+ () -> {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ try {
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ }
+ };
+
+ // Concurrent execution: run clear and record operations simultaneously
+ Thread clearThread = new Thread(clearTask);
+ Thread failThread = new Thread(failTask);
+ clearThread.start();
+ failThread.start();
+ clearThread.join();
+ failThread.join();
+ lockManager.checkLock(TEST_USER_ID, TEST_IP);
+ }
+
+ @Test
+ public void testConcurrentLockStateConsistency() throws InterruptedException
{
+ // Test configuration: 5 checking threads, 1 recording thread
+ int threadCount = 5;
+ AtomicBoolean consistencyFlag = new AtomicBoolean(true);
+
+ // Checking task: continuously verifies lock state validity
+ Runnable checkTask =
+ () -> {
+ for (int i = 0; i < 100; i++) {
+ boolean locked = lockManager.checkLock(TEST_USER_ID, TEST_IP);
+ if (locked) {
+ // When locked, verify the internal state matches
+ try {
+ Field counterField =
LoginLockManager.class.getDeclaredField("userIpLocks");
+ counterField.setAccessible(true);
+ ConcurrentMap<?, ?> locks = (ConcurrentMap<?, ?>)
counterField.get(lockManager);
+
+ // Critical check: locked state should have non-empty counters
+ if (locks.isEmpty()) {
+ consistencyFlag.set(false); // State inconsistency detected
+ }
+ } catch (Exception e) {
+ consistencyFlag.set(false);
+ }
+ }
+ }
+ };
+
+ // Recording task: pushes the system to locked state
+ Runnable recordTask =
+ () -> {
+ for (int i = 0; i < failedLoginAttempts * 2; i++) {
+ lockManager.recordFailure(TEST_USER_ID, TEST_IP);
+ }
+ };
+
+ // Execution: start all checking threads and recording thread
+ Thread[] checkThreads = new Thread[threadCount];
+ for (int i = 0; i < threadCount; i++) {
+ checkThreads[i] = new Thread(checkTask);
+ checkThreads[i].start();
+ }
+ Thread recordThread = new Thread(recordTask);
+ recordThread.start();
+
+ // Synchronization: wait for all threads to complete
+ for (Thread t : checkThreads) {
+ t.join();
+ }
+ recordThread.join();
+
+ // Final verification: no inconsistencies detected during test
+ assertTrue("Lock state should remain valid during concurrent access",
consistencyFlag.get());
+ }
+}
diff --git
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGeneratorTest.java
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGeneratorTest.java
index 447be59838d..9da2cdcf8a4 100644
---
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGeneratorTest.java
+++
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGeneratorTest.java
@@ -630,6 +630,11 @@ public class StatementGeneratorTest {
@Test
public void testDCLUserOperation() {
+ AuthorStatement unlockDcl = createAuthDclStmt("ALTER USER test @
'127.0.0.1' ACCOUNT UNLOCK;");
+ assertEquals("test", unlockDcl.getUserName());
+ assertEquals("127.0.0.1", unlockDcl.getLoginAddr());
+ assertEquals(StatementType.ACCOUNT_UNLOCK, unlockDcl.getType());
+
// 1. create user and drop user
AuthorStatement userDcl = createAuthDclStmt("create user `user1`
'password1';");
assertEquals("user1", userDcl.getUserName());
diff --git
a/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
b/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
index 24a652fbd12..087d3d8f639 100644
---
a/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
+++
b/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
@@ -157,6 +157,7 @@ statement
| grantUserRoleStatement
| revokeUserRoleStatement
| alterUserStatement
+ | alterUserAccountUnlockStatement
| renameUserStatement
| listUserPrivilegeStatement
| listRolePrivilegeStatement
@@ -709,6 +710,19 @@ alterUserStatement
: ALTER USER userName=identifier SET PASSWORD password=string
;
+alterUserAccountUnlockStatement
+ : ALTER USER userName=usernameWithRootWithOptionalHost ACCOUNT UNLOCK
+ ;
+
+usernameWithRoot
+ : ROOT
+ | identifier
+ ;
+
+usernameWithRootWithOptionalHost
+ : usernameWithRoot (AT host=STRING_LITERAL)?
+ ;
+
renameUserStatement
: ALTER USER username=identifier RENAME TO newUsername=identifier
;
@@ -1408,6 +1422,7 @@ nonReserved
;
ABSENT: 'ABSENT';
+ACCOUNT: 'ACCOUNT';
ADD: 'ADD';
ADMIN: 'ADMIN';
AFTER: 'AFTER';
@@ -1771,6 +1786,7 @@ UNION: 'UNION';
UNIQUE: 'UNIQUE';
UNKNOWN: 'UNKNOWN';
UNLOAD: 'UNLOAD';
+UNLOCK: 'UNLOCK';
UNMATCHED: 'UNMATCHED';
UNNEST: 'UNNEST';
UNTIL: 'UNTIL';
@@ -1881,6 +1897,19 @@ fragment DATE_LITERAL
| INTEGER_VALUE '.' INTEGER_VALUE '.' INTEGER_VALUE
;
+fragment DQUOTA_STRING
+ : '"' ( '""' | ~('"') )* '"'
+ ;
+
+fragment SQUOTA_STRING
+ : '\'' ( '\'\'' | ~('\'') )* '\''
+ ;
+
+STRING_LITERAL
+ : DQUOTA_STRING
+ | SQUOTA_STRING
+ ;
+
fragment TIME_LITERAL
: INTEGER_VALUE ':' INTEGER_VALUE ':' INTEGER_VALUE ('.' INTEGER_VALUE)?
;