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',
+          ],
+        }
+    )
+