Skip to content
This repository was archived by the owner on Jul 6, 2023. It is now read-only.

Commit 547254b

Browse files
committed
Support for changing an expired password
If the server responds with credentials expired upon first connection, prompt the user for a new password and execute a password change command toward the system database. Then retry to connect with the original database.
1 parent 58a24de commit 547254b

File tree

9 files changed

+280
-57
lines changed

9 files changed

+280
-57
lines changed

cypher-shell/src/integration-test/java/org/neo4j/shell/MainIntegrationTest.java

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.io.FileNotFoundException;
1111
import java.io.InputStream;
1212
import java.io.PrintStream;
13+
import java.nio.ByteBuffer;
1314

1415
import org.neo4j.driver.exceptions.ClientException;
1516
import org.neo4j.driver.exceptions.ServiceUnavailableException;
@@ -26,6 +27,7 @@
2627
import static java.lang.String.format;
2728
import static org.hamcrest.CoreMatchers.isA;
2829
import static org.junit.Assert.assertEquals;
30+
import static org.junit.Assert.assertNull;
2931
import static org.junit.Assert.assertTrue;
3032
import static org.junit.Assert.fail;
3133
import static org.mockito.Matchers.any;
@@ -53,22 +55,26 @@ private static class ShellAndConnection
5355
private String inputString = String.format( "neo4j%nneo%n" );
5456
private ByteArrayOutputStream baos;
5557
private ConnectionConfig connectionConfig;
58+
private CliArgs cliArgs;
5659
private CypherShell shell;
5760
private Main main;
5861
private PrintStream printStream;
5962
private InputStream inputStream;
63+
private ByteBuffer inputBuffer;
6064

6165
@Before
6266
public void setup() {
6367
// given
64-
inputStream = new ByteArrayInputStream(inputString.getBytes());
68+
inputBuffer = ByteBuffer.allocate(256);
69+
inputBuffer.put(inputString.getBytes());
70+
inputStream = new ByteArrayInputStream(inputBuffer.array());
6571

6672
baos = new ByteArrayOutputStream();
6773
printStream = new PrintStream(baos);
6874

6975
main = new Main(inputStream, printStream);
7076

71-
CliArgs cliArgs = new CliArgs();
77+
cliArgs = new CliArgs();
7278
cliArgs.setUsername("", "");
7379
cliArgs.setPassword("", "");
7480

@@ -77,6 +83,13 @@ public void setup() {
7783
connectionConfig = sac.connectionConfig;
7884
}
7985

86+
private void ensureUser() throws Exception {
87+
shell.execute(":use " + DatabaseManager.SYSTEM_DB_NAME);
88+
shell.execute("CREATE OR REPLACE USER foo SET PASSWORD 'pass';");
89+
shell.execute("GRANT ROLE reader TO foo;");
90+
shell.execute(":use");
91+
}
92+
8093
@Test
8194
public void promptsOnWrongAuthenticationIfInteractive() throws Exception {
8295
// when
@@ -94,6 +107,47 @@ public void promptsOnWrongAuthenticationIfInteractive() throws Exception {
94107
assertEquals("neo", connectionConfig.password());
95108
}
96109

110+
@Test
111+
public void promptsOnPasswordChangeRequired() throws Exception {
112+
shell.setCommandHelper(new CommandHelper(mock(Logger.class), Historian.empty, shell));
113+
inputBuffer.put(String.format("foo%npass%nnewpass%n").getBytes());
114+
115+
assertEquals("", connectionConfig.username());
116+
assertEquals("", connectionConfig.password());
117+
118+
// when
119+
main.connectMaybeInteractively(shell, connectionConfig, true, true);
120+
121+
// then
122+
// should be connected
123+
assertTrue(shell.isConnected());
124+
// should have prompted and set the username and password
125+
String expectedLoginOutput = format( "username: neo4j%npassword: ***%n" );
126+
assertEquals(expectedLoginOutput, baos.toString());
127+
assertEquals("neo4j", connectionConfig.username());
128+
assertEquals("neo", connectionConfig.password());
129+
130+
// Create a new user
131+
ensureUser();
132+
shell.disconnect();
133+
134+
connectionConfig = getConnectionConfig(cliArgs);
135+
assertEquals("", connectionConfig.username());
136+
assertEquals("", connectionConfig.password());
137+
138+
// when
139+
main.connectMaybeInteractively(shell, connectionConfig, true, true);
140+
141+
// then
142+
assertTrue(shell.isConnected());
143+
// should have prompted to change the password
144+
String expectedChangePasswordOutput = format( "username: foo%npassword: ****%nPassword change required%nnew password: *******%n" );
145+
assertEquals(expectedLoginOutput + expectedChangePasswordOutput, baos.toString());
146+
assertEquals("foo", connectionConfig.username());
147+
assertEquals("newpass", connectionConfig.password());
148+
assertNull(connectionConfig.newPassword());
149+
}
150+
97151
@Test
98152
public void doesNotPromptToStdOutOnWrongAuthenticationIfOutputRedirected() throws Exception {
99153
// when
@@ -335,16 +389,21 @@ private ShellAndConnection getShell( CliArgs cliArgs )
335389
private ShellAndConnection getShell( CliArgs cliArgs, LinePrinter linePrinter )
336390
{
337391
PrettyConfig prettyConfig = new PrettyConfig( cliArgs );
338-
ConnectionConfig connectionConfig = new ConnectionConfig(
392+
ConnectionConfig connectionConfig = getConnectionConfig( cliArgs );
393+
394+
return new ShellAndConnection( new CypherShell( linePrinter, prettyConfig, true, new ShellParameterMap() ), connectionConfig );
395+
}
396+
397+
private ConnectionConfig getConnectionConfig( CliArgs cliArgs )
398+
{
399+
return new ConnectionConfig(
339400
cliArgs.getScheme(),
340401
cliArgs.getHost(),
341402
cliArgs.getPort(),
342403
cliArgs.getUsername(),
343404
cliArgs.getPassword(),
344405
cliArgs.getEncryption(),
345406
cliArgs.getDatabase() );
346-
347-
return new ShellAndConnection( new CypherShell( linePrinter, prettyConfig, true, new ShellParameterMap() ), connectionConfig );
348407
}
349408

350409
private void exit( CypherShell shell ) throws CommandException

cypher-shell/src/main/java/org/neo4j/shell/ConnectionConfig.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public class ConnectionConfig {
1313
private final boolean encryption;
1414
private String username;
1515
private String password;
16+
private String newPassword;
1617
private String database;
1718

1819
public ConnectionConfig(@Nonnull String scheme,
@@ -67,6 +68,10 @@ public String password() {
6768
return password;
6869
}
6970

71+
public String newPassword() {
72+
return newPassword;
73+
}
74+
7075
@Nonnull
7176
public String driverUrl() {
7277
return String.format("%s%s:%d", scheme(), host(), port());
@@ -89,4 +94,12 @@ public void setUsername(@Nonnull String username) {
8994
public void setPassword(@Nonnull String password) {
9095
this.password = password;
9196
}
97+
98+
public void setNewPassword(@Nonnull String password) {
99+
this.newPassword = password;
100+
}
101+
102+
public boolean passwordChangeRequired() {
103+
return this.newPassword != null;
104+
}
92105
}

cypher-shell/src/main/java/org/neo4j/shell/CypherShell.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,15 @@ public ParameterMap getParameterMap()
219219
{
220220
return parameterMap;
221221
}
222+
223+
public void changePassword(@Nonnull ConnectionConfig connectionConfig) {
224+
boltStateHandler.changePassword(connectionConfig);
225+
}
226+
227+
/**
228+
* Used for testing purposes
229+
*/
230+
public void disconnect() {
231+
boltStateHandler.disconnect();
232+
}
222233
}

cypher-shell/src/main/java/org/neo4j/shell/Main.java

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import javax.annotation.Nullable;
1010

1111
import org.neo4j.driver.exceptions.AuthenticationException;
12+
import org.neo4j.driver.exceptions.Neo4jException;
1213
import org.neo4j.shell.build.Build;
1314
import org.neo4j.shell.cli.CliArgHelper;
1415
import org.neo4j.shell.cli.CliArgs;
@@ -122,23 +123,40 @@ void connectMaybeInteractively(@Nonnull CypherShell shell,
122123
didPrompt = true;
123124
}
124125

125-
try {
126-
// Try to connect
127-
shell.connect(connectionConfig);
128-
} catch (AuthenticationException e) {
129-
// Fail if we already prompted,
130-
// or do not have interactive input,
131-
// or already tried with both username and password
132-
if (didPrompt || !inputInteractive || (!connectionConfig.username().isEmpty() && !connectionConfig.password().isEmpty())) {
133-
throw e;
126+
while (true) {
127+
try {
128+
// Try to connect
129+
shell.connect(connectionConfig);
130+
131+
// If no exception occurred we are done
132+
break;
133+
} catch (AuthenticationException e) {
134+
// Fail if we already prompted,
135+
// or do not have interactive input,
136+
// or already tried with both username and password
137+
if (didPrompt || !inputInteractive || (!connectionConfig.username().isEmpty() && !connectionConfig.password().isEmpty())) {
138+
throw e;
139+
}
140+
141+
// Otherwise we prompt for username and password, and try to connect again
142+
promptForUsernameAndPassword(connectionConfig, outputInteractive);
143+
didPrompt = true;
144+
} catch (Neo4jException e) {
145+
if (passwordChangeRequiredException(e)) {
146+
promptForPasswordChange(connectionConfig, outputInteractive);
147+
shell.changePassword(connectionConfig);
148+
didPrompt = true;
149+
} else {
150+
throw e;
151+
}
134152
}
135-
136-
// Otherwise we prompt for username and password, and try to connect again
137-
promptForUsernameAndPassword(connectionConfig, outputInteractive);
138-
shell.connect(connectionConfig);
139153
}
140154
}
141155

156+
private boolean passwordChangeRequiredException(Neo4jException e) {
157+
return "Neo.ClientError.Security.CredentialsExpired".equalsIgnoreCase(e.code());
158+
}
159+
142160
private void promptForUsernameAndPassword(ConnectionConfig connectionConfig, boolean outputInteractive) throws Exception {
143161
OutputStream promptOutputStream = getOutputStreamForInteractivePrompt();
144162
ConsoleReader consoleReader = new ConsoleReader(in, promptOutputStream);
@@ -162,6 +180,35 @@ private void promptForUsernameAndPassword(ConnectionConfig connectionConfig, boo
162180
}
163181
}
164182

183+
private void promptForPasswordChange(ConnectionConfig connectionConfig, boolean outputInteractive) throws Exception {
184+
OutputStream promptOutputStream = getOutputStreamForInteractivePrompt();
185+
ConsoleReader consoleReader = new ConsoleReader(in, promptOutputStream);
186+
// Disable expansion of bangs: !
187+
consoleReader.setExpandEvents(false);
188+
// Ensure Reader does not handle user input for ctrl+C behaviour
189+
consoleReader.setHandleUserInterrupt(false);
190+
191+
consoleReader.println("Password change required");
192+
193+
try {
194+
if (connectionConfig.username().isEmpty()) {
195+
String username = outputInteractive ?
196+
promptForNonEmptyText("username", consoleReader, null) :
197+
promptForText("username", consoleReader, null);
198+
connectionConfig.setUsername(username);
199+
}
200+
if (connectionConfig.password().isEmpty()) {
201+
connectionConfig.setPassword(promptForText("password", consoleReader, '*'));
202+
}
203+
String newPassword = outputInteractive ?
204+
promptForNonEmptyText("new password", consoleReader, '*') :
205+
promptForText("new password", consoleReader, '*');
206+
connectionConfig.setNewPassword(newPassword);
207+
} finally {
208+
consoleReader.close();
209+
}
210+
}
211+
165212
/**
166213
* @param prompt
167214
* to display to the user

cypher-shell/src/main/java/org/neo4j/shell/state/BoltStateHandler.java

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public class BoltStateHandler implements TransactionHandler, Connector, Database
3434
private String activeDatabaseNameAsSetByUser;
3535
private String actualDatabaseNameAsReportedByServer;
3636
private final boolean isInteractive;
37+
private Bookmark systemBookmark;
3738

3839
public BoltStateHandler(boolean isInteractive) {
3940
this(GraphDatabase::driver, isInteractive);
@@ -172,12 +173,13 @@ private void reconnect(boolean keepBookmark) {
172173
{
173174
builder.withDatabase( activeDatabaseNameAsSetByUser );
174175
}
175-
if ( session != null && keepBookmark )
176-
{
176+
if (session != null && keepBookmark) {
177177
// Save the last bookmark and close the session
178178
final Bookmark bookmark = session.lastBookmark();
179179
session.close();
180-
builder.withBookmarks( bookmark );
180+
builder.withBookmarks(bookmark);
181+
} else if (systemBookmark != null) {
182+
builder.withBookmarks(systemBookmark);
181183
}
182184

183185
session = driver.session( builder.build() );
@@ -235,6 +237,52 @@ public void updateActualDbName(@Nonnull ResultSummary resultSummary) {
235237
actualDatabaseNameAsReportedByServer = getActualDbName(resultSummary);
236238
}
237239

240+
public void changePassword(@Nonnull ConnectionConfig connectionConfig) {
241+
if (!connectionConfig.passwordChangeRequired()) {
242+
return;
243+
}
244+
245+
if (isConnected()) {
246+
silentDisconnect();
247+
}
248+
249+
final AuthToken authToken = AuthTokens.basic(connectionConfig.username(), connectionConfig.password());
250+
251+
try {
252+
driver = getDriver(connectionConfig, authToken);
253+
254+
// This will already throw an exception if there is no connectivity
255+
driver.verifyConnectivity();
256+
257+
SessionConfig.Builder builder = SessionConfig.builder()
258+
.withDefaultAccessMode(AccessMode.WRITE)
259+
.withDatabase(SYSTEM_DB_NAME);
260+
session = driver.session(builder.build());
261+
262+
String command = "ALTER CURRENT USER SET PASSWORD FROM $o TO $n";
263+
Value parameters = Values.parameters("o", connectionConfig.password(), "n", connectionConfig.newPassword());
264+
265+
StatementResult run = session.run(command, parameters);
266+
run.consume();
267+
268+
// If successful, use the new password when reconnecting
269+
connectionConfig.setPassword(connectionConfig.newPassword());
270+
connectionConfig.setNewPassword(null);
271+
272+
// Save a system bookmark to make sure we wait for the password change to propagate on reconnection
273+
systemBookmark = session.lastBookmark();
274+
275+
silentDisconnect();
276+
} catch (Throwable t) {
277+
try {
278+
silentDisconnect();
279+
} catch (Exception e) {
280+
t.addSuppressed(e);
281+
}
282+
throw t;
283+
}
284+
}
285+
238286
/**
239287
* @throws SessionExpiredException when server no longer serves writes anymore
240288
*/
@@ -292,6 +340,14 @@ public void reset() {
292340
}
293341
}
294342

343+
/**
344+
* Used for testing purposes
345+
*/
346+
public void disconnect() {
347+
reset();
348+
silentDisconnect();
349+
}
350+
295351
List<Statement> getTransactionStatements() {
296352
return this.transactionStatements;
297353
}

0 commit comments

Comments
 (0)