From ac9c60aa1dfd6561d1640a00cebadac4b718cc29 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 28 Oct 2026 10:20:07 +0900 Subject: [PATCH] fix: parse MySQL account authentication as SHOW CREATE USER prints it SHOW CREATE USER prints the authentication plugin as a string literal and, with print_identified_with_as_hex enabled, a hash that contains unprintable characters as a hexadecimal literal. MySQL also accepts CURRENT_USER() wherever a user account is named. The plugin was read only as an identifier, AS took only a string literal, and an account name had no parentheses after CURRENT_USER; ALTER USER CURRENT_USER() also went to the PostgreSQL ALTER ROLE alias. The plugin reuses MySqlAccountNamePart, so it keeps its quote character in UserAuthentication.getPluginName(). The hash after AS may be a HexValue, returned by getCredentialValue(). UserAccount records CURRENT_USER() in user lists; role lists still reject it, as MySQL reads them with role_ident. Signed-off-by: minleejae --- .../statement/create/user/UserAccount.java | 23 ++++++- .../create/user/UserAuthentication.java | 64 +++++++++++++++--- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 57 +++++++++++----- .../statement/alter/AlterUserTest.java | 25 +++++++ .../statement/create/user/CreateUserTest.java | 67 +++++++++++++++++++ .../create/user/MySqlAccountNameTest.java | 17 +++++ 6 files changed, 227 insertions(+), 26 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/UserAccount.java b/src/main/java/net/sf/jsqlparser/statement/create/user/UserAccount.java index e149cb4b77..9e08bd6a19 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/user/UserAccount.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/UserAccount.java @@ -12,12 +12,16 @@ import java.io.Serializable; import net.sf.jsqlparser.expression.StringValue; -/** A user and optional host together with its authentication clause. */ +/** + * A user and optional host together with its authentication clause. The current user may be named + * {@code CURRENT_USER} or {@code CURRENT_USER()}. + */ public class UserAccount implements Serializable { private StringValue user; private StringValue host; private UserAuthentication authentication; + private boolean currentUserParentheses; public StringValue getUser() { return user; @@ -35,6 +39,15 @@ public void setHost(StringValue host) { this.host = host; } + /** Whether the user is written {@code CURRENT_USER()}. */ + public boolean isCurrentUserParentheses() { + return currentUserParentheses; + } + + public void setCurrentUserParentheses(boolean currentUserParentheses) { + this.currentUserParentheses = currentUserParentheses; + } + public UserAuthentication getAuthentication() { return authentication; } @@ -46,6 +59,9 @@ public void setAuthentication(UserAuthentication authentication) { @Override public String toString() { StringBuilder builder = new StringBuilder().append(user); + if (currentUserParentheses) { + builder.append("()"); + } if (host != null) { builder.append("@").append(host); } @@ -65,6 +81,11 @@ public UserAccount withHost(StringValue host) { return this; } + public UserAccount withCurrentUserParentheses(boolean currentUserParentheses) { + setCurrentUserParentheses(currentUserParentheses); + return this; + } + public UserAccount withAuthentication(UserAuthentication authentication) { setAuthentication(authentication); return this; diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/UserAuthentication.java b/src/main/java/net/sf/jsqlparser/statement/create/user/UserAuthentication.java index b07864d82e..ebd3f72600 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/user/UserAuthentication.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/UserAuthentication.java @@ -10,12 +10,16 @@ package net.sf.jsqlparser.statement.create.user; import java.io.Serializable; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.HexValue; import net.sf.jsqlparser.expression.StringValue; /** * Structured authentication clause of a MySQL user account. {@code ALTER USER} adds * {@code REPLACE 'current'} and {@code RETAIN CURRENT PASSWORD} to a password assignment, and - * {@code DISCARD OLD PASSWORD} as a mode of its own. + * {@code DISCARD OLD PASSWORD} as a mode of its own. The plugin name keeps its quote character, as + * in {@code IDENTIFIED WITH 'caching_sha2_password'}, and the hash of {@code AS} may be a + * hexadecimal literal, as SHOW CREATE USER prints it. */ public class UserAuthentication implements Serializable { @@ -30,8 +34,8 @@ public boolean isPasswordAssignment() { } private Mode mode; - private String plugin; - private StringValue credential; + private StringValue pluginName; + private Expression credential; private StringValue currentCredential; private boolean retainCurrentPassword; @@ -43,22 +47,52 @@ public void setMode(Mode mode) { this.mode = mode; } + /** The plugin name without its quotes, or null without {@code IDENTIFIED WITH}. */ public String getPlugin() { - return plugin; + return pluginName == null ? null : pluginName.getValue(); } + /** Sets an unquoted plugin name. */ public void setPlugin(String plugin) { - this.plugin = plugin; + this.pluginName = + plugin == null ? null : new StringValue().withValue(plugin).setQuoteStr(""); } + /** The plugin name with its quote character, which is empty for an unquoted name. */ + public StringValue getPluginName() { + return pluginName; + } + + public void setPluginName(StringValue pluginName) { + this.pluginName = pluginName; + } + + /** + * The password or hash as a string literal, or null when there is none or the hash of + * {@code AS} is a hexadecimal literal; {@link #getCredentialValue()} returns either. + */ public StringValue getCredential() { - return credential; + return credential instanceof StringValue ? (StringValue) credential : null; } public void setCredential(StringValue credential) { this.credential = credential; } + /** The password or hash as written: a {@link StringValue}, or a {@link HexValue} after AS. */ + public Expression getCredentialValue() { + return credential; + } + + public void setCredentialValue(Expression credential) { + if (credential != null && !(credential instanceof StringValue) + && !(credential instanceof HexValue)) { + throw new IllegalArgumentException( + "A credential is a string or hexadecimal literal: " + credential); + } + this.credential = credential; + } + /** The current password of {@code REPLACE 'current'}, or null when omitted. */ public StringValue getCurrentCredential() { return currentCredential; @@ -96,13 +130,13 @@ private String modeToString() { case BY_RANDOM_PASSWORD: return "IDENTIFIED BY RANDOM PASSWORD"; case WITH_PLUGIN: - return "IDENTIFIED WITH " + plugin; + return "IDENTIFIED WITH " + pluginName; case WITH_PLUGIN_BY_PASSWORD: - return "IDENTIFIED WITH " + plugin + " BY " + credential; + return "IDENTIFIED WITH " + pluginName + " BY " + credential; case WITH_PLUGIN_BY_RANDOM_PASSWORD: - return "IDENTIFIED WITH " + plugin + " BY RANDOM PASSWORD"; + return "IDENTIFIED WITH " + pluginName + " BY RANDOM PASSWORD"; case WITH_PLUGIN_AS_STRING: - return "IDENTIFIED WITH " + plugin + " AS " + credential; + return "IDENTIFIED WITH " + pluginName + " AS " + credential; case DISCARD_OLD_PASSWORD: return "DISCARD OLD PASSWORD"; default: @@ -120,11 +154,21 @@ public UserAuthentication withPlugin(String plugin) { return this; } + public UserAuthentication withPluginName(StringValue pluginName) { + setPluginName(pluginName); + return this; + } + public UserAuthentication withCredential(StringValue credential) { setCredential(credential); return this; } + public UserAuthentication withCredentialValue(Expression credential) { + setCredentialValue(credential); + return this; + } + public UserAuthentication withCurrentCredential(StringValue currentCredential) { setCurrentCredential(currentCredential); return this; diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 8420622293..4ea4ec5254 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2761,6 +2761,14 @@ public class CCJSqlParser extends AbstractJSqlParser { if (!condition) { throw new ParseException(message); } } + /** MySQL role lists name roles with role_ident, which does not take CURRENT_USER(). */ + private static List requireRoleNames(List roles) throws ParseException { + for (UserAccount role : roles) { + requireAccessSyntax(!role.isCurrentUserParentheses(), "A role list does not take CURRENT_USER()"); + } + return roles; + } + private static > E accessEnum(Class type, String value) throws ParseException { try { return Enum.valueOf(type, value.toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException ex) { throw new ParseException("Invalid " + type.getSimpleName() + ": " + value); } @@ -2840,14 +2848,15 @@ public class CCJSqlParser extends AbstractJSqlParser { /** * Whether ALTER USER uses MySQL account syntax rather than the PostgreSQL alias of ALTER ROLE, - * which names one unquoted role followed by an action: IF EXISTS, USER(), a quoted name, a - * host, an account list, no action at all, or a MySQL account clause. + * which names one unquoted role followed by an action: IF EXISTS, USER(), CURRENT_USER(), a + * quoted name, a host, an account list, no action at all, or a MySQL account clause. */ private boolean isMySqlAlterUserAhead() { if (getToken(1).kind != K_USER) { return false; } Token name = getToken(2); if (name.kind == K_IF || name.kind == S_CHAR_LITERAL - || name.kind == K_USER && "(".equals(getToken(3).image) + || (name.kind == K_USER || "CURRENT_USER".equalsIgnoreCase(name.image)) + && "(".equals(getToken(3).image) || isMySqlAccountHostAhead(2, false)) { return true; } @@ -16735,6 +16744,11 @@ UserAccount MySqlAccountName(): | part=MySqlAccountNamePart() { account.setUser(part); } [ + // CURRENT_USER() names the current user like CURRENT_USER; a quoted part has quotes in its image + LOOKAHEAD({ "CURRENT_USER".equalsIgnoreCase(getToken(0).image) + && "(".equals(getToken(1).image) && ")".equals(getToken(2).image) }) + "(" ")" { account.setCurrentUserParentheses(true); } + | part=MySqlAccountNamePart() { account.setHost(part); } | // a quoted user followed by an unquoted @host @@ -16778,11 +16792,11 @@ RoleSelection MySqlRoleSelection(boolean setRole): { selection = new RoleSelection(RoleSelection.Kind.ALL); } [ LOOKAHEAD({ getToken(1).kind == K_EXCEPT }) roles=MySqlAccountNameList() - { selection.setKind(RoleSelection.Kind.ALL_EXCEPT); selection.setRoles(roles); } + { selection.setKind(RoleSelection.Kind.ALL_EXCEPT); selection.setRoles(requireRoleNames(roles)); } ] | roles=MySqlAccountNameList() - { selection = new RoleSelection(RoleSelection.Kind.ROLES).withRoles(roles); } + { selection = new RoleSelection(RoleSelection.Kind.ROLES).withRoles(requireRoleNames(roles)); } ) { requireAccessSyntax(setRole || selection.getKind() != RoleSelection.Kind.DEFAULT @@ -16792,10 +16806,14 @@ RoleSelection MySqlRoleSelection(boolean setRole): } } +/** + * IDENTIFIED BY or IDENTIFIED WITH. The plugin may be quoted like an account name part, and the + * hash after AS may be a hexadecimal literal, as SHOW CREATE USER prints both. + */ UserAuthentication MySqlUserAuthentication(): { UserAuthentication authentication = new UserAuthentication(); - String plugin = null; + StringValue plugin = null; Token credential = null; } { @@ -16813,9 +16831,9 @@ UserAuthentication MySqlUserAuthentication(): } ) | - plugin=RelObjectName() { + plugin=MySqlAccountNamePart() { authentication.setMode(UserAuthentication.Mode.WITH_PLUGIN); - authentication.setPlugin(plugin); + authentication.setPluginName(plugin); } [ @@ -16831,10 +16849,13 @@ UserAuthentication MySqlUserAuthentication(): } ) | - credential= { - authentication.setMode(UserAuthentication.Mode.WITH_PLUGIN_AS_STRING); - authentication.setCredential(stringValue(credential)); - } + { authentication.setMode(UserAuthentication.Mode.WITH_PLUGIN_AS_STRING); } + ( + credential= { authentication.setCredential(stringValue(credential)); } + | + credential= + { authentication.setCredentialValue(new HexValue(credential.image)); } + ) ] ) { return authentication; } @@ -17142,7 +17163,7 @@ CreateRole MySqlCreateRole(): { ContextualKeyword("ROLE") [ LOOKAHEAD(3) { role.setIfNotExists(true); } ] - roles=MySqlAccountNameList() { role.setRoles(roles); } + roles=MySqlAccountNameList() { role.setRoles(requireRoleNames(roles)); } { return role; } } @@ -17181,7 +17202,8 @@ SetDefaultRoleStatement SetDefaultRoleStatement(): { statement.setRoles(roles); } [ ( | { statement.setForKeyword(true); } ) - accounts=MySqlAccountNameList() { statement.setAccounts(accounts); } + // MySQL reads these accounts as a role list + accounts=MySqlAccountNameList() { statement.setAccounts(requireRoleNames(accounts)); } ] { return statement; } } @@ -19917,6 +19939,11 @@ void DropTargets(Drop drop): LOOKAHEAD({ drop.getObjectType().hasAccountTargets() }) account=MySqlAccountName() { drop.addAccounts(account); } ( "," account=MySqlAccountName() { drop.addAccounts(account); } )* + { + if (drop.getObjectType() == Drop.ObjectType.ROLE) { + requireRoleNames(drop.getAccounts()); + } + } | name=DropObjectName() { drop.addNames(name); } ( "," name=DropObjectName() { drop.addNames(name); } )* @@ -23059,7 +23086,7 @@ PrivilegeClause AccessClause(boolean revoke, boolean defaults): ( "," privilege=AccessPrivilege() { clause.getPrivileges().add(privilege); } )* target=AccessTarget(defaults) { clause.setTarget(target); } | - roles=MySqlAccountNameList() { clause.setRoleItems(roles); clause.setTarget(null); } + roles=MySqlAccountNameList() { clause.setRoleItems(requireRoleNames(roles)); clause.setTarget(null); } ) ( { requireAccessSyntax(!revoke, "REVOKE requires FROM"); } | { requireAccessSyntax(revoke, "GRANT requires TO"); } ) grantees=AccessGrantees() { clause.setGranteeItems(grantees); return clause; } diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/AlterUserTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/AlterUserTest.java index d784a16fee..8bd06f22b1 100644 --- a/src/test/java/net/sf/jsqlparser/statement/alter/AlterUserTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/alter/AlterUserTest.java @@ -92,6 +92,31 @@ void replacesCurrentPassword() throws JSQLParserException { assertTrue(authentication.isRetainCurrentPassword()); } + @Test + void namesTheCurrentUserWithParentheses() throws JSQLParserException { + AlterUser alterUser = parse("ALTER USER CURRENT_USER() IDENTIFIED BY 'x' " + + "RETAIN CURRENT PASSWORD"); + UserAccount account = alterUser.getAccounts().get(0); + + assertEquals("CURRENT_USER", account.getUser().getValue()); + assertTrue(account.isCurrentUserParentheses()); + assertFalse(alterUser.isUserFunction()); + assertFalse(parse("ALTER USER CURRENT_USER IDENTIFIED BY 'x'").getAccounts().get(0) + .isCurrentUserParentheses()); + assertEquals("CURRENT_USER()", new UserAccount() + .withUser(new StringValue("CURRENT_USER").setQuoteStr("")) + .withCurrentUserParentheses(true).toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER USER 'CURRENT_USER'() IDENTIFIED BY 'x'", + "ALTER USER app() IDENTIFIED BY 'x'", + "ALTER USER CURRENT_USER()@localhost ACCOUNT LOCK"}) + void takesParenthesesOnlyAfterAnUnquotedCurrentUser(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } + @Test void altersTheCurrentUserWithUserFunction() throws JSQLParserException { AlterUser alterUser = parse("ALTER USER USER() IDENTIFIED BY 'x'"); diff --git a/src/test/java/net/sf/jsqlparser/statement/create/user/CreateUserTest.java b/src/test/java/net/sf/jsqlparser/statement/create/user/CreateUserTest.java index a5bedb1c5b..c97fe12eb7 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/user/CreateUserTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/user/CreateUserTest.java @@ -22,6 +22,8 @@ import java.util.List; import java.util.stream.Collectors; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.HexValue; +import net.sf.jsqlparser.expression.LongValue; import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; import net.sf.jsqlparser.parser.CCJSqlParserUtil; @@ -100,6 +102,71 @@ void parsesMultipleAccountsAndAuthenticationPlugin() throws JSQLParserException createUser.getAccounts().get(1).getAuthentication().getMode()); } + @Test + void parsesQuotedPluginAndHashAsShowCreateUserPrintsThem() throws JSQLParserException { + String sql = "CREATE USER `app`@`%` IDENTIFIED WITH 'caching_sha2_password' " + + "AS '$A$005$hash' REQUIRE NONE PASSWORD EXPIRE DEFAULT ACCOUNT UNLOCK"; + UserAuthentication authentication = + roundTrip(CreateUser.class, sql, Dialect.MYSQL).getAccounts().get(0) + .getAuthentication(); + + assertEquals(UserAuthentication.Mode.WITH_PLUGIN_AS_STRING, authentication.getMode()); + assertEquals("caching_sha2_password", authentication.getPlugin()); + assertEquals("'", authentication.getPluginName().getQuoteStr()); + assertEquals("$A$005$hash", authentication.getCredential().getValue()); + assertSqlCanBeParsedAndDeparsed(sql); + } + + @Test + void parsesHexadecimalHash() throws JSQLParserException { + String sql = "CREATE USER 'app'@'%' IDENTIFIED WITH 'caching_sha2_password' " + + "AS 0x244124303035240A"; + UserAuthentication authentication = + roundTrip(CreateUser.class, sql, Dialect.MYSQL).getAccounts().get(0) + .getAuthentication(); + + assertInstanceOf(HexValue.class, authentication.getCredentialValue()); + assertNull(authentication.getCredential()); + assertEquals("0x244124303035240A", authentication.getCredentialValue().toString()); + roundTrip(CreateUser.class, "CREATE USER app IDENTIFIED WITH mysql_native_password " + + "AS X'2A3132'", Dialect.MYSQL); + } + + @ParameterizedTest + @ValueSource(strings = {"`caching_sha2_password`", "\"caching_sha2_password\"", + "caching_sha2_password"}) + void keepsThePluginQuoteSeparately(String plugin) throws JSQLParserException { + UserAuthentication authentication = roundTrip(CreateUser.class, + "CREATE USER app IDENTIFIED WITH " + plugin, Dialect.MYSQL).getAccounts().get(0) + .getAuthentication(); + + assertEquals("caching_sha2_password", authentication.getPlugin()); + assertEquals(plugin, authentication.getPluginName().toString()); + } + + @Test + void buildsPluginAuthenticationWithHash() { + UserAuthentication authentication = new UserAuthentication() + .withMode(UserAuthentication.Mode.WITH_PLUGIN_AS_STRING) + .withPluginName(new StringValue("'caching_sha2_password'")) + .withCredentialValue(new HexValue("0x2A")); + + assertEquals("IDENTIFIED WITH 'caching_sha2_password' AS 0x2A", authentication.toString()); + assertThrows(IllegalArgumentException.class, + () -> authentication.setCredentialValue(new LongValue(1))); + authentication.setPlugin("mysql_native_password"); + assertEquals("IDENTIFIED WITH mysql_native_password AS 0x2A", authentication.toString()); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE USER app IDENTIFIED BY 0x2A", + "CREATE USER app IDENTIFIED WITH caching_sha2_password BY 0x2A"}) + void acceptsHexadecimalOnlyAfterAs(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } + @Test void parsesSharedOptionsInClauseOrder() throws JSQLParserException { String sql = "CREATE USER IF NOT EXISTS 'app'@'10.0.0.%' IDENTIFIED WITH " diff --git a/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java index 455f18b6a5..d92850a0a0 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java @@ -99,6 +99,23 @@ void keepsDoubleQuotesWithoutDialect() throws JSQLParserException { assertEquals(sql, CCJSqlParserUtil.parse(sql, MYSQL).toString()); } + @ParameterizedTest + @ValueSource(strings = {"DROP USER IF EXISTS CURRENT_USER()", + "RENAME USER CURRENT_USER() TO app2", "SET PASSWORD FOR CURRENT_USER() = 'x'", + "GRANT 'reader' TO CURRENT_USER()"}) + void userListsTakeCurrentUserWithParentheses(String sql) throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed(sql, true, MYSQL); + assertEquals(sql, CCJSqlParserUtil.parse(sql, MYSQL).toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE ROLE CURRENT_USER()", "DROP ROLE CURRENT_USER()", + "SET ROLE CURRENT_USER()", "SET DEFAULT ROLE ALL TO CURRENT_USER()", + "GRANT CURRENT_USER() TO app", "ALTER USER app DEFAULT ROLE CURRENT_USER()"}) + void roleListsRejectCurrentUserWithParentheses(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + } + @org.junit.jupiter.api.Test void definerCurrentUserIsUnchanged() throws JSQLParserException { CreateView view = assertInstanceOf(CreateView.class, assertSqlCanBeParsedAndDeparsed(