diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..7fdea582cc791299364567e2feb93c2cbe47390b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +*.egg-info diff --git a/authserv/__init__.py b/authserv/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cf1f4140e796779861f3fc208a2ac55f21634b36 --- /dev/null +++ b/authserv/__init__.py @@ -0,0 +1,2 @@ +from flask import Flask +app = Flask(__name__) diff --git a/authserv/auth.py b/authserv/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..68eb2f55433d335f615ec420b915e46c10223baa --- /dev/null +++ b/authserv/auth.py @@ -0,0 +1,48 @@ +import crypt +from oath import accept_totp +from authserv import protocol + + +def _check_main_password(userpw, password): + if crypt.crypt(password, userpw) == userpw: + return protocol.OK + else: + return protocol.ERR_AUTHENTICATION_FAILURE + + +def _check_app_specific_password(asps, password): + for app_pw in asps: + if crypt.crypt(password, app_pw) == app_pw: + return protocol.OK + return protocol.ERR_AUTHENTICATION_FAILURE + + +def _check_otp(totp_key, token): + try: + ok, drift = accept_totp(totp_key, token, format='dec6', + period=30, forward_drift=1, + backward_drift=1) + if ok: + return protocol.OK + except: + pass + return protocol.ERR_AUTHENTICATION_FAILURE + + +def authenticate(user, service, password, otp_token): + if not password: + return protocol.ERR_AUTHENTICATION_FAILURE + + if user.app_specific_passwords_enabled(): + if _check_app_specific_password(user.get_app_specific_passwords(service), + password) == protocol.OK: + return protocol.OK + + if user.otp_enabled(): + if not otp_token: + return protocol.ERR_OTP_REQUIRED + if _check_otp(user.get_totp_key(), otp_token) != protocol.OK: + return protocol.ERR_AUTHENTICATION_FAILURE + return _check_main_password(user.get_password(), password) + + return _check_main_password(user.get_password(), password) diff --git a/authserv/ldap_model.py b/authserv/ldap_model.py new file mode 100644 index 0000000000000000000000000000000000000000..f9ee8efe30a3a71d99c84346c3e428f4fcf1b484 --- /dev/null +++ b/authserv/ldap_model.py @@ -0,0 +1,97 @@ +import contextlib +import ldap +from ldap.ldapobject import ReconnectLDAPObj +from authserv import model + + +class Error(Exception): + pass + + +class UserDb(model.UserDb): + + ldap_attrs = [ + 'userPassword', + 'totpSecret', + 'appSpecificPassword', + ] + + def __init__(self, service_map, ldap_uri, ldap_bind_dn, ldap_bind_pw): + self.service_map = service_map + self.ldap_uri = ldap_uri + self.ldap_bind_dn = ldap_bind_dn + self.ldap_bind_pw = ldap_bind_pw + + @contextlib.contextmanager + def _conn(self): + c = ReconnectLDAPObj(self.ldap_uri) + c.protocol_version = ldap.VERSION3 + c.simple_bind_s(self.ldap_bind_dn, self.ldap_bind_pw) + yield c + c.unbind_s() + + def _query_user(self, username, service): + ldap_params = self.service_map.get(service) + if not ldap_params: + return None + + if callable(ldap_params['dn']): + basedn = ldap_params['dn'](username) + else: + basedn = ldap_params['dn'] + + with self._conn() as c: + result = c.search_s( + basedn, + ldap_params.get('scope', ldap.SCOPE_SUBTREE), + ldap_params['filter'].replace('%s', username), + self.ldap_attrs) + + if not result: + return None + if len(result) > 1: + raise Error('too many results from LDAP') + + return User(username, result[0][0], result[0][1]) + + def get_user(self, username, service): + try: + return User(username) + except (Error, ldap.LDAPError): + return None + + +class User(model.User): + + def __init__(self, username, dn, data): + self._username = username + self._dn = dn + self._otp_enabled = False + self._asps = [] + for key, values in data.iteritems(): + if key == 'userPassword': + self._password = values[0] + if self._password.startswith('{crypt}'): + self._password = self._password[7:] + elif key == 'totpSecret': + self._otp_enabled = True + self._totp_secret = values[0] + elif key == 'appSpecificPassword': + self.asps = [v.split(':', 2) for v in values] + + def otp_enabled(self): + return self._otp_enabled + + app_specific_passwords_enabled = otp_enabled + + def get_name(self): + return self._username + + def get_totp_key(self): + return self._totp_secret + + def get_app_specific_passwords(self, service): + return [x[2] for x in self._asps if x[0] == service] + + def get_password(self): + return self._password diff --git a/authserv/model.py b/authserv/model.py new file mode 100644 index 0000000000000000000000000000000000000000..ed394f8106a5b795f881ffde673d372c1345e160 --- /dev/null +++ b/authserv/model.py @@ -0,0 +1,34 @@ +import abc + + +class UserDb(object): + + __metaclass__ = abc.ABCMeta + + def get_user(self, username, service): + pass + + +class User(object): + + __metaclass__ = abc.ABCMeta + + def app_specific_passwords_enabled(self): + pass + + def otp_enabled(self): + pass + + def get_name(self): + pass + + def get_totp_key(self): + pass + + def get_app_specific_passwords(self, service): + pass + + def get_password(self): + pass + + diff --git a/authserv/protocol.py b/authserv/protocol.py new file mode 100644 index 0000000000000000000000000000000000000000..73dee075b16252e03b1149b67d03f47225158a56 --- /dev/null +++ b/authserv/protocol.py @@ -0,0 +1,4 @@ + +OK = 'OK' +ERR_OTP_REQUIRED = 'OTP_REQUIRED' +ERR_AUTHENTICATION_FAILURE = 'ERROR' diff --git a/authserv/ratelimit.py b/authserv/ratelimit.py new file mode 100644 index 0000000000000000000000000000000000000000..abcb0170f69c2c41a1f3356e39673b84cbada080 --- /dev/null +++ b/authserv/ratelimit.py @@ -0,0 +1,100 @@ +import functools +from flask import abort, request +from authserv import app +from authserv import protocol + + +class RateLimit(object): + + prefix = 'authserv/' + + def __init__(self, count, period): + self.count = count + self.period = period + + def check(self, mc, key): + key = self.prefix + key + result = mc.incr(key) + if result is None: + result = 1 + if not mc.add(key, result, time=self.period): + result = mc.incr(key) + if result is None: + # Memcache is failing. + return True + return result <= self.count + + +def ratelimit(header=None, param=None, count=0, period=0): + if not header and not param: + raise Exception('Must set either header or param') + rl = RateLimit(count, period) + required_parts = 2 + if header: + required_parts += 1 + if param: + required_parts += 1 + + def decoratorfn(fn): + @functools.wraps(fn) + def _ratelimit(*args, **kwargs): + parts = [request.method, request.path] + if header: + value = request.environ.get(header) + if value: + parts.append('%s=%s' % (header, value)) + if param: + value = request.form.get(param) + if value: + parts.append('%s=%s' % (param, value)) + if len(parts) >= required_parts: + key = ' '.join(parts) + if not rl.check(app.memcache, key): + app.logger.debug('ratelimited: %s', key) + abort(503) + return fn(*args, **kwargs) + return _ratelimit + return decoratorfn + + +class BlackList(object): + + prefix = 'bl/' + + def __init__(self, count, period, ttl): + self.rl = RateLimit(count, period) + self.ttl = ttl + + def check(self, mc, key): + key = self.prefix + key + result = mc.get(key) + return result is None + + def incr(self, mc, key): + key = self.prefix + key + if not self.rl.check(mc, key): + mc.add(key, 1, time=self.ttl) + + +def blacklist(args_idx=None, count=0, period=0, ttl=0): + if not args_idx: + raise Exception('Must set args_idx') + bl = BlackList(count, period, ttl) + + def decoratorfn(fn): + @functools.wraps(fn) + def _blacklist(*args, **kwargs): + parts = [args[x] for x in args_idx if args[x]] + if not parts: + return fn(*args, **kwargs) + key = ' '.join(parts) + if not bl.check(app.memcache, key): + app.logger.debug('blacklisted %s', key) + return protocol.ERR_AUTHENTICATION_FAILURE + result = fn(*args, **kwargs) + if result != protocol.OK: + bl.incr(app.memcache, key) + return result + return _blacklist + return decoratorfn + diff --git a/authserv/server.py b/authserv/server.py new file mode 100644 index 0000000000000000000000000000000000000000..07ebee5a9c12448458230ce5b944ef74edbd2fb4 --- /dev/null +++ b/authserv/server.py @@ -0,0 +1,65 @@ +from authserv import app +from authserv import auth +from authserv import protocol +from authserv.ratelimit import blacklist, ratelimit +from flask import Flask, request, abort, make_response + + +@blacklist([0], count=5, period=600, ttl=43200) +@blacklist([4], count=5, period=600, ttl=43200) +def _auth(username, service, password, otp_token, source_ip): + user = app.userdb.get_user(username, service) + if not user: + return protocol.ERR_AUTHENTICATION_FAILURE + return auth.authenticate(user, service, password, otp_token) + + +@app.route('/api/v1/auth', methods=('POST',)) +@ratelimit(count=10, period=60, header='HTTP_X_FORWARDED_FOR') +@ratelimit(count=10, period=60, param='username') +def do_auth(): + service = request.form.get('service') + username = request.form.get('username') + password = request.form.get('password') + otp_token = request.form.get('otp') + source_ip = request.form.get('source_ip') + + if not service or not username: + abort(400) + + try: + result = _auth(username, service, password, otp_token, source_ip) + except Exception, e: + app.logger.exception('Unexpected exception in authenticate()') + abort(500) + + app.logger.info( + 'AUTH %s %s otp=%s %s', + username, service, otp_token and 'y' or 'n', result) + + response = make_response(result) + response.headers['Cache-Control'] = 'no-cache' + response.headers['Content-Type'] = 'text/plain' + response.headers['Expires'] = '-1' + return response + + +def create_app(userdb=None, mc=None): + app.config.from_envvar('APP_CONFIG', silent=True) + + if not userdb: + from authserv import ldap_model + userdb = ldap_model.UserDb( + app.config['LDAP_SERVICE_MAP'], + app.config.get('LDAP_URI', 'ldap://127.0.0.1:389'), + app.config['LDAP_BIND_DN'], + app.config['LDAP_BIND_PW']) + app.userdb = userdb + + if not mc: + import memcache + mc = memcache.Client( + app.config['MEMCACHE_ADDR'], debug=0) + app.memcache = mc + + return app diff --git a/authserv/test/__init__.py b/authserv/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fbd6bf86a7d95c9261dad079252b40c620e4bc06 --- /dev/null +++ b/authserv/test/__init__.py @@ -0,0 +1,69 @@ +import crypt +import time +import unittest +from authserv import model + + +class FakeMemcache(object): + + def __init__(self, t=None): + self.t = t or time.time + self.data = {} + + def get(self, key): + result = self.data.get(key) + if result and result[1] > self.t(): + return result[0] + + def incr(self, key): + now = self.t() + result = self.data.get(key) + if result and result[1] > now: + result = (result[0] + 1, result[1]) + else: + return None + self.data[key] = result + return result[0] + + def add(self, key, value, time=0): + if key in self.data: + return None + self.data[key] = (value, self.t() + time) + return value + + +class FakeUser(model.User): + + def __init__(self, username, password=None, asps=None, otp_key=None): + self.username = username + self.password = crypt.crypt(password, '$6$abcdef1234567890') + self.asps = asps + self.otp_key = otp_key + + def otp_enabled(self): + return self.otp_key is not None + + def get_totp_key(self): + return self.otp_key + + def app_specific_passwords_enabled(self): + return bool(self.asps) + + def get_app_specific_passwords(self, service): + return [p[1] for p in self.asps if p[0] == service] + + def get_password(self): + return self.password + + def get_name(self): + return self.username + + +class FakeUserDb(model.UserDb): + + def __init__(self, users): + self.users = users + + def get_user(self, username, service): + return self.users.get(username) + diff --git a/authserv/test/test_auth.py b/authserv/test/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..e663a56a90022fad15e857d7416ad496e7bbc5e9 --- /dev/null +++ b/authserv/test/test_auth.py @@ -0,0 +1,63 @@ +from authserv.test import * +from authserv.auth import authenticate +from authserv import protocol +from oath import totp + + +class AuthTest(unittest.TestCase): + + def test_main_password_ok(self): + u = FakeUser('user', 'pass') + self.assertEquals( + protocol.OK, + authenticate(u, 'svc', 'pass', None)) + + def test_main_password_fail(self): + u = FakeUser('user', 'pass') + self.assertEquals( + protocol.ERR_AUTHENTICATION_FAILURE, + authenticate(u, 'svc', 'badpass', None)) + + def test_require_otp(self): + u = FakeUser('user', 'pass', otp_key=1234) + self.assertEquals( + protocol.ERR_OTP_REQUIRED, + authenticate(u, 'svc', 'pass', None)) + self.assertEquals( + protocol.ERR_OTP_REQUIRED, + authenticate(u, 'svc', 'badpass', None)) + + def test_otp_ok(self): + secret = '089421' + token = totp(secret, format='dec6', period=30) + print 'otp is', token + u = FakeUser('user', 'pass', otp_key=secret) + self.assertEquals( + protocol.OK, + authenticate(u, 'svc', 'pass', str(token))) + + def test_otp_fail(self): + u = FakeUser('user', 'pass', otp_key='123456') + self.assertEquals( + protocol.ERR_AUTHENTICATION_FAILURE, + authenticate(u, 'svc', 'pass', '123456')) + self.assertEquals( + protocol.ERR_AUTHENTICATION_FAILURE, + authenticate(u, 'svc', 'pass', 'malformed otp token!')) + + def test_app_specific_password_ok(self): + u = FakeUser('user', 'pass', asps=[ + ('svc', crypt.crypt('app-specific', 'zz'))]) + self.assertEquals( + protocol.OK, + authenticate(u, 'svc', 'app-specific', None)) + + def test_app_specific_password_fail(self): + u = FakeUser('user', 'pass', asps=[ + ('svc', crypt.crypt('app-specific', 'zz'))]) + self.assertEquals( + protocol.ERR_AUTHENTICATION_FAILURE, + authenticate(u, 'svc', 'badpass', None)) + self.assertEquals( + protocol.ERR_AUTHENTICATION_FAILURE, + authenticate(u, 'svc2', 'app-specific', None)) diff --git a/authserv/test/test_ratelimit.py b/authserv/test/test_ratelimit.py new file mode 100644 index 0000000000000000000000000000000000000000..c6030bad73c4ee02f90318df496e294bbc4ebff2 --- /dev/null +++ b/authserv/test/test_ratelimit.py @@ -0,0 +1,30 @@ +from authserv.test import * +from authserv.ratelimit import * + + +class RateLimitTest(unittest.TestCase): + + def setUp(self): + self.tick = 0 + def _time(): + return self.tick + self.mc = FakeMemcache(_time) + self.rl = RateLimit(100, 10) + + def test_ratelimit_pass(self): + n = 200 + ok = 0 + for i in xrange(n): + self.tick = i + if self.rl.check(self.mc, 'key'): + ok += 1 + self.assertEquals(n, ok) + + def test_ratelimit_fail(self): + n = 200 + ok = 0 + for i in xrange(n): + self.tick = i / 20 + if self.rl.check(self.mc, 'key'): + ok += 1 + self.assertEquals(100, ok) diff --git a/authserv/test/test_server.py b/authserv/test/test_server.py new file mode 100644 index 0000000000000000000000000000000000000000..16950a550eedccedc97c0a23dee99cc279ea0060 --- /dev/null +++ b/authserv/test/test_server.py @@ -0,0 +1,160 @@ +from authserv.test import * +from authserv.ratelimit import * +from authserv import protocol +from authserv import server + +URL = '/api/v1/auth' + + +class ServerTest(unittest.TestCase): + + def setUp(self): + self.tick = 0 + def _time(): + return self.tick + self.users = { + 'user': FakeUser('user', 'pass'), + } + app = server.create_app(userdb=FakeUserDb(self.users), + mc=FakeMemcache(_time)) + app.config.update({ + 'TESTING': True, + 'DEBUG': True, + }) + self.app = app.test_client() + + def test_auth_simple_ok(self): + response = self.app.post( + URL, data={ + 'username': 'user', + 'password': 'pass', + 'service': 'svc'}) + self.assertEquals(protocol.OK, response.data) + + def test_auth_simple_fail(self): + response = self.app.post( + URL, data={ + 'username': 'user', + 'password': 'badpass', + 'service': 'svc'}) + self.assertEquals(protocol.ERR_AUTHENTICATION_FAILURE, + response.data) + + def test_malformed_requests(self): + bad_data = [ + {'username': 'user'}, + {'username': '', 'service': 'svc'}, + {'username': 'user', 'otp': '1234'}, + ] + for data in bad_data: + response = self.app.post(URL, data=data) + self.assertEquals(400, response.status_code) + + def _create_many_users(self, n): + for i in xrange(n): + self.users['user%d' % i] = FakeUser('user%d' % i, 'pass') + + def test_ratelimit_by_client_ip(self): + n = 20 + ok = 0 + self._create_many_users(n) + for i in xrange(n): + response = self.app.post(URL, data={ + 'username': 'user%d' % i, + 'password': 'pass', + 'service': 'svc'}, headers={ + 'X-Forwarded-For': '1.2.3.4'}) + if response.status_code == 200: + ok += 1 + self.assertEquals(10, ok) + + def test_ratelimit_by_username(self): + n = 20 + ok = 0 + for i in xrange(n): + response = self.app.post(URL, data={ + 'username': 'user', + 'password': 'pass', + 'service': 'svc'}, headers={ + 'X-Forwarded-For': '1.2.3.%d' %i}) + if response.status_code == 200: + ok += 1 + self.assertEquals(10, ok) + + def test_ratelimit_ignores_unset_fields(self): + # This test will fail if the @ratelimit decorator does not + # skip its check if one of the fields is unset (in this case, + # the X-Forwarded-For header). + n = 20 + ok = 0 + self._create_many_users(n) + for i in xrange(n): + response = self.app.post(URL, data={ + 'username': 'user%d' % i, + 'password': 'pass', + 'service': 'svc'}) + if response.status_code == 200: + ok += 1 + self.assertEquals(n, ok) + + def test_blacklist_by_username(self): + # Create 6 failed logins. + for i in xrange(6): + self.tick += 1 + response = self.app.post(URL, data={ + 'username': 'user', 'password': 'badpass', 'service': 'svc'}) + self.assertEquals(200, response.status_code) + self.assertEquals(protocol.ERR_AUTHENTICATION_FAILURE, response.data) + + # Now check that all logins fail for at least an hour, even + # with the right credentials. + for i in xrange(60): + self.tick += 60 + response = self.app.post(URL, data={ + 'username': 'user', 'password': 'pass', 'service': 'svc'}) + self.assertEquals(200, response.status_code) + self.assertEquals(protocol.ERR_AUTHENTICATION_FAILURE, response.data, + 'failed at %d (t %d)' % (i, self.tick)) + + # Expire everything. + self.tick += 2592000 + + # Check that a smaller number of failures does not trigger the + # blacklist. + for i in xrange(3): + self.tick += 1 + response = self.app.post(URL, data={ + 'username': 'user', 'password': 'badpass', 'service': 'svc'}) + self.assertEquals(200, response.status_code) + self.assertEquals(protocol.ERR_AUTHENTICATION_FAILURE, response.data) + + self.tick += 60 + response = self.app.post(URL, data={ + 'username': 'user', 'password': 'pass', 'service': 'svc'}) + self.assertEquals(200, response.status_code) + self.assertEquals(protocol.OK, response.data) + + def test_blacklist_by_source_ip(self): + self._create_many_users(60) + + # Create 6 failed logins. + for i in xrange(6): + self.tick += 1 + response = self.app.post(URL, data={ + 'username': 'user%d' % i, + 'password': 'badpass', + 'service': 'svc', + 'source_ip': '1.2.3.4'}) + self.assertEquals(200, response.status_code) + self.assertEquals(protocol.ERR_AUTHENTICATION_FAILURE, response.data) + + # Now check that all logins fail for at least an hour, even + # with the right credentials. + for i in xrange(60): + self.tick += 60 + response = self.app.post(URL, data={ + 'username': 'user%d' % i, 'password': 'pass', + 'service': 'svc', 'source_ip': '1.2.3.4'}) + self.assertEquals(200, response.status_code) + self.assertEquals(protocol.ERR_AUTHENTICATION_FAILURE, response.data, + 'failed at %d (t %d)' % (i, self.tick)) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..9322abfbb8ad0480c3b02f5d5bb2f65ad47d7023 --- /dev/null +++ b/setup.py @@ -0,0 +1,23 @@ +#!/usr/bin/python + +from setuptools import setup, find_packages + +setup( + name="authserv", + version="0.1", + description="Authentication server", + author="Autistici/Inventati", + author_email="info@autistici.org", + url="https://git.autistici.org/ai/authserv", + install_requires=["Flask", "python-memcached", "oath", "nose"], + setup_requires=[], + zip_safe=False, + packages=find_packages(), + package_data={}, + entry_points={ + 'console_scripts': [ + 'tapd = tap.server:main', + ], + } + ) +