Commit f953be86 authored by ale's avatar ale
Browse files

Initial commit

parents
*.pyc
*.egg-info
.coverage
.tox
__pycache__
include: "https://git.autistici.org/ai3/build-deb/raw/master/ci-common.yml"
stages:
- test
- build_pkgsrc
- build_pkg
- upload_pkg
run_tests:
stage: test
image: "debian:stretch"
script:
- apt-get update
- env DEBIAN_FRONTEND=noninteractive apt-get install -qy --no-install-recommends python python-dev python-pip python-tox libldap2-dev libsasl2-dev default-jre-headless build-essential
- tox -e py27
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
webdav-auth
===
The authentication server bridges the [users-dav](https://git.autistici.org/ai3/docker/users-dav)
container (which runs the [webdav-server](https://git.autistici.org/ai3/tools/webdav-server) component)
and the local [auth-server](https://git.autistici.org/id/auth).
It runs as a system-level daemon.
"""ai-webdav-auth-server
Simple authentication daemon for our WebDAV servers, that runs in an
isolated chroot and should not have the credentials to access LDAP
directly.
This server supports two kinds of requests (see dav.fcgi for the
client-side logic):
* given a UID, retrieve the known DAV accounts
* authenticate with UID/realm/username/password
Requests are sent through a UNIX socket, so that the server can know
the user ID of the process that made the request (the WebDAV FastCGI
runner).
"""
import contextlib
import grp
import json
import ldap
import ldap.dn
import logging
import logging.handlers
import optparse
import os
import pam
import pwd
import socket
import struct
import threading
try:
import SocketServer
except ImportError:
import socketserver as SocketServer
# Taken from <sys/socket.h>. Python 2 does not have this constant.
SO_PEERCRED = 17
# Default LDAP URI.
LDAP_DEFAULT_URI = 'ldapi:///var/run/ldap/ldapi'
# LDAP base UID.
LDAP_DEFAULT_BASE_UID = 'ou=People, dc=investici, dc=org, o=Anarchy'
# Service used for PAM.
PAM_SERVICE = 'dav'
pamh = pam.pam()
def getpeercred(sock):
# This is Linux-specific.
data = sock.getsockopt(socket.SOL_SOCKET, SO_PEERCRED, struct.calcsize('3i'))
return struct.unpack('3i', data)
def ldap_bind(conn, dn, pw):
# Separated for easier testing.
conn.simple_bind_s(dn, pw)
class DavAuthServer(SocketServer.ThreadingMixIn,
SocketServer.UnixStreamServer):
ldap_params = {
'uri': LDAP_DEFAULT_URI,
'base_uid': LDAP_DEFAULT_BASE_UID,
'bind_dn': None,
'bind_pw': None,
}
@contextlib.contextmanager
def ldapconn(self, bind_dn=None, bind_pw=None):
logging.info('LDAP connection to %s', self.ldap_params['uri'])
c = ldap.initialize(self.ldap_params['uri'])
try:
if bind_dn:
ldap_bind(c, bind_dn, bind_pw)
elif self.ldap_params.get('bind_dn'):
ldap_bind(c, self.ldap_params['bind_dn'], self.ldap_params['bind_pw'])
yield c
finally:
c.unbind()
class DavAuthHandler(SocketServer.StreamRequestHandler):
shard_id = None
def handle(self):
pid, uid, gid = getpeercred(self.request)
logging.info('request from uid=%d gid=%d pid=%d', uid, gid, pid)
try:
request = json.load(self.rfile)
if request['type'] == 'auth':
response = self.authenticate(uid, request['dn'], request['password'])
elif request['type'] == 'get_accounts':
response = self.get_accounts(uid)
else:
raise ValueError('Unknown request type')
response_data = json.dumps(response)
self.wfile.write(response_data.encode('utf-8'))
except Exception as e:
logging.exception('request error from uid %d: %s', uid, e)
def authenticate(self, uid, dn, password):
# An empty password causes a "server is unwilling to perform"
# error, so we might just as well handle it now.
if not password:
return 'error'
try:
# Check the account exists on this host and has the right
# UID. This is necessary so you can't log in to other
# people's repositories!
with self.server.ldapconn() as l:
result = l.search_s(dn, ldap.SCOPE_BASE,
'(&(host=%s)(uidNumber=%d))' % (self.shard_id, uid))
if len(result) == 0:
logging.error('account for %s uid %d not found on %s', dn,
uid, self.shard_id)
return 'error'
# Defer the actual authentication to PAM, so we don't have
# to actually check the password ourselves. Get the
# username from the dn (ftpname=).
username = ldap.dn.explode_dn(dn)[0][len("ftpname="):]
if pamh.authenticate(username, password, service=PAM_SERVICE):
return 'ok'
logging.error('authentication failure for %s (uid=%d)', dn, uid)
return 'error'
except ldap.NO_SUCH_OBJECT:
logging.error('cannot find object for %s (uid=%d)', dn, uid)
return 'error'
# Let other errors pass through and get caught (and logged) by the generic
# exception handler one level up.
return 'error'
def get_accounts(self, uid):
with self.server.ldapconn() as l:
accounts = {}
result = l.search_s(
self.server.ldap_params['base_uid'], ldap.SCOPE_SUBTREE,
'(&(objectClass=ftpAccount)(status=active)(uidNumber=%d)(host=%s))' % (uid, self.shard_id),
['ftpname', 'homeDirectory'])
for r in result:
name = r[1]['ftpname'][0].decode('utf-8')
realm = '/dav/' + name
accounts[realm] = {'dn': r[0],
'ftpname': name,
'home': r[1]['homeDirectory'][0].decode('utf-8')}
return accounts
def run_server(socket_path, username, group, shard_id):
# Set a permissive umask for the (eventual) mkdir.
old_umask = os.umask(0o022)
# Create the socket directory if it does not exist.
socket_dir = os.path.dirname(socket_path)
if not os.path.exists(socket_dir):
os.makedirs(socket_dir, 0o755)
# Delete the socket if it already exists.
if os.path.exists(socket_path):
os.unlink(socket_path)
# Set the umask appropriately so that the socket has mode 777.
os.umask(0)
server = DavAuthServer(socket_path, DavAuthHandler)
os.umask(old_umask)
if username and group:
drop_privs(username, group)
server.serve_forever()
def drop_privs(username, group):
if os.getuid() != 0:
return
userobj = pwd.getpwnam(username)
grpobj = grp.getgrnam(group)
os.setgid(grpobj.gr_gid)
os.initgroups(username, grpobj.gr_gid)
os.setuid(userobj.pw_uid)
def main():
parser = optparse.OptionParser()
parser.add_option('--ldap-uri', dest='ldap_uri', default=LDAP_DEFAULT_URI,
metavar='URI', help='LDAP URI connection string')
parser.add_option('--ldap-bind-dn', dest='ldap_bind_dn',
metavar='DN', help='LDAP bind DN')
parser.add_option('--ldap-bind-pw', dest='ldap_bind_pw',
metavar='PASS', help='LDAP bind password')
parser.add_option('--ldap-bind-pwfile', dest='ldap_bind_pwfile',
metavar='FILE', default='/etc/ldap.secret',
help='Read LDAP bind password from this file')
parser.add_option('--ldap-base-uid', dest='ldap_base_uid',
metavar='DN', default=LDAP_DEFAULT_BASE_UID,
help='Default LDAP base DN')
parser.add_option('--shard-id', dest='shard_id',
metavar='ID', default=None,
help='Consider accounts for this shard id')
parser.add_option('--user',
help='Run as a different user')
parser.add_option('--group',
help='Run as a different group')
parser.add_option('--socket', dest='socket_path',
default='/var/run/authdav/auth', metavar='PATH',
help='Location of the listening UNIX socket')
opts, args = parser.parse_args()
if len(args) != 0:
parser.error('Too many arguments')
# Set LDAP params.
if opts.ldap_uri:
DavAuthServer.ldap_params['uri'] = opts.ldap_uri
if opts.ldap_base_uid:
DavAuthServer.ldap_params['base_uid'] = opts.ldap_base_uid
if opts.ldap_bind_dn:
DavAuthServer.ldap_params['bind_dn'] = opts.ldap_bind_dn
if opts.ldap_bind_pw:
DavAuthServer.ldap_params['bind_pw'] = opts.ldap_bind_pw
elif opts.ldap_bind_pwfile:
with open(opts.ldap_bind_pwfile) as fd:
DavAuthServer.ldap_params['bind_pw'] = fd.read().strip()
else:
parser.error('You must specify a bind password')
if opts.shard_id:
DavAuthHandler.shard_id = opts.shard_id
# Configure syslog logging.
handler = logging.handlers.SysLogHandler(
address='/dev/log',
facility=logging.handlers.SysLogHandler.LOG_DAEMON)
handler.setFormatter(
logging.Formatter('auth_server_dav: %(message)s'))
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(handler)
run_server(opts.socket_path, opts.user, opts.group, opts.shard_id)
if __name__ == '__main__':
main()
import json
import socket
class DavAuthClient(object):
def __init__(self, socketpath):
self.socketpath = socketpath
self._accounts = None
def _do_request(self, request):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.connect(self.socketpath)
sock.sendall(json.dumps(request).encode('utf-8'))
sock.shutdown(socket.SHUT_WR)
return json.load(sock.makefile('r'))
finally:
sock.close()
def authenticate(self, dn, password):
return self._do_request({
'type': 'auth', 'dn': dn, 'password': password})
def get_accounts(self):
# Cache the account map.
if self._accounts is None:
self._accounts = self._do_request({'type': 'get_accounts'})
return self._accounts
import logging
import os
import socket
import unittest
from ldap_test import LdapServer
# Attributes of the 'test-user.ldif' LDAP fixture.
TEST_DN = 'ftpname=testdav,uid=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy'
TEST_FTPNAME = 'testdav'
TEST_UID = 19475
TEST_PASS = 'password'
TEST_HOST = 'latitanza'
class LdapTestBase(unittest.TestCase):
LDIFS = []
@classmethod
def setup_class(cls):
# Start the local LDAP server.
cls.ldap_port = free_port()
cls.ldap_password = 'testpass'
ldifs = [os.path.join(os.path.dirname(__file__), 'fixtures', x)
for x in ['base.ldif'] + cls.LDIFS]
logging.getLogger('py4j.java_gateway').setLevel(logging.ERROR)
cls.server = LdapServer({
'port': cls.ldap_port,
'bind_dn': 'cn=manager,o=Anarchy',
'password': cls.ldap_password,
'base': {
'objectclass': ['organization'],
'dn': 'o=Anarchy',
'attributes': {'o': 'Anarchy'},
},
'ldifs': ldifs,
})
cls.server.start()
@classmethod
def teardown_class(cls):
cls.server.stop()
def free_port():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)
s.bind(('', 0))
port = s.getsockname()[1]
s.close()
return port
class StubBase(object):
def setUp(self):
self._stubs = []
super(StubBase, self).setUp()
def tearDown(self):
super(StubBase, self).tearDown()
for baseref, attr, fn in self._stubs:
setattr(baseref, attr, fn)
def stub(self, baseref, attr, fn):
oldfn = getattr(baseref, attr)
self._stubs.append((baseref, attr, oldfn))
setattr(baseref, attr, fn)
dn: o=Anarchy
changetype: add
o: Anarchy
objectClass: top
objectClass: organization
dn: dc=org,o=Anarchy
changetype: add
dc: org
objectClass: top
objectClass: domain
dn: dc=investici,dc=org,o=Anarchy
changetype: add
dc: investici
objectClass: top
objectClass: domain
dn: ou=People,dc=investici,dc=org,o=Anarchy
changetype: add
ou: People
objectClass: top
objectClass: organizationalUnit
description: Investici people
dn: ou=Categories,dc=investici,dc=org,o=Anarchy
changetype: add
ou: Categories
objectClass: top
objectClass: organizationalUnit
description: Site categories
dn: ou=Operators,dc=investici,dc=org,o=Anarchy
changetype: add
ou: Operators
objectClass: top
objectClass: organizationalUnit
description: Autonomous software agents
dn: ou=Group,dc=investici,dc=org,o=Anarchy
changetype: add
ou: Group
objectClass: organizationalUnit
dn: ou=Lists,dc=investici,dc=org,o=Anarchy
changetype: add
ou: Lists
objectClass: top
objectClass: organizationalUnit
description: Mailing Lists
dn: ou=Newsletters,dc=investici,dc=org,o=Anarchy
changetype: add
ou: Newsletters
objectClass: top
objectClass: organizationalUnit
description: Newsletters
dn: ou=Domains,dc=investici,dc=org,o=Anarchy
changetype: add
ou: Domains
objectClass: top
objectClass: organizationalUnit
description: Investici domains
dn: cn=autistici.org,ou=Domains,dc=investici,dc=org,o=Anarchy
changetype: add
cn: autistici.org
objectClass: top
objectClass: investiciDomain
acceptMail: true
public: yes
dn: cn=inventati.org,ou=Domains,dc=investici,dc=org,o=Anarchy
changetype: add
cn: inventati.org
objectClass: top
objectClass: investiciDomain
acceptMail: true
public: yes
dn: cn=investici.org,ou=Domains,dc=investici,dc=org,o=Anarchy
changetype: add
cn: investici.org
objectClass: top
objectClass: investiciDomain
acceptMail: true
public: no
dn: cn=anche.no,ou=Domains,dc=investici,dc=org,o=Anarchy
changetype: add
cn: anche.no
objectClass: top
objectClass: investiciDomain
acceptMail: true
public: yes
dn: uid=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy
changetype: add
cn: test@investici.org
objectClass: top
objectClass: person
objectClass: posixAccount
objectClass: shadowAccount
objectClass: organizationalPerson
objectClass: inetOrgPerson
loginShell: /bin/false
uidNumber: 19475
shadowMax: 99999
preferredLanguage: en
gidNumber: 2000
gecos: test@investici.org
sn: Private
homeDirectory: /var/empty
uid: test@investici.org
givenName: Private
shadowLastChange: 12345
shadowWarning: 7
userPassword:: e2NyeXB0fXp6WFVIZlVSbkdnOEk=
dn: ftpname=testdav,uid=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy
changetype: add
status: active
cn: testdav
objectClass: top
objectClass: person
objectClass: posixAccount
objectClass: shadowAccount
objectClass: organizationalPerson
objectClass: inetOrgPerson
objectClass: ftpAccount
loginShell: /bin/false
shadowWarning: 7
uidNumber: 19475
host: latitanza
shadowMax: 99999
ftpname: testdav
gidNumber: 33
gecos: FTP Account for
sn: Private
homeDirectory: /home/users/investici.org/testdav
uid: testdav
givenName: Private
shadowLastChange: 12345
originalHost: latitanza
userPassword:: e2NyeXB0fXp6WFVIZlVSbkdnOEk=
import json
import ldap
import mock
import os
import socket
import shutil
import tempfile
import threading
import unittest
from ai_webdav_auth import auth_server
from ai_webdav_auth.client import DavAuthClient
from ai_webdav_auth.test import *
class DavAuthServerTestBase(StubBase, LdapTestBase):
LDIFS = ['test-user.ldif']
def setUp(self):
super(DavAuthServerTestBase, self).setUp()
self.tmpdir = tempfile.mkdtemp()
self.socketpath = os.path.join(self.tmpdir, 'socket')
srv = auth_server.DavAuthServer(self.socketpath, auth_server.DavAuthHandler)
srv.ldap_params['uri'] = 'ldap://127.0.0.1:%d' % self.ldap_port
srv_t = threading.Thread(target=srv.serve_forever)
srv_t.setDaemon(True)
srv_t.start()
# The test LDAP server must always bind as the admin user, it
# is not capable of letting clients authenticate against
# arbitrary objects (unfortunately). This makes password
# checks moot, as they will always succeed: in order to test
# an authentication failure, stub out this method so that it
# raises an ldap.INVALID_CREDENTIALS exception (see an example
# in test_authenticate_bad_password below).
def _admin_bind(conn, dn, pw):
conn.simple_bind_s('cn=manager,o=Anarchy', 'testpass')
self.stub(auth_server, 'ldap_bind', _admin_bind)
# Force the local hostname to what's in LDAP.
auth_server.DavAuthHandler.shard_id = TEST_HOST
def tearDown(self):
shutil.rmtree(self.tmpdir)
super(DavAuthServerTestBase, self).tearDown()
class TestDavAuthServer(DavAuthServerTestBase):
@mock.patch('ai_webdav_auth.auth_server.getpeercred', return_value=(1, 1, 1))
def test_get_accounts_for_unknown_user(self, gpc):
resp = DavAuthClient(self.socketpath).get_accounts()
self.assertEquals({}, resp)
@mock.patch('ai_webdav_auth.auth_server.getpeercred', return_value=(1, TEST_UID, 1))
def test_get_accounts(self, gpc):
resp = DavAuthClient(self.socketpath).get_accounts()
expected = {
'/dav/testdav': {
'dn': TEST_DN,
'ftpname': 'testdav',
'home': '/home/users/investici.org/testdav',