From 42fb6191ba5626678479fc7108eea1b622f8f616 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 11 Nov 2026 12:25:25 +0900 Subject: [PATCH] feat: parse MySQL multi-factor authentication in CREATE USER and ALTER USER CREATE USER accepts a second and third factor after AND, and INITIAL AUTHENTICATION after a plugin without credentials. ALTER USER accepts ADD, MODIFY and DROP of a factor, once or twice per account, and the registration steps INITIATE REGISTRATION, FINISH REGISTRATION SET CHALLENGE_RESPONSE AS and UNREGISTER for one account or USER(). The factors extend the existing account model: UserAccount keeps the additional authentications and the UserFactorOperation list, and UserAuthentication keeps the initial authentication. Signed-off-by: minleejae --- .../jsqlparser/statement/alter/AlterUser.java | 5 +- .../statement/create/user/UserAccount.java | 72 ++- .../create/user/UserAuthentication.java | 28 +- .../create/user/UserFactorOperation.java | 140 +++++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 196 ++++++- src/site/sphinx/usage.rst | 22 + .../MySqlMultiFactorAuthenticationTest.java | 483 ++++++++++++++++++ 7 files changed, 929 insertions(+), 17 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/user/UserFactorOperation.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/user/MySqlMultiFactorAuthenticationTest.java diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterUser.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterUser.java index 04c9805ec..a32acdfe5 100644 --- a/src/main/java/net/sf/jsqlparser/statement/alter/AlterUser.java +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterUser.java @@ -28,7 +28,10 @@ * {@link UserOptions} are those of {@link net.sf.jsqlparser.statement.create.user.CreateUser}. * {@code ALTER USER acct DEFAULT ROLE ...} keeps its roles in * {@link UserOptions#getDefaultRoles()}. {@code ALTER USER USER() ...} names the current user with - * the unquoted account name {@code USER()}, see {@link #isUserFunction()}. + * the unquoted account name {@code USER()}, see {@link #isUserFunction()}. Multi-factor + * authentication changes ({@code ADD}, {@code MODIFY}, {@code DROP} of {@code 2 FACTOR} or + * {@code 3 FACTOR}) and the registration steps of one account are the + * {@link UserAccount#getFactorOperations()} of the account. */ public class AlterUser extends ASTNodeAccessImpl implements Statement { 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 9e08bd6a1..2606868a6 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 @@ -10,11 +10,18 @@ package net.sf.jsqlparser.statement.create.user; import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; import net.sf.jsqlparser.expression.StringValue; /** * A user and optional host together with its authentication clause. The current user may be named - * {@code CURRENT_USER} or {@code CURRENT_USER()}. + * {@code CURRENT_USER} or {@code CURRENT_USER()}. MySQL multi-factor authentication adds the second + * and third factor of {@code CREATE USER ... IDENTIFIED ... AND IDENTIFIED ...} in + * {@link #getAdditionalAuthentications()}, and the factor changes and registration steps of + * {@code ALTER USER} in {@link #getFactorOperations()}. */ public class UserAccount implements Serializable { @@ -22,6 +29,8 @@ public class UserAccount implements Serializable { private StringValue host; private UserAuthentication authentication; private boolean currentUserParentheses; + private final List additionalAuthentications = new ArrayList<>(); + private final List factorOperations = new ArrayList<>(); public StringValue getUser() { return user; @@ -56,6 +65,38 @@ public void setAuthentication(UserAuthentication authentication) { this.authentication = authentication; } + /** + * The authentication of the second and third factor, each written {@code AND IDENTIFIED ...} + * after the first one in {@code CREATE USER}; empty, never null, when none are given. + */ + public List getAdditionalAuthentications() { + return Collections.unmodifiableList(additionalAuthentications); + } + + public void setAdditionalAuthentications( + Collection additionalAuthentications) { + this.additionalAuthentications.clear(); + if (additionalAuthentications != null) { + this.additionalAuthentications.addAll(additionalAuthentications); + } + } + + /** + * The {@code ADD}, {@code MODIFY} and {@code DROP} factor changes, or the single registration + * step, of an {@code ALTER USER} account in source order; empty, never null, when none are + * given. + */ + public List getFactorOperations() { + return Collections.unmodifiableList(factorOperations); + } + + public void setFactorOperations(Collection factorOperations) { + this.factorOperations.clear(); + if (factorOperations != null) { + this.factorOperations.addAll(factorOperations); + } + } + @Override public String toString() { StringBuilder builder = new StringBuilder().append(user); @@ -68,6 +109,12 @@ public String toString() { if (authentication != null) { builder.append(" ").append(authentication); } + for (UserAuthentication additional : additionalAuthentications) { + builder.append(" AND ").append(additional); + } + for (UserFactorOperation operation : factorOperations) { + builder.append(" ").append(operation); + } return builder.toString(); } @@ -90,4 +137,27 @@ public UserAccount withAuthentication(UserAuthentication authentication) { setAuthentication(authentication); return this; } + + public UserAccount withAdditionalAuthentications( + Collection additionalAuthentications) { + setAdditionalAuthentications(additionalAuthentications); + return this; + } + + public UserAccount addAdditionalAuthentications( + UserAuthentication... additionalAuthentications) { + Collections.addAll(this.additionalAuthentications, additionalAuthentications); + return this; + } + + public UserAccount withFactorOperations( + Collection factorOperations) { + setFactorOperations(factorOperations); + return this; + } + + public UserAccount addFactorOperations(UserFactorOperation... factorOperations) { + Collections.addAll(this.factorOperations, factorOperations); + 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 ebd3f7260..d63889ccd 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 @@ -19,7 +19,9 @@ * {@code REPLACE 'current'} and {@code RETAIN CURRENT PASSWORD} to a password assignment, and * {@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. + * hexadecimal literal, as SHOW CREATE USER prints it. {@code CREATE USER} may give a passwordless + * plugin a temporary first factor, {@code IDENTIFIED WITH plugin INITIAL AUTHENTICATION IDENTIFIED + * ...}, kept in {@link #getInitialAuthentication()}. */ public class UserAuthentication implements Serializable { @@ -38,6 +40,7 @@ public boolean isPasswordAssignment() { private Expression credential; private StringValue currentCredential; private boolean retainCurrentPassword; + private UserAuthentication initialAuthentication; public Mode getMode() { return mode; @@ -111,9 +114,26 @@ public void setRetainCurrentPassword(boolean retainCurrentPassword) { this.retainCurrentPassword = retainCurrentPassword; } + /** + * The authentication after {@code INITIAL AUTHENTICATION}, which registers the device of a + * passwordless plugin, or null when omitted. MySQL writes it as {@code IDENTIFIED BY 'auth'}, + * {@code IDENTIFIED BY RANDOM PASSWORD} or {@code IDENTIFIED WITH plugin AS 'hash'}, after + * {@code IDENTIFIED WITH plugin} only. + */ + public UserAuthentication getInitialAuthentication() { + return initialAuthentication; + } + + public void setInitialAuthentication(UserAuthentication initialAuthentication) { + this.initialAuthentication = initialAuthentication; + } + @Override public String toString() { StringBuilder builder = new StringBuilder(modeToString()); + if (initialAuthentication != null) { + builder.append(" INITIAL AUTHENTICATION ").append(initialAuthentication); + } if (currentCredential != null) { builder.append(" REPLACE ").append(currentCredential); } @@ -178,4 +198,10 @@ public UserAuthentication withRetainCurrentPassword(boolean retainCurrentPasswor setRetainCurrentPassword(retainCurrentPassword); return this; } + + public UserAuthentication withInitialAuthentication( + UserAuthentication initialAuthentication) { + setInitialAuthentication(initialAuthentication); + return this; + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/UserFactorOperation.java b/src/main/java/net/sf/jsqlparser/statement/create/user/UserFactorOperation.java new file mode 100644 index 000000000..8b4a4eda1 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/UserFactorOperation.java @@ -0,0 +1,140 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +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; + +/** + * One multi-factor authentication change of a MySQL {@code ALTER USER} account: + * {@code ADD factor IDENTIFIED ...}, {@code MODIFY factor IDENTIFIED ...}, {@code DROP factor}, or + * a registration step of a passwordless device, {@code factor INITIATE REGISTRATION}, + * {@code factor FINISH REGISTRATION SET CHALLENGE_RESPONSE AS '...'} and {@code factor UNREGISTER}. + * The factor is written {@code 2 FACTOR} or {@code 3 FACTOR}; the server checks the number when it + * executes the statement. + */ +public class UserFactorOperation implements Serializable { + + public enum Operation { + ADD, MODIFY, DROP, INITIATE_REGISTRATION, FINISH_REGISTRATION, UNREGISTER; + + /** Whether an {@code IDENTIFIED} clause follows the factor, as for ADD and MODIFY. */ + public boolean hasAuthentication() { + return this == ADD || this == MODIFY; + } + + /** Whether this is a registration step, written after the factor. */ + public boolean isRegistration() { + return this == INITIATE_REGISTRATION || this == FINISH_REGISTRATION + || this == UNREGISTER; + } + } + + private Operation operation; + private int factor; + private UserAuthentication authentication; + private Expression challengeResponse; + + public UserFactorOperation() { + // for the fluent setters + } + + public UserFactorOperation(Operation operation, int factor) { + this.operation = operation; + this.factor = factor; + } + + public Operation getOperation() { + return operation; + } + + public void setOperation(Operation operation) { + this.operation = operation; + } + + /** The factor number of {@code n FACTOR}: 2 or 3 in a statement the server executes. */ + public int getFactor() { + return factor; + } + + public void setFactor(int factor) { + this.factor = factor; + } + + /** The authentication of the added or modified factor, or null for the other operations. */ + public UserAuthentication getAuthentication() { + return authentication; + } + + public void setAuthentication(UserAuthentication authentication) { + this.authentication = authentication; + } + + /** + * The value after {@code SET CHALLENGE_RESPONSE AS} of FINISH REGISTRATION: a + * {@link StringValue} or a {@link HexValue}, or null for the other operations. + */ + public Expression getChallengeResponse() { + return challengeResponse; + } + + public void setChallengeResponse(Expression challengeResponse) { + if (challengeResponse != null && !(challengeResponse instanceof StringValue) + && !(challengeResponse instanceof HexValue)) { + throw new IllegalArgumentException( + "A challenge response is a string or hexadecimal literal: " + + challengeResponse); + } + this.challengeResponse = challengeResponse; + } + + @Override + public String toString() { + String factorClause = factor + " FACTOR"; + switch (operation) { + case ADD: + case MODIFY: + return operation + " " + factorClause + " " + authentication; + case DROP: + return "DROP " + factorClause; + case INITIATE_REGISTRATION: + return factorClause + " INITIATE REGISTRATION"; + case FINISH_REGISTRATION: + return factorClause + " FINISH REGISTRATION SET CHALLENGE_RESPONSE AS " + + challengeResponse; + case UNREGISTER: + return factorClause + " UNREGISTER"; + default: + throw new IllegalStateException("Unsupported factor operation: " + operation); + } + } + + public UserFactorOperation withOperation(Operation operation) { + setOperation(operation); + return this; + } + + public UserFactorOperation withFactor(int factor) { + setFactor(factor); + return this; + } + + public UserFactorOperation withAuthentication(UserAuthentication authentication) { + setAuthentication(authentication); + return this; + } + + public UserFactorOperation withChallengeResponse(Expression challengeResponse) { + setChallengeResponse(challengeResponse); + 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 e4fcc2e76..62116a620 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -3234,8 +3234,8 @@ public class CCJSqlParser extends AbstractJSqlParser { } /** - * Whether a MySQL CREATE/ALTER USER clause starts at the offset: authentication, DEFAULT ROLE, - * REQUIRE, WITH resource limits, password and locking options, COMMENT or ATTRIBUTE. PostgreSQL + * Whether a MySQL CREATE/ALTER USER clause starts at the offset: authentication, a further + * factor after AND, a factor change or registration step, DEFAULT ROLE, REQUIRE, WITH resource limits, password and locking options, COMMENT or ATTRIBUTE. PostgreSQL * role attributes such as WITH LOGIN or PASSWORD 'x' do not match. */ private boolean isMySqlUserOptionAhead(int offset) { @@ -3244,6 +3244,14 @@ public class CCJSqlParser extends AbstractJSqlParser { case K_IDENTIFIED: case K_DISCARD: return true; + case K_AND: + return getToken(offset + 1).kind == K_IDENTIFIED; + case K_ADD: + case K_MODIFY: + case K_DROP: + return isMySqlFactorAhead(offset + 1); + case S_LONG: + return isMySqlFactorAhead(offset); case K_COMMENT: return getToken(offset + 1).kind == S_CHAR_LITERAL; case K_DEFAULT: @@ -3257,6 +3265,27 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + /** Whether a multi-factor authentication factor, n FACTOR, starts at the offset. */ + private boolean isMySqlFactorAhead(int offset) { + return getToken(offset).kind == S_LONG + && "FACTOR".equalsIgnoreCase(getToken(offset + 1).image); + } + + /** Whether ADD, MODIFY or DROP of a multi-factor authentication factor is next. */ + private boolean isMySqlFactorChangeAhead() { + int kind = getToken(1).kind; + return (kind == K_ADD || kind == K_MODIFY || kind == K_DROP) && isMySqlFactorAhead(2); + } + + /** The number of n FACTOR, which the MySQL grammar reads as an integer of at most 31 bits. */ + private static int factorNumber(Token token) throws ParseException { + try { + return Integer.parseInt(token.image); + } catch (NumberFormatException exception) { + throw new ParseException("Invalid authentication factor: " + token.image); + } + } + private boolean isMySqlResourceLimitAhead(int offset) { Token token = getToken(offset); return token.kind == S_IDENTIFIER && getToken(offset + 1).kind == S_LONG @@ -18166,17 +18195,116 @@ UserAuthentication MySqlUserAuthentication(): { return authentication; } } +/** + * A CREATE USER account: the first factor, which a plugin without credentials may follow with + * INITIAL AUTHENTICATION, and up to two further factors, each written AND IDENTIFIED ...; the + * server grammar also accepts the further factors without a first one. + */ UserAccount MySqlUserAccount(): { UserAccount account; UserAuthentication authentication = null; + UserAuthentication initial = null; } { account=MySqlAccountName() - [ authentication=MySqlUserAuthentication() { account.setAuthentication(authentication); } ] + [ + authentication=MySqlUserAuthentication() { account.setAuthentication(authentication); } + [ + LOOKAHEAD({ isKeywordAhead("INITIAL") }) + ContextualKeyword("INITIAL") ContextualKeyword("AUTHENTICATION") + initial=MySqlUserAuthentication() + { + requireAccessSyntax(authentication.getMode() == UserAuthentication.Mode.WITH_PLUGIN, + "INITIAL AUTHENTICATION requires IDENTIFIED WITH plugin"); + requireAccessSyntax(initial.getMode() == UserAuthentication.Mode.BY_PASSWORD + || initial.getMode() == UserAuthentication.Mode.BY_RANDOM_PASSWORD + || initial.getMode() == UserAuthentication.Mode.WITH_PLUGIN_AS_STRING, + "INITIAL AUTHENTICATION accepts IDENTIFIED BY or IDENTIFIED WITH plugin AS"); + authentication.setInitialAuthentication(initial); + } + ] + ] + ( + authentication=MySqlUserAuthentication() + { + requireAccessSyntax(initial == null, + "INITIAL AUTHENTICATION cannot be combined with further factors"); + requireAccessSyntax(account.getAdditionalAuthentications().size() < 2, + "An account has at most three authentication factors"); + account.addAdditionalAuthentications(authentication); + } + )* { return account; } } +/** The factor of a multi-factor authentication clause: 2 FACTOR or 3 FACTOR. */ +int MySqlAuthenticationFactor(): +{ + Token number; +} +{ + number= ContextualKeyword("FACTOR") + { return factorNumber(number); } +} + +/** ALTER USER: ADD factor IDENTIFIED ..., MODIFY factor IDENTIFIED ... or DROP factor. */ +UserFactorOperation MySqlFactorChange(): +{ + UserFactorOperation.Operation operation; + int factor; + UserAuthentication authentication = null; +} +{ + ( + { operation = UserFactorOperation.Operation.ADD; } + | + { operation = UserFactorOperation.Operation.MODIFY; } + | + { operation = UserFactorOperation.Operation.DROP; } + ) + factor=MySqlAuthenticationFactor() + [ authentication=MySqlUserAuthentication() ] + { + requireAccessSyntax(operation.hasAuthentication() == (authentication != null), + "ADD and MODIFY of a factor require IDENTIFIED, DROP does not accept it"); + return new UserFactorOperation(operation, factor).withAuthentication(authentication); + } +} + +/** + * ALTER USER registration of a passwordless device: factor INITIATE REGISTRATION, factor FINISH + * REGISTRATION SET CHALLENGE_RESPONSE AS 'response', or factor UNREGISTER. + */ +UserFactorOperation MySqlFactorRegistration(): +{ + UserFactorOperation operation = new UserFactorOperation(); + int factor; + Token response; +} +{ + factor=MySqlAuthenticationFactor() { operation.setFactor(factor); } + ( + LOOKAHEAD({ isKeywordAhead("INITIATE") }) + ContextualKeyword("INITIATE") ContextualKeyword("REGISTRATION") + { operation.setOperation(UserFactorOperation.Operation.INITIATE_REGISTRATION); } + | + LOOKAHEAD({ isKeywordAhead("FINISH") }) + ContextualKeyword("FINISH") ContextualKeyword("REGISTRATION") + ContextualKeyword("CHALLENGE_RESPONSE") + { operation.setOperation(UserFactorOperation.Operation.FINISH_REGISTRATION); } + ( + response= { operation.setChallengeResponse(stringValue(response)); } + | + response= { operation.setChallengeResponse(new HexValue(response.image)); } + ) + | + ContextualKeyword("UNREGISTER") + { operation.setOperation(UserFactorOperation.Operation.UNREGISTER); } + ) + { return operation; } +} + CreateUser CreateUser(): { CreateUser createUser = new CreateUser(); @@ -18403,29 +18531,49 @@ UserAuthentication MySqlAlterUserAuthentication(): { return authentication; } } +/** + * An ALTER USER account with an authentication change, or with one or two factor changes of the + * same kind: ADD ... [ADD ...], MODIFY ... [MODIFY ...] or DROP ... [DROP ...]. + */ UserAccount MySqlAlterUserAccount(): { UserAccount account; UserAuthentication authentication; + UserFactorOperation change; + UserFactorOperation second; } { account=MySqlAccountName() [ LOOKAHEAD({ getToken(1).kind == K_IDENTIFIED || getToken(1).kind == K_DISCARD }) authentication=MySqlAlterUserAuthentication() { account.setAuthentication(authentication); } + | + LOOKAHEAD({ isMySqlFactorChangeAhead() }) + change=MySqlFactorChange() { account.addFactorOperations(change); } + [ + LOOKAHEAD({ isMySqlFactorChangeAhead() }) + second=MySqlFactorChange() + { + requireAccessSyntax(second.getOperation() == change.getOperation(), + "Factor changes of one account repeat the same operation"); + account.addFactorOperations(second); + } + ] ] { return account; } } /** - * MySQL ALTER USER: accounts with authentication changes and the options shared with CREATE USER, - * USER() with a password change, or one account with DEFAULT ROLE. + * MySQL ALTER USER: accounts with authentication or factor changes and the options shared with + * CREATE USER, USER() with a password change, one account with DEFAULT ROLE, or one account or + * USER() with a registration step of multi-factor authentication. */ AlterUser AlterUser(): { AlterUser alterUser = new AlterUser(); UserAccount account; UserAuthentication authentication; + UserFactorOperation registration; RoleSelection roles; } { @@ -18433,21 +18581,41 @@ AlterUser AlterUser(): [ LOOKAHEAD(2) { alterUser.setIfExists(true); } ] ( LOOKAHEAD({ getToken(1).kind == K_USER && "(".equals(getToken(2).image) }) - "(" ")" authentication=MySqlAlterUserAuthentication() - { - requireAccessSyntax(authentication.getMode() == UserAuthentication.Mode.BY_PASSWORD - || authentication.getMode() == UserAuthentication.Mode.BY_RANDOM_PASSWORD - || authentication.getMode() == UserAuthentication.Mode.DISCARD_OLD_PASSWORD, - "USER() accepts IDENTIFIED BY or DISCARD OLD PASSWORD"); - alterUser.withUserFunction(authentication); - } + "(" ")" + ( + LOOKAHEAD({ isMySqlFactorAhead(1) }) + registration=MySqlFactorRegistration() + { + alterUser.withUserFunction(null); + alterUser.getAccounts().get(0).addFactorOperations(registration); + } + | + authentication=MySqlAlterUserAuthentication() + { + requireAccessSyntax(authentication.getMode() == UserAuthentication.Mode.BY_PASSWORD + || authentication.getMode() == UserAuthentication.Mode.BY_RANDOM_PASSWORD + || authentication.getMode() == UserAuthentication.Mode.DISCARD_OLD_PASSWORD, + "USER() accepts IDENTIFIED BY or DISCARD OLD PASSWORD"); + alterUser.withUserFunction(authentication); + } + ) | account=MySqlAlterUserAccount() { alterUser.addAccounts(account); } ( + LOOKAHEAD({ isMySqlFactorAhead(1) }) + registration=MySqlFactorRegistration() + { + requireAccessSyntax(account.getAuthentication() == null + && account.getFactorOperations().isEmpty(), + "A registration step cannot follow an authentication change"); + account.addFactorOperations(registration); + } + | LOOKAHEAD({ getToken(1).kind == K_DEFAULT && "ROLE".equalsIgnoreCase(getToken(2).image) }) ContextualKeyword("ROLE") roles=MySqlRoleSelection(false) { - requireAccessSyntax(account.getAuthentication() == null, + requireAccessSyntax(account.getAuthentication() == null + && account.getFactorOperations().isEmpty(), "DEFAULT ROLE cannot follow an authentication change"); alterUser.getOptions().setDefaultRoles(roles); } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 9d8943000..ca57e80a1 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2702,6 +2702,28 @@ clauses stay omitted. ``UserAuthentication`` adds ``ALTER USER``'s ``REPLACE 'cu alter.getAccounts().get(0).getAuthentication().isRetainCurrentPassword(); // true alter.getOptions().getPasswordLockOptions(); // [PASSWORD EXPIRE INTERVAL 90 DAY, ACCOUNT LOCK] +MySQL multi-factor authentication is part of the same model. ``CREATE USER`` keeps the +second and third factor of ``IDENTIFIED ... AND IDENTIFIED ... [AND IDENTIFIED ...]`` in +``UserAccount.getAdditionalAuthentications()``, and the temporary first factor of a +passwordless plugin, ``IDENTIFIED WITH plugin INITIAL AUTHENTICATION IDENTIFIED ...``, in +``UserAuthentication.getInitialAuthentication()``. ``ALTER USER`` keeps +``{ADD | MODIFY} n FACTOR IDENTIFIED ...``, ``DROP n FACTOR`` and the registration steps +``n FACTOR INITIATE REGISTRATION``, ``n FACTOR FINISH REGISTRATION SET CHALLENGE_RESPONSE AS +'...'`` and ``n FACTOR UNREGISTER`` as ``UserFactorOperation`` objects (operation, factor +number, authentication or challenge response) in ``UserAccount.getFactorOperations()``. As +on the server, an account takes one or two changes of the same kind, and the factor number +is checked only when the statement runs. + +.. code-block:: java + + AlterUser factors = (AlterUser) CCJSqlParserUtil.parse( + "ALTER USER app ADD 2 FACTOR IDENTIFIED WITH authentication_ldap_sasl AS 'uid=app' " + + "ADD 3 FACTOR IDENTIFIED WITH authentication_webauthn"); + UserFactorOperation second = factors.getAccounts().get(0).getFactorOperations().get(0); + second.getOperation(); // ADD + second.getFactor(); // 2 + second.getAuthentication().getPlugin(); // authentication_ldap_sasl + ``ALTER USER USER() ...`` keeps ``USER()`` as the unquoted account name, as ``DEFINER = CURRENT_USER`` does; ``isUserFunction()`` identifies it. ``ALTER USER acct DEFAULT ROLE {NONE | ALL | role, ...}`` stores a ``RoleSelection`` in diff --git a/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlMultiFactorAuthenticationTest.java b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlMultiFactorAuthenticationTest.java new file mode 100644 index 000000000..aace7823b --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlMultiFactorAuthenticationTest.java @@ -0,0 +1,483 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.user; + +import static net.sf.jsqlparser.statement.create.user.CreateUserTest.roundTrip; +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +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; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.alter.AlterUser; +import net.sf.jsqlparser.statement.create.user.UserAuthentication.Mode; +import net.sf.jsqlparser.statement.create.user.UserFactorOperation.Operation; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** MySQL 8.4 multi-factor authentication clauses of CREATE USER and ALTER USER. */ +class MySqlMultiFactorAuthenticationTest { + + private static List operations(UserAccount account) { + return account.getFactorOperations().stream() + .map(operation -> operation.getOperation() + ":" + operation.getFactor()) + .collect(Collectors.toList()); + } + + private static UserAccount alteredAccount(String sql) throws JSQLParserException { + AlterUser alterUser = roundTrip(AlterUser.class, sql, Dialect.MYSQL); + assertEquals(1, alterUser.getAccounts().size()); + return alterUser.getAccounts().get(0); + } + + @Test + void createsAnAccountWithThreeFactors() throws JSQLParserException { + CreateUser createUser = roundTrip(CreateUser.class, + "CREATE USER u1 IDENTIFIED WITH caching_sha2_password BY 'p' " + + "AND IDENTIFIED WITH authentication_ldap_sasl AS 'uid=u' " + + "AND IDENTIFIED WITH authentication_webauthn", + Dialect.MYSQL); + + UserAccount account = createUser.getAccounts().get(0); + assertEquals(Mode.WITH_PLUGIN_BY_PASSWORD, account.getAuthentication().getMode()); + assertNull(account.getAuthentication().getInitialAuthentication()); + List additional = account.getAdditionalAuthentications(); + assertEquals(2, additional.size()); + assertEquals(Mode.WITH_PLUGIN_AS_STRING, additional.get(0).getMode()); + assertEquals("authentication_ldap_sasl", additional.get(0).getPlugin()); + assertEquals("uid=u", additional.get(0).getCredential().getValue()); + assertEquals(Mode.WITH_PLUGIN, additional.get(1).getMode()); + assertEquals("authentication_webauthn", additional.get(1).getPlugin()); + assertTrue(account.getFactorOperations().isEmpty()); + assertThrows(UnsupportedOperationException.class, () -> additional.add(null)); + } + + @Test + void createsFurtherFactorsWithoutAFirstOne() throws JSQLParserException { + CreateUser createUser = roundTrip(CreateUser.class, + "CREATE USER u3 AND IDENTIFIED BY 'q' AND IDENTIFIED BY RANDOM PASSWORD", + Dialect.MYSQL); + + UserAccount account = createUser.getAccounts().get(0); + assertNull(account.getAuthentication()); + assertEquals(Arrays.asList(Mode.BY_PASSWORD, Mode.BY_RANDOM_PASSWORD), + account.getAdditionalAuthentications().stream().map(UserAuthentication::getMode) + .collect(Collectors.toList())); + } + + @Test + void keepsFactorsPerAccountBeforeTheOptions() throws JSQLParserException { + CreateUser createUser = roundTrip(CreateUser.class, + "CREATE USER IF NOT EXISTS 'u3'@'localhost' IDENTIFIED BY 'p' AND IDENTIFIED BY 'q', " + + "u4 IDENTIFIED BY 'p' AND IDENTIFIED WITH 'authentication_ldap_sasl' AS 0x41, " + + "u5 DEFAULT ROLE r1 REQUIRE SSL ACCOUNT LOCK", + Dialect.MYSQL); + + assertEquals(3, createUser.getAccounts().size()); + assertEquals(1, createUser.getAccounts().get(0).getAdditionalAuthentications().size()); + UserAuthentication second = + createUser.getAccounts().get(1).getAdditionalAuthentications().get(0); + assertEquals("'", second.getPluginName().getQuoteStr()); + assertInstanceOf(HexValue.class, second.getCredentialValue()); + assertTrue(createUser.getAccounts().get(2).getAdditionalAuthentications().isEmpty()); + assertEquals("DEFAULT ROLE r1 REQUIRE SSL ACCOUNT LOCK", + createUser.getOptions().toString()); + } + + @Test + void createsAPasswordlessAccountWithInitialAuthentication() throws JSQLParserException { + CreateUser createUser = roundTrip(CreateUser.class, + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED BY 'p'", + Dialect.MYSQL); + + UserAuthentication authentication = createUser.getAccounts().get(0).getAuthentication(); + assertEquals(Mode.WITH_PLUGIN, authentication.getMode()); + assertEquals("authentication_webauthn", authentication.getPlugin()); + UserAuthentication initial = authentication.getInitialAuthentication(); + assertEquals(Mode.BY_PASSWORD, initial.getMode()); + assertEquals("p", initial.getCredential().getValue()); + assertNull(initial.getInitialAuthentication()); + + assertEquals(Mode.BY_RANDOM_PASSWORD, + roundTrip(CreateUser.class, + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED BY RANDOM PASSWORD, " + + "u4 IDENTIFIED BY 'x' PASSWORD EXPIRE", + Dialect.MYSQL).getAccounts().get(0).getAuthentication() + .getInitialAuthentication().getMode()); + UserAuthentication hash = roundTrip(CreateUser.class, + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED WITH caching_sha2_password AS 0x41", + Dialect.MYSQL).getAccounts().get(0).getAuthentication() + .getInitialAuthentication(); + assertEquals(Mode.WITH_PLUGIN_AS_STRING, hash.getMode()); + assertEquals("caching_sha2_password", hash.getPlugin()); + assertInstanceOf(HexValue.class, hash.getCredentialValue()); + } + + @Test + void addsFactors() throws JSQLParserException { + UserAccount account = alteredAccount( + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED WITH authentication_ldap_sasl AS 'uid=u' " + + "ADD 3 FACTOR IDENTIFIED BY RANDOM PASSWORD"); + + assertNull(account.getAuthentication()); + assertTrue(account.getAdditionalAuthentications().isEmpty()); + assertEquals(Arrays.asList("ADD:2", "ADD:3"), operations(account)); + UserFactorOperation first = account.getFactorOperations().get(0); + assertTrue(first.getOperation().hasAuthentication()); + assertFalse(first.getOperation().isRegistration()); + assertEquals(Mode.WITH_PLUGIN_AS_STRING, first.getAuthentication().getMode()); + assertEquals("authentication_ldap_sasl", first.getAuthentication().getPlugin()); + assertNull(first.getChallengeResponse()); + assertEquals(Mode.BY_RANDOM_PASSWORD, + account.getFactorOperations().get(1).getAuthentication().getMode()); + } + + @Test + void modifiesFactors() throws JSQLParserException { + UserAccount account = alteredAccount( + "ALTER USER u1 MODIFY 2 FACTOR IDENTIFIED WITH authentication_webauthn"); + + assertEquals(Arrays.asList("MODIFY:2"), operations(account)); + assertEquals(Mode.WITH_PLUGIN, + account.getFactorOperations().get(0).getAuthentication().getMode()); + + assertEquals(Arrays.asList("MODIFY:2", "MODIFY:3"), + operations(alteredAccount("ALTER USER u1 MODIFY 2 FACTOR IDENTIFIED BY 'p' " + + "MODIFY 3 FACTOR IDENTIFIED WITH authentication_webauthn BY 'x'"))); + } + + @Test + void dropsFactors() throws JSQLParserException { + UserAccount account = alteredAccount("ALTER USER u1 DROP 2 FACTOR DROP 3 FACTOR"); + + assertEquals(Arrays.asList("DROP:2", "DROP:3"), operations(account)); + UserFactorOperation drop = account.getFactorOperations().get(0); + assertFalse(drop.getOperation().hasAuthentication()); + assertNull(drop.getAuthentication()); + assertEquals(Arrays.asList("DROP:3"), + operations(alteredAccount("ALTER USER CURRENT_USER() DROP 3 FACTOR"))); + } + + @Test + void changesFactorsOfSeveralAccountsBeforeTheOptions() throws JSQLParserException { + AlterUser alterUser = roundTrip(AlterUser.class, + "ALTER USER IF EXISTS 'u1'@'localhost' ADD 2 FACTOR IDENTIFIED BY 'p', " + + "u2 IDENTIFIED BY 'x' REPLACE 'y', u3 DROP 2 FACTOR DROP 3 FACTOR, u4 " + + "REQUIRE NONE PASSWORD EXPIRE NEVER COMMENT 'c'", + Dialect.MYSQL); + + assertTrue(alterUser.isIfExists()); + List accounts = alterUser.getAccounts(); + assertEquals(Arrays.asList("ADD:2"), operations(accounts.get(0))); + assertTrue(accounts.get(1).getFactorOperations().isEmpty()); + assertEquals("y", accounts.get(1).getAuthentication().getCurrentCredential().getValue()); + assertEquals(Arrays.asList("DROP:2", "DROP:3"), operations(accounts.get(2))); + assertTrue(accounts.get(3).getFactorOperations().isEmpty()); + assertEquals("REQUIRE NONE PASSWORD EXPIRE NEVER COMMENT 'c'", + alterUser.getOptions().toString()); + } + + @Test + void registersADevice() throws JSQLParserException { + UserFactorOperation initiate = + alteredAccount("ALTER USER u1 2 FACTOR INITIATE REGISTRATION") + .getFactorOperations().get(0); + assertEquals(Operation.INITIATE_REGISTRATION, initiate.getOperation()); + assertEquals(2, initiate.getFactor()); + assertTrue(initiate.getOperation().isRegistration()); + assertNull(initiate.getAuthentication()); + assertNull(initiate.getChallengeResponse()); + + UserFactorOperation finish = alteredAccount( + "ALTER USER 'u1'@'localhost' 3 FACTOR FINISH REGISTRATION " + + "SET CHALLENGE_RESPONSE AS 'x'") + .getFactorOperations().get(0); + assertEquals(Operation.FINISH_REGISTRATION, finish.getOperation()); + assertEquals(3, finish.getFactor()); + assertEquals("x", ((StringValue) finish.getChallengeResponse()).getValue()); + assertInstanceOf(HexValue.class, alteredAccount( + "ALTER USER u1 2 FACTOR FINISH REGISTRATION SET CHALLENGE_RESPONSE AS 0x41") + .getFactorOperations().get(0).getChallengeResponse()); + + AlterUser unregister = roundTrip(AlterUser.class, + "ALTER USER IF EXISTS u1 2 FACTOR UNREGISTER", Dialect.MYSQL); + assertTrue(unregister.isIfExists()); + assertTrue(unregister.getOptions().isEmpty()); + assertEquals(Arrays.asList("UNREGISTER:2"), operations(unregister.getAccounts().get(0))); + } + + @ParameterizedTest + @ValueSource(strings = { + "ALTER USER USER() 2 FACTOR INITIATE REGISTRATION", + "ALTER USER USER() 2 FACTOR FINISH REGISTRATION SET CHALLENGE_RESPONSE AS 'x'", + "ALTER USER USER() 3 FACTOR UNREGISTER" + }) + void registersADeviceOfTheCurrentUser(String sql) throws JSQLParserException { + AlterUser alterUser = roundTrip(AlterUser.class, sql, Dialect.MYSQL); + + assertTrue(alterUser.isUserFunction()); + UserAccount account = alterUser.getAccounts().get(0); + assertNull(account.getAuthentication()); + assertEquals(1, account.getFactorOperations().size()); + assertTrue(account.getFactorOperations().get(0).getOperation().isRegistration()); + + assertEquals(1, roundTrip(AlterUser.class, sql.replace("USER()", "CURRENT_USER()"), + Dialect.MYSQL).getAccounts().get(0).getFactorOperations().size()); + assertEquals(1, roundTrip(AlterUser.class, sql.replace("USER()", "CURRENT_USER"), + Dialect.MYSQL).getAccounts().get(0).getFactorOperations().size()); + } + + @Test + void leavesTheFactorNumberToTheServer() throws JSQLParserException { + // MySQL reads any integer here and reports "Incorrect nth factor value" on execution + assertEquals(Arrays.asList("ADD:1"), + operations(alteredAccount("ALTER USER u1 ADD 1 FACTOR IDENTIFIED BY 'p'"))); + assertEquals(Arrays.asList("DROP:2147483647"), + operations(alteredAccount("ALTER USER u1 DROP 2147483647 FACTOR"))); + assertEquals(Arrays.asList("UNREGISTER:4"), + operations(alteredAccount("ALTER USER u1 4 FACTOR UNREGISTER"))); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE USER u1 IDENTIFIED BY 'p' AND IDENTIFIED WITH authentication_ldap_sasl BY 'q'", + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED WITH caching_sha2_password AS 'h'", + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED WITH authentication_ldap_sasl BY RANDOM PASSWORD", + "ALTER USER u1 MODIFY 2 FACTOR IDENTIFIED BY 'p' MODIFY 3 FACTOR IDENTIFIED BY 'q'", + "ALTER USER u1 DROP 2 FACTOR, u2 DROP 3 FACTOR ACCOUNT UNLOCK", + "ALTER USER u1 2 FACTOR INITIATE REGISTRATION", + "ALTER USER u1 2 FACTOR FINISH REGISTRATION SET CHALLENGE_RESPONSE AS 'x'", + "ALTER USER u1 2 FACTOR UNREGISTER" + }) + void parsesInDefaultModeAndMariaDb(String sql) throws JSQLParserException { + Class type = + sql.startsWith("CREATE") ? CreateUser.class : AlterUser.class; + roundTrip(type, sql, Dialect.MARIADB); + assertInstanceOf(type, assertSqlCanBeParsedAndDeparsed(sql, false)); + assertEquals(sql.toLowerCase(), assertInstanceOf(type, + CCJSqlParserUtil.parse(sql.toLowerCase())).toString().toLowerCase()); + } + + @Test + void endsAtTheStatementBoundary() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "ALTER USER u1 2 FACTOR INITIATE REGISTRATION; " + + "ALTER USER u1 DROP 2 FACTOR DROP 3 FACTOR; " + + "CREATE USER u2 IDENTIFIED BY 'p' AND IDENTIFIED BY 'q'; " + + "CREATE USER u3 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED BY RANDOM PASSWORD; SELECT 1", + parser -> parser.withDialect(Dialect.MYSQL)); + + assertEquals(5, statements.size()); + assertInstanceOf(AlterUser.class, statements.get(0)); + assertInstanceOf(AlterUser.class, statements.get(1)); + assertInstanceOf(CreateUser.class, statements.get(2)); + assertInstanceOf(CreateUser.class, statements.get(3)); + assertInstanceOf(PlainSelect.class, statements.get(4)); + } + + @Test + void buildsAndDeparses() { + CreateUser createUser = new CreateUser().addAccounts( + new UserAccount().withUser(new StringValue("app").setQuoteStr("")) + .withAuthentication(new UserAuthentication().withMode(Mode.BY_PASSWORD) + .withCredential(new StringValue("p"))) + .addAdditionalAuthentications(new UserAuthentication() + .withMode(Mode.WITH_PLUGIN).withPlugin("authentication_webauthn")), + new UserAccount().withUser(new StringValue("key").setQuoteStr("")) + .withAuthentication(new UserAuthentication().withMode(Mode.WITH_PLUGIN) + .withPlugin("authentication_webauthn").withInitialAuthentication( + new UserAuthentication() + .withMode(Mode.BY_RANDOM_PASSWORD)))); + assertDeparse(createUser, + "CREATE USER app IDENTIFIED BY 'p' AND IDENTIFIED WITH authentication_webauthn, " + + "key IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED BY RANDOM PASSWORD"); + + UserAccount account = new UserAccount().withUser(new StringValue("app").setQuoteStr("")) + .addFactorOperations(new UserFactorOperation(Operation.DROP, 2)); + AlterUser alterUser = new AlterUser().addAccounts(account); + assertDeparse(alterUser, "ALTER USER app DROP 2 FACTOR"); + + account.withFactorOperations(Arrays.asList( + new UserFactorOperation().withOperation(Operation.MODIFY).withFactor(3) + .withAuthentication(new UserAuthentication().withMode(Mode.BY_PASSWORD) + .withCredential(new StringValue("p"))))); + assertDeparse(alterUser, "ALTER USER app MODIFY 3 FACTOR IDENTIFIED BY 'p'"); + + account.withFactorOperations(Arrays.asList( + new UserFactorOperation(Operation.FINISH_REGISTRATION, 2) + .withChallengeResponse(new HexValue("0x41")))); + assertDeparse(alterUser, + "ALTER USER app 2 FACTOR FINISH REGISTRATION SET CHALLENGE_RESPONSE AS 0x41"); + + account.setFactorOperations(null); + account.withAdditionalAuthentications(null); + assertEquals("ALTER USER app", alterUser.toString()); + UserFactorOperation operation = new UserFactorOperation(Operation.UNREGISTER, 2); + assertThrows(IllegalArgumentException.class, + () -> operation.setChallengeResponse(new LongValue(1))); + } + + @Test + void reportsSchemaChangeWithoutTables() throws JSQLParserException { + for (String sql : Arrays.asList( + "CREATE USER u1 IDENTIFIED BY 'p' AND IDENTIFIED BY 'q'", + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 2 FACTOR UNREGISTER")) { + Statement statement = + CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL)); + + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SCHEMA); + assertThat(new TablesNamesFinder().getTables(statement)).isEmpty(); + assertThat(Validation.validate(Arrays.asList(DatabaseType.MYSQL), sql)).isEmpty(); + String[] visited = new String[1]; + statement.accept(new StatementVisitorAdapter() { + @Override + public Void visit(CreateUser createUser, S context) { + visited[0] = createUser.toString(); + return null; + } + + @Override + public Void visit(AlterUser alterUser, S context) { + visited[0] = alterUser.toString(); + return null; + } + }, null); + assertEquals(sql, visited[0]); + } + } + + @ParameterizedTest + @ValueSource(strings = { + // CREATE USER: at most three factors, INITIAL AUTHENTICATION only after a bare plugin + "CREATE USER u1 IDENTIFIED BY 'p' AND IDENTIFIED BY 'q' AND IDENTIFIED BY 'r' " + + "AND IDENTIFIED BY 's'", + "CREATE USER u1 AND IDENTIFIED BY 'p' AND IDENTIFIED BY 'q' AND IDENTIFIED BY 'r'", + "CREATE USER u1 IDENTIFIED BY 'p' AND BY 'q'", + "CREATE USER u1 IDENTIFIED BY 'p' AND", + "CREATE USER u1 IDENTIFIED BY 'p', IDENTIFIED BY 'q'", + "CREATE USER u1 IDENTIFIED BY 'p' AND DISCARD OLD PASSWORD", + "CREATE USER u1 IDENTIFIED BY 'p' ADD 2 FACTOR IDENTIFIED BY 'q'", + "CREATE USER u1 ADD 2 FACTOR IDENTIFIED BY 'q'", + "CREATE USER u1 2 FACTOR INITIATE REGISTRATION", + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED WITH caching_sha2_password", + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED WITH caching_sha2_password BY 'p'", + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn BY 'x' " + + "INITIAL AUTHENTICATION IDENTIFIED BY 'p'", + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn AS 'x' " + + "INITIAL AUTHENTICATION IDENTIFIED BY 'p'", + "CREATE USER u2 IDENTIFIED BY 'x' INITIAL AUTHENTICATION IDENTIFIED BY 'p'", + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED BY 'p' AND IDENTIFIED BY 'q'", + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED BY 'p' " + + "INITIAL AUTHENTICATION IDENTIFIED BY 'q'", + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn INITIAL AUTHENTICATION BY 'p'", + "CREATE USER u2 IDENTIFIED WITH authentication_webauthn INITIAL IDENTIFIED BY 'p'", + "CREATE USER u2 INITIAL AUTHENTICATION IDENTIFIED BY 'p'", + // ALTER USER: one or two changes of the same kind per account + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED BY 'p' ADD 3 FACTOR IDENTIFIED BY 'q' " + + "ADD 3 FACTOR IDENTIFIED BY 'q'", + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED BY 'p' 3 FACTOR IDENTIFIED BY 'q'", + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED BY 'p', 3 FACTOR IDENTIFIED BY 'q'", + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED BY 'p' MODIFY 3 FACTOR IDENTIFIED BY 'q'", + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED BY 'p' DROP 3 FACTOR", + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED BY 'p' REPLACE 'x'", + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED BY 'p' RETAIN CURRENT PASSWORD", + "ALTER USER u1 ADD 2 FACTOR IDENTIFIED BY 'p' AND IDENTIFIED BY 'q'", + "ALTER USER u1 ADD 2 FACTOR", + "ALTER USER u1 ADD 2 FACTOR DISCARD OLD PASSWORD", + "ALTER USER u1 ADD FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 ADD 2.0 FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 ADD '2' FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 ADD 0x2 FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 ADD 2147483648 FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 ADD 99999999999999999999 FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 ADD 2 FACTORS IDENTIFIED BY 'p'", + "ALTER USER u1 MODIFY 2 FACTOR", + "ALTER USER u1 MODIFY 2 FACTOR IDENTIFIED BY 'p' ADD 3 FACTOR IDENTIFIED BY 'q'", + "ALTER USER u1 MODIFY 2 FACTOR IDENTIFIED BY 'p' MODIFY 3 FACTOR IDENTIFIED BY 'p' " + + "MODIFY 3 FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 DROP 2 FACTOR 3 FACTOR", + "ALTER USER u1 DROP 2 FACTOR, 3 FACTOR", + "ALTER USER u1 DROP 2 FACTOR DROP 3 FACTOR DROP 3 FACTOR", + "ALTER USER u1 DROP 2 FACTOR ADD 3 FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 DROP 2 FACTOR IDENTIFIED BY 'p'", + "ALTER USER u1 DROP FACTOR", + "ALTER USER u1 DROP 2", + "ALTER USER u1 IDENTIFIED BY 'p' ADD 2 FACTOR IDENTIFIED BY 'q'", + "ALTER USER u1 IDENTIFIED BY 'p' AND IDENTIFIED BY 'q'", + "ALTER USER u1 IDENTIFIED WITH authentication_webauthn " + + "INITIAL AUTHENTICATION IDENTIFIED BY 'p'", + "ALTER USER u1 DROP 2 FACTOR DEFAULT ROLE r1", + "ALTER USER USER() ADD 2 FACTOR IDENTIFIED BY 'p'", + "ALTER USER USER() DROP 2 FACTOR", + // registration: one account, one step, nothing else + "ALTER USER u1 2 FACTOR FINISH REGISTRATION SET CHALLENGE_RESPONSE = 'x'", + "ALTER USER u1 2 FACTOR FINISH REGISTRATION SET CHALLENGE_RESPONSE 'x'", + "ALTER USER u1 2 FACTOR FINISH REGISTRATION SET CHALLENGE_RESPONSE AS 1", + "ALTER USER u1 2 FACTOR FINISH REGISTRATION", + "ALTER USER u1 2 FACTOR INITIATE REGISTRATION SET CHALLENGE_RESPONSE AS 'x'", + "ALTER USER u1 FACTOR UNREGISTER", + "ALTER USER u1 2 FACTOR REGISTER", + "ALTER USER u1 2 FACTOR INITIATE", + "ALTER USER u1 2 FACTOR", + "ALTER USER u1 2 FACTOR UNREGISTER, u2 2 FACTOR UNREGISTER", + "ALTER USER u1, u2 2 FACTOR UNREGISTER", + "ALTER USER u1 2 FACTOR UNREGISTER ACCOUNT LOCK", + "ALTER USER u1 2 FACTOR UNREGISTER 3 FACTOR UNREGISTER", + "ALTER USER u1 IDENTIFIED BY 'p' 2 FACTOR UNREGISTER", + "ALTER USER u1 DROP 2 FACTOR 3 FACTOR UNREGISTER", + "ALTER USER USER() IDENTIFIED BY 'p' 2 FACTOR UNREGISTER" + }) + void rejectsSyntaxOutsideTheStatementDefinition(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } + + @Test + void factorWordsRemainIdentifiers() throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed("SELECT factor, initial, authentication, initiate, " + + "registration, finish, unregister, challenge_response FROM t"); + assertSqlCanBeParsedAndDeparsed("CREATE TABLE factor (initial INT, registration INT)"); + } +}