This is an automated email from the ASF dual-hosted git repository.

CalvinKirs pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new c4dee4bd5e8 [feature](auth) Durable ACCOUNT_LOCK / ACCOUNT_UNLOCK for 
user accounts (#67792)
c4dee4bd5e8 is described below

commit c4dee4bd5e82c6f0f380e0243cbaf265999e6a6a
Author: Raghvendra Singh <[email protected]>
AuthorDate: Thu Sep 17 09:04:48 2026 +0530

    [feature](auth) Durable ACCOUNT_LOCK / ACCOUNT_UNLOCK for user accounts 
(#67792)
    
    ### What problem does this PR solve?
    
    Issue Number: close #67791
    
    Related PR: none
    
    Problem Summary:
    
    `CREATE USER ... ACCOUNT_LOCK` parsed but did nothing, and `ALTER USER
    ... ACCOUNT_LOCK` was refused with "Not support lock account now". The
    only lock Doris had was the failed-login one, whose counter and lock
    time are deliberately in-memory and per FE, so it clears on a restart or
    a master switch and never applies cluster-wide. There was no durable way
    to stop an account from authenticating without dropping it.
    
    ### Release note
    
    Support the MySQL-compatible administrative account lock: `ALTER USER
    ... ACCOUNT_LOCK | ACCOUNT_UNLOCK` and `CREATE USER ... ACCOUNT_LOCK`. A
    locked account is refused at authentication with
    `ER_ACCOUNT_HAS_BEEN_LOCKED` (3118); the lock is persisted and
    journaled, so it holds on every FE and across restarts; `ACCOUNT_UNLOCK`
    also resets the failed-login lock; `SHOW CREATE USER` prints
    `ACCOUNT_LOCK`. `ACCOUNT_LOCK` / `ACCOUNT_UNLOCK` combined with
    password-policy options in one `ALTER USER` now fails with the
    one-operation error instead of applying only the lock, and `ALTER USER
    root ACCOUNT_LOCK` is refused.
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
    - [x] Regression test:
    `regression-test/suites/account_p0/test_account_lock.groovy`
    - [x] Unit Test: `AccountLockTest` (lock/unlock via SQL, `CREATE USER
    ... ACCOUNT_LOCK`, policy edits keep the lock, `ACCOUNT_UNLOCK` clears a
    failed-login lock, journal replay, GSON round trip including an image
    written before the field, combined options rejected without a
    half-applied state, root refused, the shared predicate);
    `AuthenticatorManagerTest` (a locked account is refused after any
    authenticator accepted it, and on the certificate-only path);
    `AlterUserCommandTest` updated
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    
    - Behavior changed:
        - [ ] No.
    - [x] Yes. `ALTER USER ... ACCOUNT_LOCK` now succeeds instead of failing
    analysis, and `CREATE USER ... ACCOUNT_LOCK` now takes effect. Both were
    documented syntax with no effect before. `ACCOUNT_UNLOCK` additionally
    clears the administrative lock.
    
    - Does this need documentation?
        - [ ] No.
    - [x] Yes. `CREATE USER` / `ALTER USER` reference: `ACCOUNT_LOCK` /
    `ACCOUNT_UNLOCK` semantics (authentication-only check, existing sessions
    unaffected, `ACCOUNT_UNLOCK` resets failed-login tracking). Docs PR to
    follow once the shape is agreed.
    
    ### Design
    
    - `PasswordPolicy.FailedLoginPolicy` gains a persisted `manuallyLocked`
    flag (`@SerializedName`, rides the existing GSON image path; an image
    written before the field deserializes unlocked).
    - `ALTER USER ... ACCOUNT_LOCK` becomes a real
    `AlterUserOpType.LOCK_ACCOUNT` operation. The enum value has existed for
    years, so it is journaled through the ordinary `OP_ALTER_USER` path; an
    older binary replays it as an unknown operation: it logs an ERROR for
    that entry and the account stays unlocked there (no crash, no partial
    state). `ACCOUNT_UNLOCK` clears the flag and, as in MySQL, resets the
    failed-login state.
    - `CREATE USER ... ACCOUNT_LOCK` is honored through
    `PasswordPolicy.update`.
    - Enforcement is one predicate, `Auth.checkAccountLocked` →
    `PasswordPolicyManager.checkAccountLocked`, run after any authentication
    succeeded: the local-password path (inside
    `PasswordPolicy.checkAccountLockedAndPasswordExpiration`), the LDAP
    branch of `Auth.checkPlainPassword` (the Arrow Flight path), and
    `AuthenticatorManager` for every authenticator, including authentication
    integrations, plugins and certificate-only logins
    (`refuseIfAccountLocked`). The refusal is a new
    `ErrorCode.ERR_ACCOUNT_HAS_BEEN_LOCKED` (3118, `Access denied for user
    '%s'@'%s'. Account is locked.`). An identity without a Doris password
    policy (an LDAP-only user) is untouched; sessions already authenticated
    are not terminated (MySQL semantics).
    - `ACCOUNT_LOCK` / `ACCOUNT_UNLOCK` and the password-policy options are
    independent operations in `AlterUserInfo.validate`, so a statement
    carrying both hits the existing one-operation error; `ALTER USER root
    ACCOUNT_LOCK` is refused ("Can not lock root user"), in the same
    category as `CREATE USER root` / `DROP USER root`.
    - `SHOW CREATE USER` prints `ACCOUNT_LOCK` from a new
    `password_policy.account_locked` row appended to the policy info
    (appended last, so existing consumers' indices are unchanged).
---
 .../java/org/apache/doris/common/ErrorCode.java    |   2 +
 .../mysql/authenticate/AuthenticatorManager.java   |  35 +++-
 .../org/apache/doris/mysql/privilege/Auth.java     |  19 ++
 .../doris/mysql/privilege/PasswordPolicy.java      |  62 +++++-
 .../mysql/privilege/PasswordPolicyManager.java     |  21 +++
 .../plans/commands/ShowCreateUserCommand.java      |   4 +
 .../trees/plans/commands/info/AlterUserInfo.java   |  11 +-
 .../authenticate/AuthenticatorManagerTest.java     |  54 ++++++
 .../doris/mysql/privilege/AccountLockTest.java     | 207 +++++++++++++++++++++
 .../trees/plans/commands/AlterUserCommandTest.java |   9 +-
 .../suites/account_p0/test_account_lock.groovy     | 107 +++++++++++
 11 files changed, 524 insertions(+), 7 deletions(-)

diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/ErrorCode.java 
b/fe/fe-common/src/main/java/org/apache/doris/common/ErrorCode.java
index 8f5fe32bb30..60be049c122 100644
--- a/fe/fe-common/src/main/java/org/apache/doris/common/ErrorCode.java
+++ b/fe/fe-common/src/main/java/org/apache/doris/common/ErrorCode.java
@@ -1038,6 +1038,8 @@ public enum ErrorCode {
             + "you must change it using a client that supports expired 
passwords."),
     ERR_SECURE_TRANSPORT_REQUIRED(3159, new byte[] {'H', 'Y', '0', '0', '0'},
             "Connections using insecure transport are prohibited."),
+    ERR_ACCOUNT_HAS_BEEN_LOCKED(3118, new byte[] {'H', 'Y', '0', '0', '0'},
+            "Access denied for user '%s'@'%s'. Account is locked."),
     ERR_CREDENTIALS_CONTRADICT_TO_HISTORY(3638, new byte[] {'H', 'Y', '0', 
'0', '0'},
             "Cannot use these credentials for '%s'@'%s' because they 
contradict the password history policy"),
     ERR_USER_ACCESS_DENIED_FOR_USER_ACCOUNT_BLOCKED_BY_PASSWORD_LOCK(3955, new 
byte[] {'H', 'Y', '0', '0', '0'},
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/authenticate/AuthenticatorManager.java
 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/authenticate/AuthenticatorManager.java
index bb840114220..d62772cc171 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/authenticate/AuthenticatorManager.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/authenticate/AuthenticatorManager.java
@@ -23,6 +23,8 @@ import 
org.apache.doris.auth.certificate.CertificateRuntimeAuthFactory;
 import org.apache.doris.auth.certificate.CertificateRuntimeAuthService;
 import org.apache.doris.authentication.AuthenticationFailureType;
 import org.apache.doris.authentication.CredentialType;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.AuthenticationException;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.ErrorCode;
 import org.apache.doris.common.util.ClassLoaderUtils;
@@ -41,6 +43,7 @@ import org.apache.doris.plugin.PropertiesUtils;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.QueryState;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
@@ -182,6 +185,10 @@ public class AuthenticatorManager {
             return false;
         }
         if (certDecision.shouldSkipPasswordVerification()) {
+            // a certificate-only login is an authentication too: a locked 
account stays refused
+            if (refuseIfAccountLocked(context, 
certDecision.getUserIdentity())) {
+                return false;
+            }
             context.setCurrentUserIdentity(certDecision.getUserIdentity());
             context.setRemoteIP(remoteIp);
             context.setIsTempUser(false);
@@ -304,8 +311,32 @@ public class AuthenticatorManager {
         return null;
     }
 
-    private boolean finishSuccessfulAuthentication(ConnectContext context, 
String remoteIp,
-            AuthenticateResponse response, boolean setOkState) {
+    /**
+     * ACCOUNT_LOCK, enforced after ANY authentication succeeded -- local 
password, LDAP, an
+     * authentication integration or plugin, or a client certificate -- so 
none of them can open a
+     * locked Doris account (the local-password path also checks it inside the 
password policy).
+     * Returns true when the login was refused: the 3118 error is set and 
sent, nothing is applied.
+     */
+    @VisibleForTesting
+    boolean refuseIfAccountLocked(ConnectContext context, UserIdentity 
userIdentity) throws IOException {
+        try {
+            Env.getCurrentEnv().getAuth().checkAccountLocked(userIdentity);
+            return false;
+        } catch (AuthenticationException e) {
+            context.getState().setError(ErrorCode.ERR_ACCOUNT_HAS_BEEN_LOCKED,
+                    
ErrorCode.ERR_ACCOUNT_HAS_BEEN_LOCKED.formatErrorMsg(userIdentity.getQualifiedUser(),
+                            userIdentity.getHost()));
+            MysqlProto.sendResponsePacket(context);
+            return true;
+        }
+    }
+
+    @VisibleForTesting
+    boolean finishSuccessfulAuthentication(ConnectContext context, String 
remoteIp,
+            AuthenticateResponse response, boolean setOkState) throws 
IOException {
+        if (refuseIfAccountLocked(context, response.getUserIdentity())) {
+            return false;
+        }
         if (setOkState) {
             context.getState().setOk();
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/Auth.java 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/Auth.java
index e2a1182bd22..e25720f63e7 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/Auth.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/Auth.java
@@ -242,6 +242,10 @@ public class Auth implements Writable {
                 throw new 
AuthenticationException(ErrorCode.ERR_ACCESS_DENIED_ERROR, remoteUser + "@" + 
remoteHost,
                         Strings.isNullOrEmpty(remotePasswd) ? "NO" : "YES");
             }
+            // an LDAP-accepted credential still does not open a Doris account 
under ACCOUNT_LOCK
+            if (currentUser != null && !currentUser.isEmpty()) {
+                checkAccountLocked(currentUser.get(0));
+            }
         } else {
             readLock();
             try {
@@ -252,6 +256,15 @@ public class Auth implements Writable {
         }
     }
 
+    /**
+     * MySQL-compatible ACCOUNT_LOCK, enforced at authentication for every 
authenticator: a locked
+     * Doris account is refused whichever path (local password, LDAP, 
integration, plugin) accepted the
+     * credential. Not a session check -- sessions already authenticated are 
untouched.
+     */
+    public void checkAccountLocked(UserIdentity userIdentity) throws 
AuthenticationException {
+        passwdPolicyManager.checkAccountLocked(userIdentity);
+    }
+
     public void checkPlainPasswordForUserIdentity(UserIdentity userIdentity, 
String remotePasswd,
             List<UserIdentity> currentUser) throws AuthenticationException {
         readLock();
@@ -1962,6 +1975,12 @@ public class Auth implements Writable {
                 case SET_PASSWORD_POLICY:
                     passwdPolicyManager.updatePolicy(userIdent, null, 
passwordOptions);
                     break;
+                case LOCK_ACCOUNT:
+                    // MySQL-compatible ALTER USER ... ACCOUNT_LOCK: refuses 
the account's own logins
+                    // from now on (persisted + journaled). Not a session 
check: existing sessions are
+                    // unaffected, as in MySQL.
+                    passwdPolicyManager.lockUser(userIdent);
+                    break;
                 case UNLOCK_ACCOUNT:
                     passwdPolicyManager.unlockUser(userIdent);
                     break;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/PasswordPolicy.java 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/PasswordPolicy.java
index 996e2ddec61..68dc64bdf09 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/PasswordPolicy.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/PasswordPolicy.java
@@ -60,6 +60,7 @@ public class PasswordPolicy {
     private static final String PASSWORD_LOCK_SECONDS = 
"password_policy.password_lock_seconds";
     private static final String FAILED_LOGIN_COUNTER = 
"password_policy.failed_login_counter";
     private static final String LOCK_TIME = "password_policy.lock_time";
+    private static final String ACCOUNT_LOCKED = 
"password_policy.account_locked";
 
     @SerializedName(value = "expirePolicy")
     private ExpirePolicy expirePolicy = new ExpirePolicy();
@@ -78,6 +79,13 @@ public class PasswordPolicy {
     public void checkAccountLockedAndPasswordExpiration(UserIdentity curUser) 
throws AuthenticationException {
         lock.readLock().lock();
         try {
+            // MySQL-compatible ACCOUNT_LOCK: an administrative lock refuses 
authentication outright.
+            // As in MySQL it is an AUTHENTICATION check only: sessions that 
already authenticated
+            // are not terminated, and a proxying session is judged by the 
proxy's own account.
+            if (failedLoginPolicy.isManuallyLocked()) {
+                throw new 
AuthenticationException(ErrorCode.ERR_ACCOUNT_HAS_BEEN_LOCKED,
+                        curUser.getQualifiedUser(), curUser.getHost());
+            }
             if (expirePolicy.isExpire()) {
                 throw new 
AuthenticationException(ErrorCode.ERR_MUST_CHANGE_PASSWORD_LOGIN);
             }
@@ -119,6 +127,13 @@ public class PasswordPolicy {
             historyPolicy.update(password, passwordOptions.getHistoryPolicy());
             
failedLoginPolicy.updateNumFailedLogin(passwordOptions.getLoginAttempts());
             
failedLoginPolicy.updatePasswordLockSeconds(passwordOptions.getPasswordLockSecond());
+            // CREATE USER ... ACCOUNT_LOCK | ACCOUNT_UNLOCK (ALTER USER 
routes the two through
+            // their own AlterUserOpType so they journal as their own 
operation)
+            if (passwordOptions.getAccountUnlocked() == 
FailedLoginPolicy.LOCK_ACCOUNT) {
+                failedLoginPolicy.lockAccount();
+            } else if (passwordOptions.getAccountUnlocked() == 
FailedLoginPolicy.UNLOCK_ACCOUNT) {
+                failedLoginPolicy.unlockAccount();
+            }
         } finally {
             lock.writeLock().unlock();
         }
@@ -153,12 +168,30 @@ public class PasswordPolicy {
     public void unlockAccount() {
         lock.writeLock().lock();
         try {
-            failedLoginPolicy.unlock();
+            failedLoginPolicy.unlockAccount();
+        } finally {
+            lock.writeLock().unlock();
+        }
+    }
+
+    public void lockAccount() {
+        lock.writeLock().lock();
+        try {
+            failedLoginPolicy.lockAccount();
         } finally {
             lock.writeLock().unlock();
         }
     }
 
+    public boolean isAccountLocked() {
+        lock.readLock().lock();
+        try {
+            return failedLoginPolicy.isManuallyLocked();
+        } finally {
+            lock.readLock().unlock();
+        }
+    }
+
     /**
      * Password expire policy.
      * If a password is expired, user can no longer login with this password
@@ -350,6 +383,14 @@ public class PasswordPolicy {
         // Same as failedLoginCounter, not persist
         public AtomicLong lockTime = new AtomicLong(0);
 
+        // MySQL-compatible ACCOUNT_LOCK: an administrative lock, set by 
CREATE USER ... ACCOUNT_LOCK
+        // or ALTER USER ... ACCOUNT_LOCK and cleared by ALTER USER ... 
ACCOUNT_UNLOCK. Unlike the
+        // failed-login lock above it IS persisted (image) and journaled 
(OP_ALTER_USER), so it
+        // survives a restart and holds on every FE. Absent in images written 
before this field
+        // (GSON default: false).
+        @SerializedName(value = "manuallyLocked")
+        public boolean manuallyLocked = false;
+
         // Return true if the account is being locked.
         // Return false if nothing happen.
         public boolean onFailedLogin() {
@@ -409,6 +450,21 @@ public class PasswordPolicy {
             this.lockTime.set(0);
         }
 
+        public void lockAccount() {
+            this.manuallyLocked = true;
+        }
+
+        // ALTER USER ... ACCOUNT_UNLOCK clears the administrative lock AND 
the failed-login state,
+        // as MySQL's ACCOUNT UNLOCK resets failed-login tracking too.
+        public void unlockAccount() {
+            this.manuallyLocked = false;
+            unlock();
+        }
+
+        public boolean isManuallyLocked() {
+            return manuallyLocked;
+        }
+
         private String passwordLockSecondsToString() {
             if (passwordLockSeconds == -1) {
                 return "UNBOUNDED";
@@ -441,10 +497,14 @@ public class PasswordPolicy {
             List<String> row4 = Lists.newArrayList();
             row4.add(LOCK_TIME);
             row4.add(lockTime.get() == 0 ? "" : 
TimeUtils.longToTimeString(lockTime.get()));
+            List<String> row5 = Lists.newArrayList();
+            row5.add(ACCOUNT_LOCKED);
+            row5.add(String.valueOf(manuallyLocked));
             rows.add(row1);
             rows.add(row2);
             rows.add(row3);
             rows.add(row4);
+            rows.add(row5);
         }
     }
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/PasswordPolicyManager.java
 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/PasswordPolicyManager.java
index 0b041de269f..5950dc6265f 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/PasswordPolicyManager.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/PasswordPolicyManager.java
@@ -20,6 +20,7 @@ package org.apache.doris.mysql.privilege;
 import org.apache.doris.analysis.PasswordOptions;
 import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.common.AuthenticationException;
+import org.apache.doris.common.ErrorCode;
 import org.apache.doris.common.io.Text;
 import org.apache.doris.common.io.Writable;
 import org.apache.doris.mysql.privilege.PasswordPolicy.ExpirePolicy;
@@ -70,6 +71,21 @@ public class PasswordPolicyManager implements Writable {
         policy.checkAccountLockedAndPasswordExpiration(curUser);
     }
 
+    /**
+     * The administrative lock alone (no failed-login or expiration check): 
the check every
+     * authentication path runs for a Doris-managed account, whichever 
authenticator accepted the
+     * credential (local password, LDAP, an authentication integration or 
plugin).
+     */
+    public void checkAccountLocked(UserIdentity curUser) throws 
AuthenticationException {
+        if (curUser == null || !hasUser(curUser)) {
+            return;
+        }
+        if (getOrCreatePolicy(curUser).isAccountLocked()) {
+            throw new 
AuthenticationException(ErrorCode.ERR_ACCOUNT_HAS_BEEN_LOCKED,
+                    curUser.getQualifiedUser(), curUser.getHost());
+        }
+    }
+
     public boolean onFailedLogin(UserIdentity curUser) {
         if (!hasUser(curUser)) {
             return false;
@@ -110,6 +126,11 @@ public class PasswordPolicyManager implements Writable {
         return passwordPolicy.getInfo();
     }
 
+    public void lockUser(UserIdentity userIdent) {
+        PasswordPolicy passwordPolicy = getOrCreatePolicy(userIdent);
+        passwordPolicy.lockAccount();
+    }
+
     public void unlockUser(UserIdentity userIdent) {
         if (!hasUser(userIdent)) {
             return;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateUserCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateUserCommand.java
index e801b07995b..7729e599c4e 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateUserCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowCreateUserCommand.java
@@ -147,6 +147,10 @@ public class ShowCreateUserCommand extends ShowCommand {
                         sb.append(" PASSWORD_LOCK_TIME 
").append(lockValue).append(" SECOND");
                     }
                 }
+                // failedLoginPolicy: <ACCOUNT_LOCKED> -- the administrative 
lock (MySQL ACCOUNT LOCK)
+                if (policies.size() > 8 && 
"true".equalsIgnoreCase(policies.get(8).get(1))) {
+                    sb.append(" ACCOUNT_LOCK");
+                }
             }
         }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterUserInfo.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterUserInfo.java
index 0bc0a4ef47d..644d0fa7efb 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterUserInfo.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterUserInfo.java
@@ -115,11 +115,18 @@ public class AlterUserInfo {
             ops.add(AlterUserOpType.MODIFY_COMMENT);
         }
         passwordOptions.analyze();
+        // ACCOUNT_LOCK / ACCOUNT_UNLOCK and the password-policy options are 
independent operations, so a
+        // statement carrying both hits the one-operation rule below instead 
of silently dropping one side.
         if (passwordOptions.getAccountUnlocked() == 
PasswordPolicy.FailedLoginPolicy.LOCK_ACCOUNT) {
-            throw new AnalysisException("Not support lock account now");
+            if 
(userDesc.getUserIdent().getQualifiedUser().equals(Auth.ROOT_USER)) {
+                // like CREATE USER root / DROP USER root: a locked root has 
no way back
+                throw new AnalysisException("Can not lock root user");
+            }
+            ops.add(AlterUserOpType.LOCK_ACCOUNT);
         } else if (passwordOptions.getAccountUnlocked() == 
PasswordPolicy.FailedLoginPolicy.UNLOCK_ACCOUNT) {
             ops.add(AlterUserOpType.UNLOCK_ACCOUNT);
-        } else if (passwordOptions.getExpirePolicySecond() != 
PasswordOptions.UNSET
+        }
+        if (passwordOptions.getExpirePolicySecond() != PasswordOptions.UNSET
                 || passwordOptions.getHistoryPolicy() != PasswordOptions.UNSET
                 || passwordOptions.getPasswordLockSecond() != 
PasswordOptions.UNSET
                 || passwordOptions.getLoginAttempts() != 
PasswordOptions.UNSET) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/authenticate/AuthenticatorManagerTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/authenticate/AuthenticatorManagerTest.java
index 630d29e87fc..4c43f2fc44d 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/authenticate/AuthenticatorManagerTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/authenticate/AuthenticatorManagerTest.java
@@ -17,10 +17,12 @@
 
 package org.apache.doris.mysql.authenticate;
 
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.authentication.AuthenticationFailureType;
 import org.apache.doris.authentication.BasicPrincipal;
 import org.apache.doris.authentication.CredentialType;
 import org.apache.doris.catalog.Env;
+import org.apache.doris.common.AuthenticationException;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.ErrorCode;
 import org.apache.doris.datasource.DelegatedCredential;
@@ -976,4 +978,56 @@ class AuthenticatorManagerTest {
         field.setAccessible(true);
         field.set(null, value);
     }
+
+    @Test
+    void testLockedAccountIsRefusedAfterAnyAuthenticatorAccepted() throws 
Exception {
+        // ACCOUNT_LOCK is enforced at the common finish, so an LDAP / 
integration / plugin authenticator
+        // that accepted the credential still cannot open a locked Doris 
account
+        UserIdentity locked = 
UserIdentity.createAnalyzedUserIdentWithIp("alice", "%");
+        Mockito.doThrow(new 
AuthenticationException(ErrorCode.ERR_ACCOUNT_HAS_BEEN_LOCKED, "alice", "%"))
+                .when(auth).checkAccountLocked(locked);
+        AuthenticatorManager manager = new AuthenticatorManager("password");
+        ConnectContext context = new ConnectContext();
+        try (MockedStatic<MysqlProto> mysqlProto = 
Mockito.mockStatic(MysqlProto.class)) {
+            
Assertions.assertFalse(manager.finishSuccessfulAuthentication(context, 
REMOTE_IP,
+                    new AuthenticateResponse(true, locked), true));
+            mysqlProto.verify(() -> MysqlProto.sendResponsePacket(context));
+        }
+        Assertions.assertEquals(QueryState.MysqlStateType.ERR, 
context.getState().getStateType());
+        
Assertions.assertTrue(context.getState().getErrorMessage().contains("Account is 
locked"),
+                context.getState().getErrorMessage());
+        Assertions.assertNull(context.getCurrentUserIdentity());
+
+        // an account that is not locked finishes as before
+        UserIdentity open = UserIdentity.createAnalyzedUserIdentWithIp("bob", 
"%");
+        ConnectContext openContext = new ConnectContext();
+        
Assertions.assertTrue(manager.finishSuccessfulAuthentication(openContext, 
REMOTE_IP,
+                new AuthenticateResponse(true, open), false));
+        Assertions.assertEquals(open, openContext.getCurrentUserIdentity());
+        Mockito.verify(auth).checkAccountLocked(open);
+    }
+
+    @Test
+    void testCertificateOnlyLoginRunsTheSameLockCheck() throws Exception {
+        // the certificate-only path returns before 
finishSuccessfulAuthentication, so it calls the
+        // shared refusal directly: a locked account is refused with 3118 and 
nothing is applied
+        UserIdentity locked = 
UserIdentity.createAnalyzedUserIdentWithIp("alice", "%");
+        Mockito.doThrow(new 
AuthenticationException(ErrorCode.ERR_ACCOUNT_HAS_BEEN_LOCKED, "alice", "%"))
+                .when(auth).checkAccountLocked(locked);
+        AuthenticatorManager manager = new AuthenticatorManager("password");
+        ConnectContext context = new ConnectContext();
+        try (MockedStatic<MysqlProto> mysqlProto = 
Mockito.mockStatic(MysqlProto.class)) {
+            Assertions.assertTrue(manager.refuseIfAccountLocked(context, 
locked));
+            mysqlProto.verify(() -> MysqlProto.sendResponsePacket(context));
+        }
+        Assertions.assertEquals(QueryState.MysqlStateType.ERR, 
context.getState().getStateType());
+        
Assertions.assertTrue(context.getState().getErrorMessage().contains("Account is 
locked"),
+                context.getState().getErrorMessage());
+        Assertions.assertNull(context.getCurrentUserIdentity());
+
+        UserIdentity open = UserIdentity.createAnalyzedUserIdentWithIp("bob", 
"%");
+        ConnectContext openContext = new ConnectContext();
+        Assertions.assertFalse(manager.refuseIfAccountLocked(openContext, 
open));
+        Assertions.assertNotEquals(QueryState.MysqlStateType.ERR, 
openContext.getState().getStateType());
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccountLockTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccountLockTest.java
new file mode 100644
index 00000000000..b0f31113152
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccountLockTest.java
@@ -0,0 +1,207 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.alter.AlterUserOpType;
+import org.apache.doris.analysis.PasswordOptions;
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.AuthenticationException;
+import org.apache.doris.nereids.parser.NereidsParser;
+import org.apache.doris.nereids.trees.plans.commands.Command;
+import org.apache.doris.nereids.trees.plans.commands.ShowCreateUserCommand;
+import org.apache.doris.persist.AlterUserOperationLog;
+import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.utframe.TestWithFeService;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+/**
+ * MySQL-compatible administrative account lock: {@code CREATE USER ... 
ACCOUNT_LOCK},
+ * {@code ALTER USER ... ACCOUNT_LOCK | ACCOUNT_UNLOCK}. Persisted and 
journaled, enforced at
+ * password authentication only (an authentication check, not a session check, 
as in MySQL).
+ */
+public class AccountLockTest extends TestWithFeService {
+
+    private static final String HOST = "192.168.1.1";
+
+    private Auth auth() {
+        return Env.getCurrentEnv().getAuth();
+    }
+
+    private UserIdentity ident(String name) {
+        UserIdentity user = new UserIdentity(name, "%");
+        user.setIsAnalyzed();
+        return user;
+    }
+
+    private void run(String sql) throws Exception {
+        ((Command) new NereidsParser().parseSingle(sql)).run(connectContext, 
null);
+    }
+
+    private String loginError(String user, String password) {
+        try {
+            auth().checkPlainPassword(user, HOST, password, null);
+            return null;
+        } catch (AuthenticationException e) {
+            return e.getMessage();
+        }
+    }
+
+    private boolean canLogin(String user, String password) {
+        return loginError(user, password) == null;
+    }
+
+    private String showCreateUser(String name) throws Exception {
+        List<List<String>> rows = new 
ShowCreateUserCommand(ident(name)).doRun(connectContext, null)
+                .getResultRows();
+        Assertions.assertEquals(1, rows.size());
+        return rows.get(0).get(1);
+    }
+
+    private boolean policySaysLocked(String name) {
+        List<List<String>> info = 
auth().getPasswdPolicyManager().getPolicyInfo(ident(name));
+        Assertions.assertEquals("password_policy.account_locked", 
info.get(8).get(0));
+        return Boolean.parseBoolean(info.get(8).get(1));
+    }
+
+    @Test
+    public void testAlterLockRefusesLoginAndUnlockRestoresIt() throws 
Exception {
+        run("CREATE USER 'lk1'@'%' IDENTIFIED BY 'p1'");
+        Assertions.assertTrue(canLogin("lk1", "p1"));
+        Assertions.assertFalse(showCreateUser("lk1").contains("ACCOUNT_LOCK"));
+
+        run("ALTER USER 'lk1'@'%' ACCOUNT_LOCK");
+        Assertions.assertTrue(policySaysLocked("lk1"));
+        Assertions.assertTrue(showCreateUser("lk1").contains(" ACCOUNT_LOCK"), 
showCreateUser("lk1"));
+        // the right password is refused with MySQL's 
ER_ACCOUNT_HAS_BEEN_LOCKED text ...
+        String refused = loginError("lk1", "p1");
+        Assertions.assertNotNull(refused);
+        Assertions.assertTrue(refused.contains("Account is locked"), refused);
+        // ... and a wrong one stays a plain access-denied (the lock leaks 
nothing extra)
+        Assertions.assertFalse(canLogin("lk1", "wrong"));
+
+        // a policy edit does NOT clear an administrative lock
+        run("ALTER USER 'lk1'@'%' FAILED_LOGIN_ATTEMPTS 3");
+        Assertions.assertTrue(policySaysLocked("lk1"));
+        Assertions.assertFalse(canLogin("lk1", "p1"));
+
+        run("ALTER USER 'lk1'@'%' ACCOUNT_UNLOCK");
+        Assertions.assertFalse(policySaysLocked("lk1"));
+        Assertions.assertTrue(canLogin("lk1", "p1"));
+        Assertions.assertFalse(showCreateUser("lk1").contains("ACCOUNT_LOCK"));
+    }
+
+    @Test
+    public void testCreateUserAccountLockIsHonored() throws Exception {
+        run("CREATE USER 'lk2'@'%' IDENTIFIED BY 'p2' ACCOUNT_LOCK");
+        Assertions.assertTrue(policySaysLocked("lk2"));
+        String refused = loginError("lk2", "p2");
+        Assertions.assertNotNull(refused);
+        Assertions.assertTrue(refused.contains("Account is locked"), refused);
+
+        run("ALTER USER 'lk2'@'%' ACCOUNT_UNLOCK");
+        Assertions.assertTrue(canLogin("lk2", "p2"));
+    }
+
+    @Test
+    public void testUnlockAlsoClearsTheFailedLoginLock() throws Exception {
+        run("CREATE USER 'lk3'@'%' IDENTIFIED BY 'p3' FAILED_LOGIN_ATTEMPTS 1 
PASSWORD_LOCK_TIME UNBOUNDED");
+        Assertions.assertTrue(canLogin("lk3", "p3"));
+        Assertions.assertFalse(canLogin("lk3", "wrong"));
+        // one failure with FAILED_LOGIN_ATTEMPTS 1: the failed-login lock is 
on
+        String blocked = loginError("lk3", "p3");
+        Assertions.assertNotNull(blocked);
+        Assertions.assertTrue(blocked.contains("Account is blocked"), blocked);
+        Assertions.assertFalse(policySaysLocked("lk3")); // not an 
administrative lock
+
+        run("ALTER USER 'lk3'@'%' ACCOUNT_UNLOCK");
+        Assertions.assertTrue(canLogin("lk3", "p3"));
+    }
+
+    @Test
+    public void testLockIsJournaledAndPersisted() throws Exception {
+        // a follower replays the OP_ALTER_USER entry the master journaled for 
ACCOUNT_LOCK
+        run("CREATE USER 'lk4'@'%' IDENTIFIED BY 'p4'");
+        Assertions.assertTrue(canLogin("lk4", "p4"));
+        auth().replayAlterUser(new 
AlterUserOperationLog(AlterUserOpType.LOCK_ACCOUNT, ident("lk4"),
+                null, null, PasswordOptions.UNSET_OPTION, null));
+        Assertions.assertTrue(policySaysLocked("lk4"));
+        Assertions.assertFalse(canLogin("lk4", "p4"));
+        auth().replayAlterUser(new 
AlterUserOperationLog(AlterUserOpType.UNLOCK_ACCOUNT, ident("lk4"),
+                null, null, PasswordOptions.UNSET_OPTION, null));
+        Assertions.assertTrue(canLogin("lk4", "p4"));
+
+        // the flag rides the password policy's image serialization ...
+        PasswordPolicy locked = PasswordPolicy.createDefault();
+        locked.lockAccount();
+        PasswordPolicy reloaded = 
GsonUtils.GSON.fromJson(GsonUtils.GSON.toJson(locked), PasswordPolicy.class);
+        Assertions.assertTrue(reloaded.isAccountLocked());
+
+        // ... and an image written before the field deserializes UNLOCKED
+        JsonObject legacyJson = 
JsonParser.parseString(GsonUtils.GSON.toJson(locked)).getAsJsonObject();
+        
Assertions.assertNotNull(legacyJson.getAsJsonObject("failedLoginPolicy").remove("manuallyLocked"));
+        PasswordPolicy legacy = GsonUtils.GSON.fromJson(legacyJson, 
PasswordPolicy.class);
+        Assertions.assertFalse(legacy.isAccountLocked());
+    }
+
+    @Test
+    public void testLockCombinedWithPolicyOptionsIsRejectedNotHalfApplied() 
throws Exception {
+        run("CREATE USER 'lk6'@'%' IDENTIFIED BY 'p6'");
+        Exception e = Assertions.assertThrows(Exception.class, () ->
+                run("ALTER USER 'lk6'@'%' FAILED_LOGIN_ATTEMPTS 3 
PASSWORD_LOCK_TIME 60 SECOND ACCOUNT_LOCK"));
+        Assertions.assertTrue(e.getMessage().contains("one type of 
operation"), e.getMessage());
+        // nothing was applied: not locked, and the policy options stayed 
untouched
+        Assertions.assertFalse(policySaysLocked("lk6"));
+        Assertions.assertTrue(canLogin("lk6", "p6"));
+        List<List<String>> info = 
auth().getPasswdPolicyManager().getPolicyInfo(ident("lk6"));
+        Assertions.assertEquals("DISABLED", info.get(4).get(1)); // 
NUM_FAILED_LOGIN
+        Assertions.assertEquals("DISABLED", info.get(5).get(1)); // 
PASSWORD_LOCK_SECONDS
+        // the same combination on ACCOUNT_UNLOCK is rejected the same way
+        e = Assertions.assertThrows(Exception.class, () ->
+                run("ALTER USER 'lk6'@'%' FAILED_LOGIN_ATTEMPTS 3 
ACCOUNT_UNLOCK"));
+        Assertions.assertTrue(e.getMessage().contains("one type of 
operation"), e.getMessage());
+    }
+
+    @Test
+    public void testRootCannotBeLocked() throws Exception {
+        Exception e = Assertions.assertThrows(Exception.class, () -> 
run("ALTER USER 'root'@'%' ACCOUNT_LOCK"));
+        Assertions.assertTrue(e.getMessage().contains("Can not lock root 
user"), e.getMessage());
+        Assertions.assertTrue(canLogin("root", ""));
+    }
+
+    @Test
+    public void 
testCheckAccountLockedIsTheSharedPredicateForEveryAuthenticator() throws 
Exception {
+        // the check every authentication path runs after its authenticator 
accepted the credential
+        run("CREATE USER 'lk7'@'%' IDENTIFIED BY 'p7'");
+        Assertions.assertDoesNotThrow(() -> 
auth().checkAccountLocked(ident("lk7")));
+        Assertions.assertDoesNotThrow(() -> 
auth().checkAccountLocked(ident("nobody_without_a_policy")));
+        Assertions.assertDoesNotThrow(() -> auth().checkAccountLocked(null));
+        run("ALTER USER 'lk7'@'%' ACCOUNT_LOCK");
+        AuthenticationException refused = 
Assertions.assertThrows(AuthenticationException.class,
+                () -> auth().checkAccountLocked(ident("lk7")));
+        Assertions.assertTrue(refused.getMessage().contains("Account is 
locked"), refused.getMessage());
+        run("ALTER USER 'lk7'@'%' ACCOUNT_UNLOCK");
+        Assertions.assertDoesNotThrow(() -> 
auth().checkAccountLocked(ident("lk7")));
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterUserCommandTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterUserCommandTest.java
index 333c9b3c697..f6981054db0 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterUserCommandTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterUserCommandTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.doris.nereids.trees.plans.commands;
 
+import org.apache.doris.alter.AlterUserOpType;
 import org.apache.doris.analysis.PassVar;
 import org.apache.doris.analysis.PasswordOptions;
 import org.apache.doris.analysis.UserDesc;
@@ -79,8 +80,12 @@ public class AlterUserCommandTest extends TestWithFeService {
 
         //test PasswordOptions
         PasswordOptions passwordOptions02 = new 
PasswordOptions(PasswordOptions.UNSET, PasswordOptions.UNSET, 
PasswordOptions.UNSET, PasswordOptions.UNSET, PasswordOptions.UNSET, -1);
-        AlterUserInfo alterUserInfo03 = new AlterUserInfo(true, userDesc, 
passwordOptions02, null);
+        // ACCOUNT_LOCK is a real operation now (MySQL-compatible 
administrative lock): on its own (no
+        // password change riding along, non-root target) it validates as 
exactly that one operation
+        UserDesc lockTarget = new UserDesc(new UserIdentity("lock_target", 
"%"));
+        AlterUserInfo alterUserInfo03 = new AlterUserInfo(true, lockTarget, 
passwordOptions02, null);
         AlterUserCommand alterUserCommand03 = new 
AlterUserCommand(alterUserInfo03);
-        Assertions.assertThrows(AnalysisException.class, () -> 
alterUserCommand03.validate(), "Not support lock account now");
+        Assertions.assertDoesNotThrow(() -> alterUserCommand03.validate());
+        Assertions.assertEquals(AlterUserOpType.LOCK_ACCOUNT, 
alterUserInfo03.getOpType());
     }
 }
diff --git a/regression-test/suites/account_p0/test_account_lock.groovy 
b/regression-test/suites/account_p0/test_account_lock.groovy
new file mode 100644
index 00000000000..bd02499f7da
--- /dev/null
+++ b/regression-test/suites/account_p0/test_account_lock.groovy
@@ -0,0 +1,107 @@
+// 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.
+
+// MySQL-compatible administrative account lock: CREATE USER ... ACCOUNT_LOCK,
+// ALTER USER ... ACCOUNT_LOCK | ACCOUNT_UNLOCK. Refuses the account's own 
logins
+// with ER_ACCOUNT_HAS_BEEN_LOCKED, survives policy edits, is cleared by
+// ACCOUNT_UNLOCK (which also clears a failed-login lock), shows in SHOW CREATE
+// USER.
+suite("test_account_lock", "account,nonConcurrent") {
+    def user = "test_account_lock_user"
+    def locked = "test_account_lock_born_locked"
+    def tokens = context.config.jdbcUrl.split('/')
+    def url = tokens[0] + "//" + tokens[2] + "/" + "information_schema" + "?"
+
+    def loginError = { String u, String password ->
+        try {
+            connect(u, password, url) {
+                sql "SELECT 1"
+            }
+            return null
+        } catch (Exception e) {
+            logger.info("login of ${u} refused: " + e.getMessage())
+            return e.getMessage()
+        }
+    }
+    def canLogin = { String u, String password -> loginError(u, password) == 
null }
+
+    def grantClusterUsage = { String u ->
+        if (isCloudMode()) {
+            def clusters = sql "SHOW CLUSTERS"
+            assertTrue(!clusters.isEmpty())
+            sql """GRANT USAGE_PRIV ON CLUSTER `${clusters[0][0]}` TO 
'${u}'@'%'"""
+        }
+    }
+
+    try_sql "DROP USER IF EXISTS '${user}'@'%'"
+    try_sql "DROP USER IF EXISTS '${locked}'@'%'"
+
+    // 1. lock an existing account: the right password is refused with MySQL's 
text
+    sql "CREATE USER '${user}'@'%' IDENTIFIED BY 'p1'"
+    grantClusterUsage(user)
+    assertTrue(canLogin(user, "p1"))
+    sql "ALTER USER '${user}'@'%' ACCOUNT_LOCK"
+    def err = loginError(user, "p1")
+    assertNotNull(err)
+    assertTrue(err.contains("Account is locked"), err)
+
+    // 2. SHOW CREATE USER prints the lock
+    def created = sql "SHOW CREATE USER '${user}'@'%'"
+    assertEquals(1, created.size())
+    assertTrue(created[0].toString().contains("ACCOUNT_LOCK"), 
created[0].toString())
+
+    // 3. a policy edit does not clear the lock; ACCOUNT_UNLOCK does
+    sql "ALTER USER '${user}'@'%' FAILED_LOGIN_ATTEMPTS 3"
+    assertFalse(canLogin(user, "p1"))
+    sql "ALTER USER '${user}'@'%' ACCOUNT_UNLOCK"
+    assertTrue(canLogin(user, "p1"))
+    created = sql "SHOW CREATE USER '${user}'@'%'"
+    assertFalse(created[0].toString().contains("ACCOUNT_LOCK"), 
created[0].toString())
+
+    // 3b. ACCOUNT_LOCK / ACCOUNT_UNLOCK are single operations: combining them 
with password-policy
+    //     options is rejected outright instead of half-applied, and root 
cannot be locked
+    test {
+        sql "ALTER USER '${user}'@'%' FAILED_LOGIN_ATTEMPTS 3 
PASSWORD_LOCK_TIME 60 SECOND ACCOUNT_LOCK"
+        exception "one type of operation"
+    }
+    assertTrue(canLogin(user, "p1"))
+    test {
+        sql "ALTER USER 'root'@'%' ACCOUNT_LOCK"
+        exception "Can not lock root user"
+    }
+
+    // 4. CREATE USER ... ACCOUNT_LOCK is honored
+    sql "CREATE USER '${locked}'@'%' IDENTIFIED BY 'p2' ACCOUNT_LOCK"
+    grantClusterUsage(locked)
+    err = loginError(locked, "p2")
+    assertNotNull(err)
+    assertTrue(err.contains("Account is locked"), err)
+    sql "ALTER USER '${locked}'@'%' ACCOUNT_UNLOCK"
+    assertTrue(canLogin(locked, "p2"))
+
+    // 5. ACCOUNT_UNLOCK also clears a failed-login lock
+    sql "ALTER USER '${locked}'@'%' FAILED_LOGIN_ATTEMPTS 1 PASSWORD_LOCK_TIME 
UNBOUNDED"
+    assertFalse(canLogin(locked, "wrong"))
+    err = loginError(locked, "p2")
+    assertNotNull(err)
+    assertTrue(err.contains("Account is blocked"), err)
+    sql "ALTER USER '${locked}'@'%' ACCOUNT_UNLOCK"
+    assertTrue(canLogin(locked, "p2"))
+
+    sql "DROP USER IF EXISTS '${user}'@'%'"
+    sql "DROP USER IF EXISTS '${locked}'@'%'"
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to