Merge pull request #13190 from is-apps/master-sftp-key

Add SFTP public key authentication support
This commit is contained in:
Vincent Petry 2015-02-10 16:44:29 +01:00
commit bd01ff135a
10 changed files with 472 additions and 7 deletions

View File

@ -18,6 +18,7 @@ OC::$CLASSPATH['OC\Files\Storage\SMB_OC'] = 'files_external/lib/smb_oc.php';
OC::$CLASSPATH['OC\Files\Storage\AmazonS3'] = 'files_external/lib/amazons3.php';
OC::$CLASSPATH['OC\Files\Storage\Dropbox'] = 'files_external/lib/dropbox.php';
OC::$CLASSPATH['OC\Files\Storage\SFTP'] = 'files_external/lib/sftp.php';
OC::$CLASSPATH['OC\Files\Storage\SFTP_Key'] = 'files_external/lib/sftp_key.php';
OC::$CLASSPATH['OC_Mount_Config'] = 'files_external/lib/config.php';
OC::$CLASSPATH['OCA\Files\External\Api'] = 'files_external/lib/api.php';
@ -196,5 +197,17 @@ OC_Mount_Config::registerBackend('\OC\Files\Storage\SFTP', [
],
]);
OC_Mount_Config::registerBackend('\OC\Files\Storage\SFTP_Key', [
'backend' => 'SFTP with secret key login',
'priority' => 100,
'configuration' => array(
'host' => (string)$l->t('Host'),
'user' => (string)$l->t('Username'),
'public_key' => (string)$l->t('Public key'),
'private_key' => '#private_key',
'root' => '&'.$l->t('Remote subfolder')),
'custom' => 'sftp_key',
]
);
$mountProvider = new \OCA\Files_External\Config\ConfigAdapter();
\OC::$server->getMountProviderCollection()->registerProvider($mountProvider);

View File

@ -0,0 +1,33 @@
<?php
/**
* Copyright (c) 2015 University of Edinburgh <Ross.Nicoll@ed.ac.uk>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/
namespace OCA\Files_External\Appinfo;
use \OCA\Files_External\Controller\AjaxController;
use \OCP\AppFramework\App;
use \OCP\IContainer;
/**
* @package OCA\Files_External\Appinfo
*/
class Application extends App {
public function __construct(array $urlParams=array()) {
parent::__construct('files_external', $urlParams);
$container = $this->getContainer();
/**
* Controllers
*/
$container->registerService('AjaxController', function (IContainer $c) {
return new AjaxController(
$c->query('AppName'),
$c->query('Request')
);
});
}
}

View File

@ -20,6 +20,23 @@
*
*/
namespace OCA\Files_External\Appinfo;
$application = new Application();
$application->registerRoutes(
$this,
array(
'routes' => array(
array(
'name' => 'Ajax#getSshKeys',
'url' => '/ajax/sftp_key.php',
'verb' => 'POST',
'requirements' => array()
)
)
)
);
/** @var $this OC\Route\Router */
$this->create('files_external_add_mountpoint', 'ajax/addMountPoint.php')
@ -37,10 +54,11 @@ $this->create('files_external_dropbox', 'ajax/dropbox.php')
$this->create('files_external_google', 'ajax/google.php')
->actionInclude('files_external/ajax/google.php');
$this->create('files_external_list_applicable', '/applicable')
->actionInclude('files_external/ajax/applicable.php');
OC_API::register('get',
\OC_API::register('get',
'/apps/files_external/api/v1/mounts',
array('\OCA\Files\External\Api', 'getUserMounts'),
'files_external');

View File

@ -0,0 +1,48 @@
<?php
/**
* Copyright (c) 2015 University of Edinburgh <Ross.Nicoll@ed.ac.uk>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/
namespace OCA\Files_External\Controller;
use OCP\AppFramework\Controller;
use OCP\IRequest;
use OCP\AppFramework\Http\JSONResponse;
class AjaxController extends Controller {
public function __construct($appName, IRequest $request) {
parent::__construct($appName, $request);
}
private function generateSshKeys() {
$rsa = new \Crypt_RSA();
$rsa->setPublicKeyFormat(CRYPT_RSA_PUBLIC_FORMAT_OPENSSH);
$rsa->setPassword(\OC::$server->getConfig()->getSystemValue('secret', ''));
$key = $rsa->createKey();
// Replace the placeholder label with a more meaningful one
$key['publicKey'] = str_replace('phpseclib-generated-key', gethostname(), $key['publickey']);
return $key;
}
/**
* Generates an SSH public/private key pair.
*
* @NoAdminRequired
*/
public function getSshKeys() {
$key = $this->generateSshKeys();
return new JSONResponse(
array('data' => array(
'private_key' => $key['privatekey'],
'public_key' => $key['publickey']
),
'status' => 'success'
));
}
}

View File

@ -0,0 +1,53 @@
$(document).ready(function() {
$('#externalStorage tbody tr.\\\\OC\\\\Files\\\\Storage\\\\SFTP_Key').each(function() {
var tr = $(this);
var config = $(tr).find('.configuration');
if ($(config).find('.sftp_key').length === 0) {
setupTableRow(tr, config);
}
});
// We can't catch the DOM elements being added, but we can pick up when
// they receive focus
$('#externalStorage').on('focus', 'tbody tr.\\\\OC\\\\Files\\\\Storage\\\\SFTP_Key', function() {
var tr = $(this);
var config = $(tr).find('.configuration');
if ($(config).find('.sftp_key').length === 0) {
setupTableRow(tr, config);
}
});
$('#externalStorage').on('click', '.sftp_key', function(event) {
event.preventDefault();
var tr = $(this).parent().parent();
generateKeys(tr);
});
function setupTableRow(tr, config) {
$(config).append($(document.createElement('input')).addClass('button sftp_key')
.attr('type', 'button')
.attr('value', t('files_external', 'Generate keys')));
// If there's no private key, build one
if (0 === $(config).find('[data-parameter="private_key"]').val().length) {
generateKeys(tr);
}
}
function generateKeys(tr) {
var config = $(tr).find('.configuration');
$.post(OC.filePath('files_external', 'ajax', 'sftp_key.php'), {}, function(result) {
if (result && result.status === 'success') {
$(config).find('[data-parameter="public_key"]').val(result.data.public_key);
$(config).find('[data-parameter="private_key"]').val(result.data.private_key);
OC.MountConfig.saveStorage(tr, function() {
// Nothing to do
});
} else {
OC.dialogs.alert(result.data.message, t('files_external', 'Error generating key pair') );
}
});
}
});

View File

@ -69,4 +69,4 @@
"Enable User External Storage" : "Enable User External Storage",
"Allow users to mount the following external storage" : "Allow users to mount the following external storage"
},"pluralForm" :"nplurals=2; plural=(n != 1);"
}
}

View File

@ -20,7 +20,7 @@ class SFTP extends \OC\Files\Storage\Common {
/**
* @var \Net_SFTP
*/
private $client;
protected $client;
private static $tempFiles = array();
@ -42,7 +42,8 @@ class SFTP extends \OC\Files\Storage\Common {
$this->host = substr($this->host, $proto+3);
}
$this->user = $params['user'];
$this->password = $params['password'];
$this->password
= isset($params['password']) ? $params['password'] : '';
$this->root
= isset($params['root']) ? $this->cleanPath($params['root']) : '/';
@ -101,6 +102,18 @@ class SFTP extends \OC\Files\Storage\Common {
return 'sftp::' . $this->user . '@' . $this->host . '/' . $this->root;
}
public function getHost() {
return $this->host;
}
public function getRoot() {
return $this->root;
}
public function getUser() {
return $this->user;
}
/**
* @param string $path
*/
@ -121,7 +134,7 @@ class SFTP extends \OC\Files\Storage\Common {
return false;
}
private function writeHostKeys($keys) {
protected function writeHostKeys($keys) {
try {
$keyPath = $this->hostKeysPath();
if ($keyPath && file_exists($keyPath)) {
@ -137,7 +150,7 @@ class SFTP extends \OC\Files\Storage\Common {
return false;
}
private function readHostKeys() {
protected function readHostKeys() {
try {
$keyPath = $this->hostKeysPath();
if (file_exists($keyPath)) {

View File

@ -0,0 +1,194 @@
<?php
/**
* Copyright (c) 2014, 2015 University of Edinburgh <Ross.Nicoll@ed.ac.uk>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/
namespace OC\Files\Storage;
/**
* Uses phpseclib's Net_SFTP class and the Net_SFTP_Stream stream wrapper to
* provide access to SFTP servers.
*/
class SFTP_Key extends \OC\Files\Storage\SFTP {
private $publicKey;
private $privateKey;
public function __construct($params) {
parent::__construct($params);
$this->publicKey = $params['public_key'];
$this->privateKey = $params['private_key'];
}
/**
* Returns the connection.
*
* @return \Net_SFTP connected client instance
* @throws \Exception when the connection failed
*/
public function getConnection() {
if (!is_null($this->client)) {
return $this->client;
}
$hostKeys = $this->readHostKeys();
$this->client = new \Net_SFTP($this->getHost());
// The SSH Host Key MUST be verified before login().
$currentHostKey = $this->client->getServerPublicHostKey();
if (array_key_exists($this->getHost(), $hostKeys)) {
if ($hostKeys[$this->getHost()] !== $currentHostKey) {
throw new \Exception('Host public key does not match known key');
}
} else {
$hostKeys[$this->getHost()] = $currentHostKey;
$this->writeHostKeys($hostKeys);
}
$key = $this->getPrivateKey();
if (is_null($key)) {
throw new \Exception('Secret key could not be loaded');
}
if (!$this->client->login($this->getUser(), $key)) {
throw new \Exception('Login failed');
}
return $this->client;
}
/**
* Returns the private key to be used for authentication to the remote server.
*
* @return \Crypt_RSA instance or null in case of a failure to load the key.
*/
private function getPrivateKey() {
$key = new \Crypt_RSA();
$key->setPassword(\OC::$server->getConfig()->getSystemValue('secret', ''));
if (!$key->loadKey($this->privateKey)) {
// Should this exception rather than return null?
return null;
}
return $key;
}
/**
* Throws an exception if the provided host name/address is invalid (cannot be resolved
* and is not an IPv4 address).
*
* @return true; never returns in case of a problem, this return value is used just to
* make unit tests happy.
*/
public function assertHostAddressValid($hostname) {
// TODO: Should handle IPv6 addresses too
if (!preg_match('/^\d+\.\d+\.\d+\.\d+$/', $hostname) && gethostbyname($hostname) === $hostname) {
// Hostname is not an IPv4 address and cannot be resolved via DNS
throw new \InvalidArgumentException('Cannot resolve hostname.');
}
return true;
}
/**
* Throws an exception if the provided port number is invalid (cannot be resolved
* and is not an IPv4 address).
*
* @return true; never returns in case of a problem, this return value is used just to
* make unit tests happy.
*/
public function assertPortNumberValid($port) {
if (!preg_match('/^\d+$/', $port)) {
throw new \InvalidArgumentException('Port number must be a number.');
}
if ($port < 0 || $port > 65535) {
throw new \InvalidArgumentException('Port number must be between 0 and 65535 inclusive.');
}
return true;
}
/**
* Replaces anything that's not an alphanumeric character or "." in a hostname
* with "_", to make it safe for use as part of a file name.
*/
protected function sanitizeHostName($name) {
return preg_replace('/[^\d\w\._]/', '_', $name);
}
/**
* Replaces anything that's not an alphanumeric character or "_" in a username
* with "_", to make it safe for use as part of a file name.
*/
protected function sanitizeUserName($name) {
return preg_replace('/[^\d\w_]/', '_', $name);
}
public function test() {
if (empty($this->getHost())) {
\OC::$server->getLogger()->warning('Hostname has not been specified');
return false;
}
if (empty($this->getUser())) {
\OC::$server->getLogger()->warning('Username has not been specified');
return false;
}
if (!isset($this->privateKey)) {
\OC::$server->getLogger()->warning('Private key was missing from the request');
return false;
}
// Sanity check the host
$hostParts = explode(':', $this->getHost());
try {
if (count($hostParts) == 1) {
$hostname = $hostParts[0];
$this->assertHostAddressValid($hostname);
} else if (count($hostParts) == 2) {
$hostname = $hostParts[0];
$this->assertHostAddressValid($hostname);
$this->assertPortNumberValid($hostParts[1]);
} else {
throw new \Exception('Host connection string is invalid.');
}
} catch(\Exception $e) {
\OC::$server->getLogger()->warning($e->getMessage());
return false;
}
// Validate the key
$key = $this->getPrivateKey();
if (is_null($key)) {
\OC::$server->getLogger()->warning('Secret key could not be loaded');
return false;
}
try {
if ($this->getConnection()->nlist() === false) {
return false;
}
} catch(\Exception $e) {
// We should be throwing a more specific error, so we're not just catching
// Exception here
\OC::$server->getLogger()->warning($e->getMessage());
return false;
}
// Save the key somewhere it can easily be extracted later
if (\OC::$server->getUserSession()->getUser()) {
$view = new \OC\Files\View('/'.\OC::$server->getUserSession()->getUser()->getUId().'/files_external/sftp_keys');
if (!$view->is_dir('')) {
if (!$view->mkdir('')) {
\OC::$server->getLogger()->warning('Could not create secret key directory.');
return false;
}
}
$key_filename = $this->sanitizeUserName($this->getUser()).'@'.$this->sanitizeHostName($hostname).'.pub';
$key_file = $view->fopen($key_filename, "w");
if ($key_file) {
fwrite($key_file, $this->publicKey);
fclose($key_file);
} else {
\OC::$server->getLogger()->warning('Could not write secret key file.');
}
}
return true;
}
}

View File

@ -0,0 +1,85 @@
<?php
/**
* ownCloud
*
* @author Henrik Kjölhede
* @copyright 2013 Henrik Kjölhede hkjolhede@gmail.com
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* License as published by the Free Software Foundation; either
* version 3 of the License, or any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU AFFERO GENERAL PUBLIC LICENSE for more details.
*
* You should have received a copy of the GNU Affero General Public
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Test\Files\Storage;
class SFTP_Key extends Storage {
private $config;
protected function setUp() {
parent::setUp();
$id = $this->getUniqueID();
$this->config = include('files_external/tests/config.php');
if ( ! is_array($this->config) or ! isset($this->config['sftp_key']) or ! $this->config['sftp_key']['run']) {
$this->markTestSkipped('SFTP with key backend not configured');
}
$this->config['sftp_key']['root'] .= '/' . $id; //make sure we have an new empty folder to work in
$this->instance = new \OC\Files\Storage\SFTP_Key($this->config['sftp_key']);
$this->instance->mkdir('/');
}
protected function tearDown() {
if ($this->instance) {
$this->instance->rmdir('/');
}
parent::tearDown();
}
/**
* @expectedException InvalidArgumentException
*/
public function testInvalidAddressShouldThrowException() {
# I'd use example.com for this, but someone decided to break the spec and make it resolve
$this->instance->assertHostAddressValid('notarealaddress...');
}
public function testValidAddressShouldPass() {
$this->assertTrue($this->instance->assertHostAddressValid('localhost'));
}
/**
* @expectedException InvalidArgumentException
*/
public function testNegativePortNumberShouldThrowException() {
$this->instance->assertPortNumberValid('-1');
}
/**
* @expectedException InvalidArgumentException
*/
public function testNonNumericalPortNumberShouldThrowException() {
$this->instance->assertPortNumberValid('a');
}
/**
* @expectedException InvalidArgumentException
*/
public function testHighPortNumberShouldThrowException() {
$this->instance->assertPortNumberValid('65536');
}
public function testValidPortNumberShouldPass() {
$this->assertTrue($this->instance->assertPortNumberValid('22222'));
}
}

View File

@ -88,5 +88,13 @@ return array(
'user'=>'test',
'password'=>'test',
'root'=>'/test'
)
),
'sftp_key' => array (
'run'=>false,
'host'=>'localhost',
'user'=>'test',
'public_key'=>'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDJPTvz3OLonF2KSGEKP/nd4CPmRYvemG2T4rIiNYjDj0U5y+2sKEWbjiUlQl2bsqYuVoJ+/UNJlGQbbZ08kQirFeo1GoWBzqioaTjUJfbLN6TzVVKXxR9YIVmH7Ajg2iEeGCndGgbmnPfj+kF9TR9IH8vMVvtubQwf7uEwB0ALhw== phpseclib-generated-key',
'private_key'=>'test',
'root'=>'/test'
),
);