Commit 9a46bc51 authored by Jérome Perrin's avatar Jérome Perrin

ProFTPd: More Authentication methods

* version up
 * ssh keys support
 * mod_auth_web support

See merge request nexedi/slapos!993
parents 9a5b1341 caafba05
...@@ -23,16 +23,15 @@ recipe = collective.recipe.grp ...@@ -23,16 +23,15 @@ recipe = collective.recipe.grp
[proftpd] [proftpd]
recipe = slapos.recipe.cmmi recipe = slapos.recipe.cmmi
md5sum = 4040f6a6b86173e2a03f4ccdb9b9af6e md5sum = 4a9b8877b2e9b08d70e71ad56c19e2c9
url = ftp://ftp.proftpd.org/distrib/source/proftpd-1.3.6b.tar.gz url = ftp://ftp.proftpd.org/distrib/source/proftpd-1.3.7a.tar.gz
configure-options = configure-options =
--enable-openssl --enable-openssl
--enable-nls --enable-nls
--enable-ctrls --enable-ctrls
--enable-dso --enable-dso
--disable-cap --disable-cap
--with-modules=mod_sftp:mod_ban --with-modules=mod_sftp:mod_ban:mod_rewrite
--prefix=${buildout:parts-directory}/${:_buildout_section_name_}
environment = environment =
CFLAGS=-DPR_RUN_DIR=\"/proc/self/cwd/var\" CFLAGS=-DPR_RUN_DIR=\"/proc/self/cwd/var\"
CPPFLAGS=-I${zlib:location}/include -I${openssl:location}/include CPPFLAGS=-I${zlib:location}/include -I${openssl:location}/include
...@@ -47,11 +46,8 @@ patches = ...@@ -47,11 +46,8 @@ patches =
# mod_auth_web: a proftpd module to authenticate users against an HTTP service # mod_auth_web: a proftpd module to authenticate users against an HTTP service
[proftpd-mod_auth_web-repository] [proftpd-mod_auth_web-repository]
recipe = slapos.recipe.build:gitclone recipe = slapos.recipe.build:gitclone
#repository = https://github.com/proftpd/mod_auth_web repository = https://github.com/proftpd/mod_auth_web
# XXX until https://github.com/proftpd/mod_auth_web/pull/1 gets merged, we use revision = e36105808b7d07d843b11f428a666a8f3cec35e4
# a copy of this repository on nexedi gitlab
repository = https://lab.nexedi.com/jerome/mod_auth_web
revision = dec090bd0e287544a34be156ee17f715bd4286f9
git-executable = ${git:location}/bin/git git-executable = ${git:location}/bin/git
[proftpd-mod_auth_web] [proftpd-mod_auth_web]
......
...@@ -4,15 +4,14 @@ http://www.proftpd.org/docs/ ...@@ -4,15 +4,14 @@ http://www.proftpd.org/docs/
# Features # Features
* sftp only is enabled * sftp only is enabled, with authentication by key or password
* partially uploadloaded are not visible thanks to [`HiddenStores`](http://proftpd.org/docs/directives/linked/config_ref_HiddenStores.html) ( in fact they are, but name starts with `.` ) * partially uploadloaded are not visible thanks to [`HiddenStores`](http://proftpd.org/docs/directives/linked/config_ref_HiddenStores.html) ( in fact they are, but name starts with `.` )
* 5 failed login attempts will cause the host to be temporary banned * 5 failed login attempts will cause the host to be temporary banned
* support authentication against an external web service
# TODO # TODO
* only password login is enabled. enabling [`SFTPAuthorizedUserKeys`](http://www.proftpd.org/docs/contrib/mod_sftp.html#SFTPAuthorizedUserKeys) seems to break password only login
* log rotation * log rotation
* make sure SFTPLog is useful (seems very verbose and does not contain more than stdout) * make sure SFTPLog is useful (seems very verbose and does not contain more than stdout)
* make it easier to manage users ( using `mod_auth_web` against an ERP5 endpoint or accepting a list of user/password as instance parameter )
* allow configuring webhooks when new file is uploaded * allow configuring webhooks when new file is uploaded
...@@ -19,8 +19,8 @@ md5sum = efb4238229681447aa7fe73898dffad4 ...@@ -19,8 +19,8 @@ md5sum = efb4238229681447aa7fe73898dffad4
[instance-default] [instance-default]
filename = instance-default.cfg.in filename = instance-default.cfg.in
md5sum = 2a2c066d7d40dd8545f3008f434ee842 md5sum = dae19ec06f8da9fa2980a6d2bdf3da54
[proftpd-config-file] [proftpd-config-file]
filename = proftpd-config-file.cfg.in filename = proftpd-config-file.cfg.in
md5sum = a7c0f4607c378b640379cc258a8aadfa md5sum = 82cc600f4fce9852370f9d1f7c4cd3a6
...@@ -66,16 +66,24 @@ ban-log=${directory:log}/proftpd-ban.log ...@@ -66,16 +66,24 @@ ban-log=${directory:log}/proftpd-ban.log
ssh-host-rsa-key=${ssh-host-rsa-key:output} ssh-host-rsa-key=${ssh-host-rsa-key:output}
ssh-host-dsa-key=${ssh-host-dsa-key:output} ssh-host-dsa-key=${ssh-host-dsa-key:output}
ssh-host-ecdsa-key=${ssh-host-ecdsa-key:output} ssh-host-ecdsa-key=${ssh-host-ecdsa-key:output}
ssh-authorized-keys-dir = ${directory:ssh-authorized-keys-dir} ssh-authorized-key = ${ssh-authorized-keys:rendered}
ban-table=${directory:srv}/proftpd-ban-table ban-table=${directory:srv}/proftpd-ban-table
control-socket=${directory:var}/proftpd.sock control-socket=${directory:var}/proftpd.sock
auth-user-file=${auth-user-file:output} auth-user-file=${auth-user-file:output}
authentication-url = {{ slapparameter_dict.get('authentication-url', '')}}
recipe = slapos.cookbook:wrapper recipe = slapos.cookbook:wrapper
command-line = command-line =
{{ proftpd_bin }} --nodaemon --config ${proftpd-config-file:rendered} {{ proftpd_bin }} --nodaemon --config ${proftpd-config-file:rendered}
wrapper-path = ${directory:service}/proftpd wrapper-path = ${directory:service}/proftpd
[ssh-authorized-keys]
rendered = ${directory:ssh-authorized-keys-dir}/authorized_keys
{% if slapparameter_dict.get('ssh-key') %}
recipe = slapos.recipe.template:jinja2
template = inline:{{ slapparameter_dict['ssh-key'] | indent }}
{% endif %}
[proftpd-listen-promise] [proftpd-listen-promise]
<= monitor-promise-base <= monitor-promise-base
module = check_port_listening module = check_port_listening
...@@ -133,5 +141,9 @@ instance-promises = ...@@ -133,5 +141,9 @@ instance-promises =
[publish-connection-parameter] [publish-connection-parameter]
recipe = slapos.cookbook:publish recipe = slapos.cookbook:publish
url = ${proftpd:url} url = ${proftpd:url}
{% if not slapparameter_dict.get('authentication-url') %}
username = ${proftpd-password:username} username = ${proftpd-password:username}
{% if not slapparameter_dict.get('ssh-key') %}
password = ${proftpd-password:passwd} password = ${proftpd-password:passwd}
{% endif %}
{% endif %}
{ {
"$schema": "http://json-schema.org/draft-04/schema#", "$schema": "http://json-schema.org/draft-04/schema#",
"description": "Parameters to instantiate PoFTPd", "description": "Parameters to instantiate ProFTPd",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"port": { "port": {
"description": "Port number to listen to - default to 8022", "description": "Port number to listen to",
"type": "number" "type": "number",
"default": 8022
},
"ssh-key": {
"description": "SSH public key, in RFC4716 format. Note that this is not the default format used by openssh and that openssh keys must be converted with `ssh-keygen -e -f ~/.ssh/id_rsa.pub`",
"type": "string"
},
"authentication-url": {
"description": "URL of an HTTP endpoint to authenticate users. Endoint recieve a `application/x-www-form-urlencoded` POST request with `login` and `password` arguments and must respond with a `X-Proftpd-Authentication-Result: Success` header to signal successful authentication",
"type": "string"
} }
} }
} }
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
"optional": true "optional": true
}, },
"password": { "password": {
"description": "Password for default username", "description": "Password for default username, when not using ssh-key",
"type": "string", "type": "string",
"optional": true "optional": true
} }
......
...@@ -20,7 +20,7 @@ SFTPEngine on ...@@ -20,7 +20,7 @@ SFTPEngine on
SFTPHostKey {{ proftpd['ssh-host-rsa-key'] }} SFTPHostKey {{ proftpd['ssh-host-rsa-key'] }}
SFTPHostKey {{ proftpd['ssh-host-dsa-key'] }} SFTPHostKey {{ proftpd['ssh-host-dsa-key'] }}
SFTPHostKey {{ proftpd['ssh-host-ecdsa-key'] }} SFTPHostKey {{ proftpd['ssh-host-ecdsa-key'] }}
#SFTPAuthorizedUserKeys file:{{ proftpd['ssh-authorized-keys-dir'] }}%u SFTPAuthorizedUserKeys file:{{ proftpd['ssh-authorized-key'] }}
# Logging # Logging
...@@ -34,6 +34,23 @@ RequireValidShell off ...@@ -34,6 +34,23 @@ RequireValidShell off
AuthUserFile {{ proftpd['auth-user-file'] }} AuthUserFile {{ proftpd['auth-user-file'] }}
# http authentication
{% if proftpd['authentication-url'] %}
LoadModule mod_auth_web.c
AuthWebURL {{ proftpd['authentication-url'] }}
AuthWebRequireHeader "X-Proftpd-Authentication-Result: Success"
AuthWebUsernameParamName login
AuthWebPasswordParamName password
AuthWebLocalUser {{ proftpd['user'] }}
# mod_auth_web only read /etc/passwd to know the home of the users,
# so we rewrite the relative paths to be relative to the data dir.
LoadModule mod_rewrite.c
RewriteEngine on
RewriteCondition %m !USER
RewriteRule ^([^/]+.*) {{ proftpd['data-dir'] }}$1
{% endif %}
# Prevent partially uploaded files to be visible # Prevent partially uploaded files to be visible
HiddenStores on HiddenStores on
DeleteAbortedStores on DeleteAbortedStores on
......
{ {
"name": "ProFTPd", "name": "ProFTPd",
"description": "ProFTPd as a SFTP server with virtual users", "description": "ProFTPd as a SFTP server with virtual users",
"serialisation": "json-in-xml", "serialisation": "xml",
"software-type": { "software-type": {
"default": { "default": {
"title": "Default", "title": "Default",
......
...@@ -27,18 +27,23 @@ ...@@ -27,18 +27,23 @@
import os import os
import shutil import shutil
from urllib.parse import urlparse from urllib.parse import urlparse, parse_qs
import tempfile import tempfile
import io import io
import subprocess import subprocess
from http.server import BaseHTTPRequestHandler
import logging
import pysftp import pysftp
import psutil import psutil
import paramiko
from paramiko.ssh_exception import SSHException from paramiko.ssh_exception import SSHException
from paramiko.ssh_exception import AuthenticationException from paramiko.ssh_exception import AuthenticationException
from slapos.testing.testcase import makeModuleSetUpAndTestCaseClass from slapos.testing.testcase import makeModuleSetUpAndTestCaseClass
from slapos.testing.utils import findFreeTCPPort from slapos.testing.utils import findFreeTCPPort
from slapos.testing.utils import ManagedHTTPServer
setUpModule, SlapOSInstanceTestCase = makeModuleSetUpAndTestCaseClass( setUpModule, SlapOSInstanceTestCase = makeModuleSetUpAndTestCaseClass(
os.path.abspath( os.path.abspath(
...@@ -176,7 +181,7 @@ class TestUserManagement(ProFTPdTestCase): ...@@ -176,7 +181,7 @@ class TestUserManagement(ProFTPdTestCase):
class TestBan(ProFTPdTestCase): class TestBan(ProFTPdTestCase):
def test_client_are_banned_after_5_wrong_passwords(self): def test_client_are_banned_after_5_wrong_passwords(self):
# Simulate failed 5 login attempts # Simulate failed 5 login attempts
for i in range(5): for _ in range(5):
with self.assertRaisesRegex(AuthenticationException, with self.assertRaisesRegex(AuthenticationException,
'Authentication failed'): 'Authentication failed'):
self._getConnection(password='wrong') self._getConnection(password='wrong')
...@@ -237,3 +242,119 @@ class TestFilesAndSocketsInInstanceDir(ProFTPdTestCase): ...@@ -237,3 +242,119 @@ class TestFilesAndSocketsInInstanceDir(ProFTPdTestCase):
s for s in self.proftpdProcess.connections('unix') s for s in self.proftpdProcess.connections('unix')
if not s.laddr.startswith(self.computer_partition_root_path) if not s.laddr.startswith(self.computer_partition_root_path)
]) ])
class TestSSHKey(TestSFTPOperations):
@classmethod
def getInstanceParameterDict(cls):
cls.ssh_key = paramiko.DSSKey.generate(1024)
return {
'ssh-key':
'---- BEGIN SSH2 PUBLIC KEY ----\n{}\n---- END SSH2 PUBLIC KEY ----'.
format(cls.ssh_key.get_base64())
}
def _getConnection(self, username=None):
"""Override to log in with the SSH key
"""
parameter_dict = self.computer_partition.getConnectionParameterDict()
sftp_url = urlparse(parameter_dict['url'])
username = username or parameter_dict['username']
cnopts = pysftp.CnOpts()
cnopts.hostkeys = None
with tempfile.NamedTemporaryFile(mode='w') as keyfile:
self.ssh_key.write_private_key(keyfile)
keyfile.flush()
return pysftp.Connection(
sftp_url.hostname,
port=sftp_url.port,
cnopts=cnopts,
username=username,
private_key=keyfile.name,
)
def test_authentication_failure(self):
parameter_dict = self.computer_partition.getConnectionParameterDict()
sftp_url = urlparse(parameter_dict['url'])
with self.assertRaisesRegex(AuthenticationException,
'Authentication failed'):
self._getConnection(username='wrong username')
cnopts = pysftp.CnOpts()
cnopts.hostkeys = None
# wrong private key
with tempfile.NamedTemporaryFile(mode='w') as keyfile:
paramiko.DSSKey.generate(1024).write_private_key(keyfile)
keyfile.flush()
with self.assertRaisesRegex(AuthenticationException,
'Authentication failed'):
pysftp.Connection(
sftp_url.hostname,
port=sftp_url.port,
cnopts=cnopts,
username=parameter_dict['username'],
private_key=keyfile.name,
)
def test_published_parameters(self):
# no password is published, we only login with key
parameter_dict = self.computer_partition.getConnectionParameterDict()
self.assertIn('username', parameter_dict)
self.assertNotIn('password', parameter_dict)
class TestAuthenticationURL(TestSFTPOperations):
class AuthenticationServer(ManagedHTTPServer):
class RequestHandler(BaseHTTPRequestHandler):
def do_POST(self):
# type: () -> None
assert self.headers[
'Content-Type'] == 'application/x-www-form-urlencoded', self.headers[
'Content-Type']
posted_data = dict(
parse_qs(
self.rfile.read(int(self.headers['Content-Length'])).decode()))
if posted_data['login'] == ['login'] and posted_data['password'] == [
'password'
]:
self.send_response(200)
self.send_header("X-Proftpd-Authentication-Result", "Success")
self.end_headers()
return self.wfile.write(b"OK")
self.send_response(401)
return self.wfile.write(b"Forbidden")
log_message = logging.getLogger(__name__ + '.AuthenticationServer').info
@classmethod
def getInstanceParameterDict(cls):
return {
'authentication-url':
cls.getManagedResource('authentication-server',
TestAuthenticationURL.AuthenticationServer).url
}
def _getConnection(self, username='login', password='password'):
"""Override to log in with the HTTP credentials by default.
"""
return super()._getConnection(username=username, password=password)
def test_authentication_success(self):
with self._getConnection() as sftp:
self.assertEqual(sftp.listdir('.'), [])
def test_authentication_failure(self):
with self.assertRaisesRegex(AuthenticationException,
'Authentication failed'):
self._getConnection(username='login', password='wrong')
def test_published_parameters(self):
# no login or password are published, logins are defined by their
# user name
parameter_dict = self.computer_partition.getConnectionParameterDict()
self.assertNotIn('username', parameter_dict)
self.assertNotIn('password', parameter_dict)
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment