Skip to content

Commit 2da30e5

Browse files
committed
Support public key authentication
1 parent fadaf99 commit 2da30e5

4 files changed

Lines changed: 196 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,23 @@ this port can be obtained by `server.getPort()`. It can be changed by calling
7575
});
7676

7777
You can interact with the SFTP server by using the SFTP protocol with password
78-
authentication. By default the server accepts every pair of username and
79-
password, but you can restrict it to specific pairs.
78+
or SSH key authentication. By default, the server accepts every pair of username
79+
and password and every SSH key. You can enforce authentication by registering
80+
credentials
8081

8182
withSftpServer(server -> {
8283
server.addUser("username", "password");
8384
...
8485
});
8586

87+
or an SSH key (the server creates the key pair and provides you the private
88+
key)
89+
90+
withSftpServer(server -> {
91+
PrivateKey privateKey = server.addSshKeyForUser("username");
92+
...
93+
});
94+
8695

8796
### Testing code that reads files
8897

‎pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
</parent>
1010

1111
<artifactId>fake-sftp-server-lambda</artifactId>
12-
<version>2.0.1</version>
12+
<version>2.1.0</version>
1313
<packaging>jar</packaging>
1414

1515
<name>Fake SFTP Server Lambda</name>

‎src/main/java/com/github/stefanbirkner/fakesftpserver/lambda/FakeSftpServer.java‎

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
import java.nio.file.attribute.BasicFileAttributes;
1616
import java.nio.file.attribute.UserPrincipalLookupService;
1717
import java.nio.file.spi.FileSystemProvider;
18+
import java.security.*;
1819
import java.util.*;
1920

2021
import static com.github.marschall.memoryfilesystem.MemoryFileSystemBuilder.newLinux;
2122
import static java.nio.file.FileVisitResult.CONTINUE;
2223
import static java.nio.file.Files.*;
2324
import static java.util.Collections.singletonList;
25+
import static org.apache.sshd.common.config.keys.KeyUtils.compareKeys;
2426

2527
/**
2628
* {@code FakeSftpServer} runs an in-memory SFTP server while your tests are
@@ -218,6 +220,7 @@ public FileVisitResult postVisitDirectory(
218220
private SshServer server;
219221
private boolean withSftpServerFinished = false;
220222
private final Map<String, String> usernamesAndPasswords = new HashMap<>();
223+
private final Map<String, PublicKey> publicKeysByUser = new HashMap<>();
221224

222225
/**
223226
* {@code FakeSftpServer} cannot be created manually. It is always provided
@@ -260,10 +263,30 @@ public void setPort(
260263
restartServer(port);
261264
}
262265

266+
/**
267+
* Register a username for SSH key based authentication.
268+
* {@code FakeSftpServer} generates an SSH key pair for the user and returns
269+
* the pair's private key. The private key can be used for authentication.
270+
* <p>If {@code addSshKeyForUser} is called multiple times with the same
271+
* username then the latest key is effective.
272+
* <p>After registering a username it is only possible to connect to the
273+
* server with one of the registered username/password pairs or an SSH key.
274+
* @param username the username.
275+
* @return the private key that can be used for authentication.
276+
*/
277+
public PrivateKey addSshKeyForUser(
278+
String username
279+
) throws NoSuchAlgorithmException {
280+
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
281+
KeyPair keyPair = keyPairGenerator.generateKeyPair();
282+
publicKeysByUser.put(username, keyPair.getPublic());
283+
return keyPair.getPrivate();
284+
}
285+
263286
/**
264287
* Register a username with its password. After registering a username
265288
* it is only possible to connect to the server with one of the registered
266-
* username/password pairs.
289+
* username/password pairs or an SSH key.
267290
* <p>If {@code addUser} is called multiple times with the same username but
268291
* different passwords then the last password is effective.
269292
* @param username the username.
@@ -435,6 +458,7 @@ private Closeable start(
435458
server.setPort(port);
436459
server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider());
437460
server.setPasswordAuthenticator(this::authenticate);
461+
server.setPublickeyAuthenticator(this::authenticatePublicKey);
438462
server.setSubsystemFactories(singletonList(new SftpSubsystemFactory()));
439463
/* When a channel is closed SshServer calls close() on the file system.
440464
* In order to use the file system for multiple channels/sessions we
@@ -451,11 +475,28 @@ private boolean authenticate(
451475
String password,
452476
ServerSession session
453477
) {
454-
return usernamesAndPasswords.isEmpty()
455-
|| Objects.equals(
456-
usernamesAndPasswords.get(username),
457-
password
458-
);
478+
return isNoUserRegistered()
479+
|| isCorrectPassword(username, password);
480+
}
481+
482+
private boolean isNoUserRegistered() {
483+
return usernamesAndPasswords.isEmpty() && publicKeysByUser.isEmpty();
484+
}
485+
486+
private boolean isCorrectPassword(String username, String password) {
487+
return Objects.equals(
488+
usernamesAndPasswords.get(username),
489+
password
490+
);
491+
}
492+
493+
private boolean authenticatePublicKey(
494+
String username,
495+
PublicKey publicKey,
496+
ServerSession session
497+
) {
498+
PublicKey registeredKey = publicKeysByUser.get(username);
499+
return compareKeys(publicKey, registeredKey);
459500
}
460501

461502
private void ensureDirectoryOfPathExists(

‎src/test/java/com/github/stefanbirkner/fakesftpserver/lambda/FakeSftpServerTest.java‎

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,27 @@
77
import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores;
88
import org.junit.jupiter.api.Nested;
99
import org.junit.jupiter.api.Test;
10+
import org.junit.jupiter.api.io.TempDir;
1011

1112
import java.io.ByteArrayInputStream;
1213
import java.io.IOException;
1314
import java.io.InputStream;
1415
import java.net.ConnectException;
1516
import java.nio.file.Path;
1617
import java.nio.file.Paths;
17-
import java.util.Vector;
18+
import java.security.*;
19+
import java.security.KeyPair;
20+
import java.util.*;
1821
import java.util.concurrent.atomic.AtomicInteger;
1922
import java.util.concurrent.atomic.AtomicReference;
2023

2124
import static com.github.stefanbirkner.fakesftpserver.lambda.FakeSftpServer.withSftpServer;
2225
import static com.github.stefanbirkner.fishbowl.Fishbowl.exceptionThrownBy;
2326
import static com.github.stefanbirkner.fishbowl.Fishbowl.ignoreException;
2427
import static java.nio.charset.StandardCharsets.UTF_8;
28+
import static java.nio.file.Files.createTempFile;
29+
import static java.nio.file.Files.write;
30+
import static java.util.Arrays.asList;
2531
import static org.apache.commons.io.IOUtils.toByteArray;
2632
import static org.assertj.core.api.Assertions.assertThat;
2733
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -92,7 +98,7 @@ void a_client_can_connect_to_the_server_at_a_user_specified_port(
9298
@Nested
9399
class authentication {
94100
@Nested
95-
class server_without_credentials {
101+
class server_without_credentials_or_SSH_keys {
96102
@Test
97103
void the_server_accepts_connections_with_password(
98104
) throws Exception {
@@ -107,6 +113,31 @@ void the_server_accepts_connections_with_password(
107113
}
108114
);
109115
}
116+
117+
@Test
118+
void the_server_accepts_connections_with_an_SSH_key(
119+
@TempDir Path tempDir
120+
) throws Exception {
121+
String anyPrivateKey = fileWithArbitraryPrivateKey(tempDir);
122+
withSftpServer(
123+
server -> {
124+
JSCH.addIdentity(anyPrivateKey);
125+
Session session = createSessionForUser(
126+
"dummy user",
127+
server.getPort()
128+
);
129+
session.connect(TIMEOUT);
130+
}
131+
);
132+
}
133+
134+
private String fileWithArbitraryPrivateKey(
135+
Path directory
136+
) throws NoSuchAlgorithmException, IOException {
137+
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
138+
KeyPair keyPair = keyPairGenerator.generateKeyPair();
139+
return keyFile(directory, keyPair.getPrivate());
140+
}
110141
}
111142

112143
@Nested
@@ -164,6 +195,101 @@ void the_last_password_is_effective_if_addUser_is_called_multiple_times(
164195
}
165196
}
166197

198+
@Nested
199+
class SSH_key {
200+
@TempDir
201+
Path tempDir;
202+
203+
@Test
204+
public void the_server_accepts_connections_with_correct_private_key(
205+
) throws Exception {
206+
withSftpServer(
207+
server -> {
208+
PrivateKey privateKey = server.addSshKeyForUser("dummy user");
209+
JSCH.addIdentity(
210+
keyFile(tempDir, privateKey)
211+
);
212+
Session session = createSessionForUser(
213+
"dummy user",
214+
server.getPort()
215+
);
216+
session.connect(TIMEOUT);
217+
}
218+
);
219+
}
220+
221+
@Test
222+
public void the_server_rejects_connections_with_wrong_private_key(
223+
) throws Exception {
224+
withSftpServer(
225+
server -> {
226+
server.addSshKeyForUser("dummy user");
227+
PrivateKey privateKey = server.addSshKeyForUser("another user");
228+
JSCH.addIdentity(
229+
keyFile(tempDir, privateKey)
230+
);
231+
Session session = createSessionForUser(
232+
"dummy user",
233+
server.getPort()
234+
);
235+
assertThatThrownBy(() -> session.connect(TIMEOUT))
236+
.isInstanceOf(Exception.class);
237+
}
238+
);
239+
}
240+
241+
@Test
242+
public void the_server_rejects_connections_for_user_without_private_key(
243+
) throws Exception {
244+
withSftpServer(
245+
server -> {
246+
// We need to create an SSH key, because servers without
247+
// registered credentials or SSH keys accept every
248+
// connection.
249+
server.addSshKeyForUser("another user");
250+
Session session = createSessionForUser(
251+
"dummy user",
252+
server.getPort()
253+
);
254+
assertThatThrownBy(() -> session.connect(TIMEOUT))
255+
.isInstanceOf(Exception.class);
256+
}
257+
);
258+
}
259+
260+
@Test
261+
public void the_latest_key_is_effective_if_multiple_keys_are_created_for_a_user(
262+
) throws Exception {
263+
withSftpServer(
264+
server -> {
265+
server.addSshKeyForUser("dummy user");
266+
PrivateKey privateKey = server.addSshKeyForUser("dummy user");
267+
JSCH.addIdentity(
268+
keyFile(tempDir, privateKey)
269+
);
270+
Session session = createSessionForUser(
271+
"dummy user",
272+
server.getPort()
273+
);
274+
session.connect(TIMEOUT);
275+
}
276+
);
277+
}
278+
}
279+
280+
private String keyFile(
281+
Path directory,
282+
PrivateKey key
283+
) throws IOException {
284+
Path keyFile = createTempFile(directory, "key_", ".tmp");
285+
write(keyFile, asList(
286+
"-----BEGIN PRIVATE KEY-----",
287+
Base64.getMimeEncoder().encodeToString(key.getEncoded()),
288+
"-----END PRIVATE KEY-----"
289+
));
290+
return keyFile.toFile().getAbsolutePath();
291+
}
292+
167293
private Session createSessionWithCredentials(
168294
FakeSftpServer server,
169295
String username,
@@ -964,10 +1090,18 @@ private static Session createSessionWithCredentials(
9641090
String username,
9651091
String password,
9661092
int port
1093+
) throws JSchException {
1094+
Session session = createSessionForUser(username, port);
1095+
session.setPassword(password);
1096+
return session;
1097+
}
1098+
1099+
private static Session createSessionForUser(
1100+
String username,
1101+
int port
9671102
) throws JSchException {
9681103
Session session = JSCH.getSession(username, "127.0.0.1", port);
9691104
session.setConfig("StrictHostKeyChecking", "no");
970-
session.setPassword(password);
9711105
return session;
9721106
}
9731107

0 commit comments

Comments
 (0)