Skip to content

Commit 1c659b0

Browse files
committed
*: support MySQL dual passwords (RETAIN CURRENT PASSWORD / DISCARD OLD PASSWORD)
Adds MySQL 8.0-compatible dual-password support for credential rotation without downtime: - Parser: new RETAIN CURRENT PASSWORD and DISCARD OLD PASSWORD clauses on ALTER USER and SET PASSWORD; RETAIN/DISCARD/OLD registered as non-reserved keywords. - Privilege cache: UserRecord.AdditionalAuthenticationString, decoded from mysql.user.user_attributes '$.additional_password'. - Auth: ConnectionVerification falls back to the secondary password for mysql_native_password / caching_sha2_password / tidb_sm3_password when the primary check fails. - Privilege: APPLICATION_PASSWORD_ADMIN dynamic privilege; required when RETAIN/DISCARD is applied to another user's account (self-service is always allowed, whether the statement uses CURRENT_USER() or names the caller explicitly). - Executor: executeCreateUser rejects RETAIN/DISCARD (per MySQL); executeAlterUser and executeSetPwd promote the current authentication_string to user_attributes.additional_password on RETAIN, JSON_REMOVE on DISCARD, and silently drop the secondary on a plugin change (matching MySQL behavior). - Error codes: ErrCurrentPasswordCannotBeRetainedPluginChange (4058), ErrCurrentPasswordCannotBeRetainedEmptyNew (4059) — MySQL-compatible. - Tests: 10 unit tests in pkg/executor/test/passwordtest/dual_password_test.go plus integration test in tests/integrationtest/t/executor/dual_password.test. Ref #60587
1 parent 25fa27a commit 1c659b0

16 files changed

Lines changed: 12283 additions & 11498 deletions

File tree

pkg/errno/errcode.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,12 @@ const (
945945
ErrEngineAttributeNotSupported = 3981
946946
ErrJSONInBooleanContext = 3986
947947
ErrTableWithoutPrimaryKey = 3750
948+
// Dual-password (RETAIN CURRENT PASSWORD / DISCARD OLD PASSWORD) — match MySQL 8.0.
949+
ErrCurrentPasswordNotRequired = 3892
950+
ErrDuplicatePasswordSpecifiedKeywords = 3864
951+
ErrCurrentPasswordCannotBeRetainedPluginChange = 4058
952+
ErrCurrentPasswordCannotBeRetainedEmptyNew = 4059
953+
ErrCurrentPasswordCannotBeRetainedEmptyPrimary = 4060
948954
// MariaDB errors.
949955
ErrOnlyOneDefaultPartionAllowed = 4030
950956
ErrWrongPartitionTypeExpectedSystemTime = 4113

pkg/errno/errname.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,12 @@ var MySQLErrName = map[uint16]*mysql.ErrMessage{
946946
ErrDependentByCheckConstraint: mysql.Message("Check constraint '%s' uses column '%s', hence column cannot be dropped or renamed.", nil),
947947
ErrEngineAttributeNotSupported: mysql.Message("Storage engine does not support ENGINE_ATTRIBUTE.", nil),
948948
ErrJSONInBooleanContext: mysql.Message("Evaluating a JSON value in SQL boolean context does an implicit comparison against JSON integer 0; if this is not what you want, consider converting JSON to a SQL numeric type with JSON_VALUE RETURNING", nil),
949+
// Dual-password errors — match MySQL 8.0 text.
950+
ErrCurrentPasswordNotRequired: mysql.Message("Current password needs to be specified only if it exists.", nil),
951+
ErrDuplicatePasswordSpecifiedKeywords: mysql.Message("RETAIN CURRENT PASSWORD and DISCARD OLD PASSWORD clauses are mutually exclusive in a single ALTER USER statement.", nil),
952+
ErrCurrentPasswordCannotBeRetainedPluginChange: mysql.Message("Current password can not be retained for user '%-.64s'@'%-.64s' because authentication plugin is being changed.", nil),
953+
ErrCurrentPasswordCannotBeRetainedEmptyNew: mysql.Message("Current password can not be retained for user '%-.64s'@'%-.64s' because new password is empty.", nil),
954+
ErrCurrentPasswordCannotBeRetainedEmptyPrimary: mysql.Message("Empty current password can not be retained as secondary password for user '%-.64s'@'%-.64s'.", nil),
949955
// MariaDB errors.
950956
ErrOnlyOneDefaultPartionAllowed: mysql.Message("Only one DEFAULT partition allowed", nil),
951957
ErrWrongPartitionTypeExpectedSystemTime: mysql.Message("Wrong partitioning type, expected type: `SYSTEM_TIME`", nil),

pkg/executor/simple.go

Lines changed: 162 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import (
4747
"github.com/pingcap/tidb/pkg/parser/auth"
4848
"github.com/pingcap/tidb/pkg/parser/format"
4949
"github.com/pingcap/tidb/pkg/parser/mysql"
50+
"github.com/pingcap/tidb/pkg/parser/terror"
5051
"github.com/pingcap/tidb/pkg/planner/core"
5152
"github.com/pingcap/tidb/pkg/planner/core/resolve"
5253
"github.com/pingcap/tidb/pkg/plugin"
@@ -124,6 +125,8 @@ type passwordOrLockOptionsInfo struct {
124125
passwordLockTime int64
125126
failedLoginAttemptsChange bool
126127
passwordLockTimeChange bool
128+
retainCurrentPassword bool
129+
discardOldPassword bool
127130
}
128131

129132
type passwordReuseInfo struct {
@@ -917,8 +920,15 @@ func (info *passwordOrLockOptionsInfo) loadOptions(plOption []*ast.PasswordOrLoc
917920
case ast.PasswordReuseDefault:
918921
info.passwordReuseInterval = notSpecified
919922
info.passwordReuseIntervalChange = true
923+
case ast.RetainCurrentPassword:
924+
info.retainCurrentPassword = true
925+
case ast.DiscardOldPassword:
926+
info.discardOldPassword = true
920927
}
921928
}
929+
if info.retainCurrentPassword && info.discardOldPassword {
930+
return exeerrors.ErrDuplicatePasswordSpecifiedKeywords.GenWithStackByArgs()
931+
}
922932
return nil
923933
}
924934

@@ -1090,6 +1100,14 @@ func (e *SimpleExec) executeCreateUser(ctx context.Context, s *ast.CreateUserStm
10901100
if err != nil {
10911101
return err
10921102
}
1103+
// MySQL rejects RETAIN CURRENT PASSWORD / DISCARD OLD PASSWORD in CREATE USER;
1104+
// a new user always starts with a single primary password.
1105+
if plOptions.retainCurrentPassword {
1106+
return errors.Errorf("RETAIN CURRENT PASSWORD clause is not supported in CREATE USER statement")
1107+
}
1108+
if plOptions.discardOldPassword {
1109+
return errors.Errorf("DISCARD OLD PASSWORD clause is not supported in CREATE USER statement")
1110+
}
10931111
passwordLocking := createUserFailedLoginJSON(plOptions)
10941112
if s.IsCreateRole {
10951113
plOptions.lockAccount = "Y"
@@ -1719,6 +1737,7 @@ func (e *SimpleExec) executeAlterUser(ctx context.Context, s *ast.AlterUserStmt)
17191737
hasSystemUserPriv := checker.RequestDynamicVerification(activeRoles, "SYSTEM_USER", false)
17201738
hasRestrictedUserPriv := checker.RequestDynamicVerification(activeRoles, "RESTRICTED_USER_ADMIN", false)
17211739
hasSystemSchemaPriv := checker.RequestVerification(activeRoles, mysql.SystemDB, mysql.UserTable, "", mysql.UpdatePriv)
1740+
hasApplicationPasswordAdminPriv := checker.RequestDynamicVerification(activeRoles, "APPLICATION_PASSWORD_ADMIN", false)
17221741

17231742
var authTokenOptions []*ast.AuthTokenOrTLSOption
17241743
for _, authTokenOrTLSOption := range s.AuthTokenOrTLSOptions {
@@ -1795,6 +1814,32 @@ func (e *SimpleExec) executeAlterUser(ctx context.Context, s *ast.AlterUserStmt)
17951814
continue
17961815
}
17971816

1817+
// MySQL-compatible dual password: RETAIN CURRENT PASSWORD / DISCARD OLD PASSWORD validation.
1818+
// https://dev.mysql.com/doc/refman/8.0/en/password-management.html#password-management-dual-password
1819+
if plOptions.retainCurrentPassword || plOptions.discardOldPassword {
1820+
// Cross-user use of either clause requires APPLICATION_PASSWORD_ADMIN.
1821+
if !alterCurrentUser && !hasApplicationPasswordAdminPriv {
1822+
return plannererrors.ErrSpecificAccessDenied.GenWithStackByArgs("APPLICATION_PASSWORD_ADMIN")
1823+
}
1824+
// Only password-based auth plugins can hold a secondary password.
1825+
if !isDualPasswordCapablePlugin(currentAuthPlugin) {
1826+
return errors.Errorf("Dual password is not supported for users authenticating with plugin '%s'", currentAuthPlugin)
1827+
}
1828+
}
1829+
if plOptions.retainCurrentPassword {
1830+
// RETAIN requires a new password to be set, with the same plugin, and the new password must be non-empty.
1831+
if spec.AuthOpt == nil || !(spec.AuthOpt.ByAuthString || spec.AuthOpt.ByHashString) {
1832+
return exeerrors.ErrCurrentPasswordCannotBeRetainedEmptyNew.GenWithStackByArgs(spec.User.Username, spec.User.Hostname)
1833+
}
1834+
if spec.AuthOpt.AuthPlugin != "" && spec.AuthOpt.AuthPlugin != currentAuthPlugin {
1835+
return exeerrors.ErrCurrentPasswordCannotBeRetainedPluginChange.GenWithStackByArgs(spec.User.Username, spec.User.Hostname)
1836+
}
1837+
if (spec.AuthOpt.ByAuthString && spec.AuthOpt.AuthString == "") ||
1838+
(spec.AuthOpt.ByHashString && spec.AuthOpt.HashString == "") {
1839+
return exeerrors.ErrCurrentPasswordCannotBeRetainedEmptyNew.GenWithStackByArgs(spec.User.Username, spec.User.Hostname)
1840+
}
1841+
}
1842+
17981843
type AuthTokenOptionHandler int
17991844
const (
18001845
// noNeedAuthTokenOptions means the final auth plugin is NOT tidb_auth_plugin
@@ -1965,13 +2010,35 @@ func (e *SimpleExec) executeAlterUser(ctx context.Context, s *ast.AlterUserStmt)
19652010
if passwordLockingStr != "" {
19662011
newAttributes = append(newAttributes, passwordLockingStr)
19672012
}
2013+
// MySQL-compatible dual password: if RETAIN CURRENT PASSWORD is requested,
2014+
// capture the current authentication_string as the secondary password before
2015+
// overwriting it in this UPDATE.
2016+
if plOptions.retainCurrentPassword {
2017+
attrObj, err := buildAdditionalPasswordJSON(ctx, sqlExecutor, spec.User.Username, spec.User.Hostname)
2018+
if err != nil {
2019+
return err
2020+
}
2021+
// attrObj is a full JSON object; trim the surrounding braces so it can
2022+
// be concatenated with the other newAttributes entries.
2023+
newAttributes = append(newAttributes, strings.TrimSuffix(strings.TrimPrefix(attrObj, "{"), "}"))
2024+
}
19682025
if length := len(newAttributes); length > 0 {
19692026
if length > 1 || passwordLockingStr == "" {
19702027
passwordLockingInfo.containsNoOthers = false
19712028
}
19722029
newAttributesStr := fmt.Sprintf("{%s}", strings.Join(newAttributes, ","))
19732030
fields = append(fields, alterField{"user_attributes=json_merge_patch(coalesce(user_attributes, '{}'), %?)", newAttributesStr})
19742031
}
2032+
// DISCARD OLD PASSWORD removes the secondary password.
2033+
// MySQL also silently drops the secondary when the auth plugin is changed;
2034+
// detect that here and do the same.
2035+
dropSecondary := plOptions.discardOldPassword
2036+
if !dropSecondary && spec.AuthOpt != nil && spec.AuthOpt.AuthPlugin != "" && spec.AuthOpt.AuthPlugin != currentAuthPlugin {
2037+
dropSecondary = true
2038+
}
2039+
if dropSecondary && !plOptions.retainCurrentPassword {
2040+
fields = append(fields, alterField{"user_attributes=json_remove(coalesce(user_attributes, '{}'), '$.additional_password')", nil})
2041+
}
19752042

19762043
switch authTokenOptionHandler {
19772044
case noNeedAuthTokenOptions:
@@ -2456,6 +2523,65 @@ func userExists(ctx context.Context, sctx sessionctx.Context, name string, host
24562523
}
24572524

24582525
// use the same internal executor to read within the same transaction, otherwise same as userExists
2526+
// isDualPasswordCapablePlugin reports whether a user whose plugin is `plugin` is
2527+
// eligible to hold a secondary ("additional") password. Dual passwords are only
2528+
// meaningful for password-based plugins. LDAP / socket / token plugins are excluded,
2529+
// matching MySQL 8.0 behavior.
2530+
func isDualPasswordCapablePlugin(plugin string) bool {
2531+
switch plugin {
2532+
case mysql.AuthNativePassword, mysql.AuthCachingSha2Password, mysql.AuthTiDBSM3Password:
2533+
return true
2534+
}
2535+
return false
2536+
}
2537+
2538+
// readAuthenticationString returns the current authentication_string for the
2539+
// given user, using the supplied system session's executor. The second return
2540+
// value is true when a row was found, false otherwise — callers must check it
2541+
// before treating an empty string as a legitimate empty password vs. a missing
2542+
// user. Used when RETAIN CURRENT PASSWORD needs to capture the pre-change hash
2543+
// as the secondary password.
2544+
func readAuthenticationString(ctx context.Context, sqlExecutor sqlexec.SQLExecutor, name, host string) (string, bool, error) {
2545+
sql := new(strings.Builder)
2546+
sqlescape.MustFormatSQL(sql, `SELECT authentication_string FROM %n.%n WHERE User=%? AND Host=%? FOR UPDATE;`, mysql.SystemDB, mysql.UserTable, name, strings.ToLower(host))
2547+
rs, err := sqlExecutor.ExecuteInternal(ctx, sql.String())
2548+
if err != nil {
2549+
return "", false, err
2550+
}
2551+
defer terror.Call(rs.Close)
2552+
req := rs.NewChunk(nil)
2553+
if err := rs.Next(ctx, req); err != nil {
2554+
return "", false, err
2555+
}
2556+
if req.NumRows() == 0 {
2557+
return "", false, nil
2558+
}
2559+
return req.GetRow(0).GetString(0), true, nil
2560+
}
2561+
2562+
// buildAdditionalPasswordJSON reads the user's current authentication_string
2563+
// and returns a JSON-object fragment `{"additional_password": "<hash>"}`
2564+
// suitable for merging into user_attributes via JSON_MERGE_PATCH.
2565+
// It fails when the user doesn't exist or when the current primary password is
2566+
// empty — MySQL rejects RETAIN CURRENT PASSWORD in both situations.
2567+
func buildAdditionalPasswordJSON(ctx context.Context, sqlExecutor sqlexec.SQLExecutor, name, host string) (string, error) {
2568+
oldPwd, found, err := readAuthenticationString(ctx, sqlExecutor, name, host)
2569+
if err != nil {
2570+
return "", err
2571+
}
2572+
if !found {
2573+
return "", exeerrors.ErrPasswordNoMatch
2574+
}
2575+
if oldPwd == "" {
2576+
return "", exeerrors.ErrCurrentPasswordCannotBeRetainedEmptyPrimary.GenWithStackByArgs(name, host)
2577+
}
2578+
encoded, err := json.Marshal(oldPwd)
2579+
if err != nil {
2580+
return "", err
2581+
}
2582+
return fmt.Sprintf(`{"additional_password": %s}`, encoded), nil
2583+
}
2584+
24592585
func userExistsInternal(ctx context.Context, sqlExecutor sqlexec.SQLExecutor, name string, host string) (bool, string, error) {
24602586
sql := new(strings.Builder)
24612587
sqlescape.MustFormatSQL(sql, `SELECT * FROM %n.%n WHERE User=%? AND Host=%? FOR UPDATE;`, mysql.SystemDB, mysql.UserTable, name, strings.ToLower(host))
@@ -2515,22 +2641,34 @@ func (e *SimpleExec) executeSetPwd(ctx context.Context, s *ast.SetPwdStmt) error
25152641

25162642
var u, h string
25172643
disableSandboxMode := false
2518-
if s.User == nil || s.User.CurrentUser {
2519-
if e.Ctx().GetSessionVars().User == nil {
2644+
sessUser := e.Ctx().GetSessionVars().User
2645+
// setPwdForSelf matches executeAlterUser's alterCurrentUser idiom: treat an
2646+
// explicit `FOR 'self'@'host'` that names the caller as self-service, not
2647+
// cross-user. MySQL 8.0 documents this — APPLICATION_PASSWORD_ADMIN is only
2648+
// required when the statement names a *different* account.
2649+
setPwdForSelf := s.User == nil || s.User.CurrentUser ||
2650+
(sessUser != nil && sessUser.Username == s.User.Username && sessUser.AuthHostname == s.User.Hostname)
2651+
if setPwdForSelf {
2652+
if sessUser == nil {
25202653
return errors.New("Session error is empty")
25212654
}
2522-
u = e.Ctx().GetSessionVars().User.AuthUsername
2523-
h = e.Ctx().GetSessionVars().User.AuthHostname
2655+
u = sessUser.AuthUsername
2656+
h = sessUser.AuthHostname
25242657
} else {
25252658
u = s.User.Username
25262659
h = s.User.Hostname
25272660

25282661
checker := privilege.GetPrivilegeManager(e.Ctx())
25292662
activeRoles := e.Ctx().GetSessionVars().ActiveRoles
25302663
if checker != nil && !checker.RequestVerification(activeRoles, "", "", "", mysql.SuperPriv) {
2531-
currUser := e.Ctx().GetSessionVars().User
2664+
currUser := sessUser
25322665
return exeerrors.ErrDBaccessDenied.GenWithStackByArgs(currUser.Username, currUser.Hostname, "mysql")
25332666
}
2667+
// MySQL: RETAIN CURRENT PASSWORD on another user requires APPLICATION_PASSWORD_ADMIN.
2668+
if s.RetainCurrentPassword && checker != nil &&
2669+
!checker.RequestDynamicVerification(activeRoles, "APPLICATION_PASSWORD_ADMIN", false) {
2670+
return plannererrors.ErrSpecificAccessDenied.GenWithStackByArgs("APPLICATION_PASSWORD_ADMIN")
2671+
}
25342672
}
25352673
exists, authplugin, err := userExistsInternal(ctx, sqlExecutor, u, h)
25362674
if err != nil {
@@ -2539,6 +2677,14 @@ func (e *SimpleExec) executeSetPwd(ctx context.Context, s *ast.SetPwdStmt) error
25392677
if !exists {
25402678
return errors.Trace(exeerrors.ErrPasswordNoMatch)
25412679
}
2680+
if s.RetainCurrentPassword {
2681+
if !isDualPasswordCapablePlugin(authplugin) {
2682+
return errors.Errorf("Dual password is not supported for users authenticating with plugin '%s'", authplugin)
2683+
}
2684+
if s.Password == "" {
2685+
return exeerrors.ErrCurrentPasswordCannotBeRetainedEmptyNew.GenWithStackByArgs(u, h)
2686+
}
2687+
}
25422688
if e.Ctx().InSandBoxMode() {
25432689
if !(s.User == nil || s.User.CurrentUser ||
25442690
e.Ctx().GetSessionVars().User.AuthUsername == u && e.Ctx().GetSessionVars().User.AuthHostname == strings.ToLower(h)) {
@@ -2599,7 +2745,17 @@ func (e *SimpleExec) executeSetPwd(ctx context.Context, s *ast.SetPwdStmt) error
25992745
}
26002746
// update mysql.user
26012747
sql := new(strings.Builder)
2602-
sqlescape.MustFormatSQL(sql, `UPDATE %n.%n SET authentication_string=%?,password_expired='N',password_last_changed=current_timestamp() WHERE User=%? AND Host=%?;`, mysql.SystemDB, mysql.UserTable, pwd, u, strings.ToLower(h))
2748+
if s.RetainCurrentPassword {
2749+
// If RETAIN CURRENT PASSWORD is specified, promote the current authentication_string
2750+
// to user_attributes.$.additional_password as part of this UPDATE.
2751+
attr, err := buildAdditionalPasswordJSON(ctx, sqlExecutor, u, h)
2752+
if err != nil {
2753+
return err
2754+
}
2755+
sqlescape.MustFormatSQL(sql, `UPDATE %n.%n SET authentication_string=%?,password_expired='N',password_last_changed=current_timestamp(),user_attributes=json_merge_patch(coalesce(user_attributes, '{}'), %?) WHERE User=%? AND Host=%?;`, mysql.SystemDB, mysql.UserTable, pwd, attr, u, strings.ToLower(h))
2756+
} else {
2757+
sqlescape.MustFormatSQL(sql, `UPDATE %n.%n SET authentication_string=%?,password_expired='N',password_last_changed=current_timestamp() WHERE User=%? AND Host=%?;`, mysql.SystemDB, mysql.UserTable, pwd, u, strings.ToLower(h))
2758+
}
26032759
_, err = sqlExecutor.ExecuteInternal(ctx, sql.String())
26042760
if err != nil {
26052761
return err

pkg/executor/test/passwordtest/BUILD.bazel

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ go_test(
44
name = "passwordtest_test",
55
timeout = "short",
66
srcs = [
7+
"dual_password_test.go",
78
"main_test.go",
89
"password_management_test.go",
910
],
1011
flaky = True,
11-
shard_count = 9,
12+
shard_count = 25,
1213
deps = [
1314
"//pkg/domain",
1415
"//pkg/errno",

0 commit comments

Comments
 (0)