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]