Commit caafba05 authored by Jérome Perrin's avatar Jérome Perrin

software/proftpd: support user management and authentication with a web service

The web service will receive 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.

The sftp server will map all logins to the same user.
parent 59fa7ee7
......@@ -31,7 +31,7 @@ configure-options =
--enable-ctrls
--enable-dso
--disable-cap
--with-modules=mod_sftp:mod_ban
--with-modules=mod_sftp:mod_ban:mod_rewrite
environment =
CFLAGS=-DPR_RUN_DIR=\"/proc/self/cwd/var\"
CPPFLAGS=-I${zlib:location}/include -I${openssl:location}/include
......
......@@ -7,11 +7,11 @@ http://www.proftpd.org/docs/
* 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 `.` )
* 5 failed login attempts will cause the host to be temporary banned
* support authentication against an external web service
# TODO
* log rotation
* 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
......@@ -19,8 +19,8 @@ md5sum = efb4238229681447aa7fe73898dffad4
[instance-default]
filename = instance-default.cfg.in
md5sum = 830a2e759d64b01ddcf593467493abce
md5sum = dae19ec06f8da9fa2980a6d2bdf3da54
[proftpd-config-file]
filename = proftpd-config-file.cfg.in
md5sum = 336bad8d0283739be9e0e62da445f33e
md5sum = 82cc600f4fce9852370f9d1f7c4cd3a6
......@@ -70,6 +70,7 @@ ssh-authorized-key = ${ssh-authorized-keys:rendered}
ban-table=${directory:srv}/proftpd-ban-table
control-socket=${directory:var}/proftpd.sock
auth-user-file=${auth-user-file:output}
authentication-url = {{ slapparameter_dict.get('authentication-url', '')}}
recipe = slapos.cookbook:wrapper
command-line =
......@@ -140,7 +141,9 @@ instance-promises =
[publish-connection-parameter]
recipe = slapos.cookbook:publish
url = ${proftpd:url}
{% if not slapparameter_dict.get('authentication-url') %}
username = ${proftpd-password:username}
{% if not slapparameter_dict.get('ssh-key') %}
{% if not slapparameter_dict.get('ssh-key') %}
password = ${proftpd-password:passwd}
{% endif %}
{% endif %}
......@@ -11,6 +11,10 @@
"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"
}
}
}
......@@ -10,7 +10,8 @@
},
"username": {
"description": "Default username",
"type": "string"
"type": "string",
"optional": true
},
"password": {
"description": "Password for default username, when not using ssh-key",
......
......@@ -34,6 +34,23 @@ RequireValidShell off
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
HiddenStores on
DeleteAbortedStores on
......
......@@ -27,10 +27,12 @@
import os
import shutil
from urllib.parse import urlparse
from urllib.parse import urlparse, parse_qs
import tempfile
import io
import subprocess
from http.server import BaseHTTPRequestHandler
import logging
import pysftp
import psutil
......@@ -40,6 +42,7 @@ from paramiko.ssh_exception import AuthenticationException
from slapos.testing.testcase import makeModuleSetUpAndTestCaseClass
from slapos.testing.utils import findFreeTCPPort
from slapos.testing.utils import ManagedHTTPServer
setUpModule, SlapOSInstanceTestCase = makeModuleSetUpAndTestCaseClass(
......@@ -302,3 +305,56 @@ class TestSSHKey(TestSFTPOperations):
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