summaryrefslogtreecommitdiffstats
path: root/accounts/backend
diff options
context:
space:
mode:
authorAlexander Sulfrian <alexander@sulfrian.net>2016-01-23 20:17:36 +0100
committerAlexander Sulfrian <alexander@sulfrian.net>2016-01-25 01:56:49 +0100
commit9f24d8bd26e7dd3b7e36294edee31be7a37fa650 (patch)
tree85f50a093f70d36fd58c52072ba2e5ccdaef91e7 /accounts/backend
parentea3983d891bc6e34a827902ac8cf15734923e14c (diff)
downloadweb-9f24d8bd26e7dd3b7e36294edee31be7a37fa650.tar.gz
web-9f24d8bd26e7dd3b7e36294edee31be7a37fa650.tar.bz2
web-9f24d8bd26e7dd3b7e36294edee31be7a37fa650.zip
backend/user: Allow different backends for users
Diffstat (limited to 'accounts/backend')
-rw-r--r--accounts/backend/user/__init__.py116
-rw-r--r--accounts/backend/user/dummy.py104
-rw-r--r--accounts/backend/user/ldap.py230
3 files changed, 450 insertions, 0 deletions
diff --git a/accounts/backend/user/__init__.py b/accounts/backend/user/__init__.py
new file mode 100644
index 0000000..749f284
--- /dev/null
+++ b/accounts/backend/user/__init__.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+
+
+class NoSuchUserError(ValueError):
+ pass
+
+
+class ShouldNotHappen(RuntimeError):
+ pass
+
+
+class Backend(object):
+ """
+ This is the backend class for the account management. It is
+ stateless, so every request needs the authentication data again.
+
+ * register a new user
+ >> backend = Backend(app)
+ >> foo = Account('foo','foo@bar.de', password='bar')
+ >> backend.register(foo)
+
+ * authenticate a new user
+ >> backend = Backend(app)
+ >> foo = backend.auth('foo', 'bar')
+
+ * updates an account
+ >> foo.change_mail('a@b.de')
+ >> foo.change_password('newpw','oldpw') # changes root password
+ >> foo.change_password('newpw','oldpw', 'gitlab') # changes password for gitlab
+ >> backend.update(foo) # save changes in the backend
+ # save changes in the backend as admin user (no need for old password)
+ >> backend.update(foo, as_admin=True)
+
+ * delete an account
+ >> backend = Backend(app)
+ >> backend.delete(Account)
+ >> backend.delete('foo')
+
+ * find accounts
+ >> backend = Backend(app)
+ >> all_accounts = backend.find() # find all accounts
+ >> print([x.uid for x in all_accounts])
+ >> backend.find_by_uid('test') # find users by uid
+ >> backend.get_by_uid('test') # same, raise NoSuchUserError if no match
+ >> backend.find_by_mail('test@test.de') # find users by mail
+ >> backend.find_by_uid('test*', wildcard=True) # find with wildcards
+ """
+
+ def __init__(self, app):
+ self.app = app
+
+ #: Exception type, that is raised if no matching user was found.
+ self.NoSuchUserError = NoSuchUserError
+
+ def auth(self, username, password):
+ """
+ Tries to authenticate a user with a given password. If the
+ authentication is successful an Account object will be returned.
+ """
+ raise NotImplementedError()
+
+ def get_by_uid(self, uid):
+ """
+ Find a single user by uid. Unlike find_by_uid, don't return a list but
+ raise NoSuchUserError if there is no such user.
+ """
+ users = self.find_by_uid(uid)
+ if len(users) == 0:
+ raise NoSuchUserError('No such user')
+ if len(users) > 1:
+ raise ShouldNotHappen('Several users for one uid returned.')
+
+ return users[0]
+
+ def get_by_mail(self, mail):
+ """
+ Find a single user by mail. Unlike find_by_mail, don't return a list but
+ raise NoSuchUserError if there is no such user.
+ """
+ users = self.find_by_mail(mail)
+ if len(users) == 0:
+ raise NoSuchUserError('No such user')
+ if len(users) > 1:
+ raise ShouldNotHappen('Several users for one mail returned.')
+
+ return users[0]
+
+ def find_by_uid(self, uid, wildcard=False):
+ return self.find({'uid': uid}, wildcard)
+
+ def find_by_mail(self, mail, wildcard=False):
+ return self.find({'mail': mail}, wildcard)
+
+ def find(self, filters=None, wildcard=False):
+ """
+ Find accounts by a given filter.
+ """
+ raise NotImplementedError()
+
+ def register(self, account):
+ """
+ Persists an account in the backend.
+ """
+ raise NotImplementedError()
+
+ def update(self, account, as_admin=False):
+ """
+ Updates account information like passwords or email.
+ """
+ raise NotImplementedError()
+
+ def delete(self, account, password=None, as_admin=False):
+ """
+ Deletes an account permanently.
+ """
+ raise NotImplementedError()
diff --git a/accounts/backend/user/dummy.py b/accounts/backend/user/dummy.py
new file mode 100644
index 0000000..c4925fb
--- /dev/null
+++ b/accounts/backend/user/dummy.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from copy import deepcopy
+from fnmatch import fnmatch
+
+from . import Backend
+from accounts.models import Account
+
+
+def _match_filter(account, filters, wildcard):
+ if filters is None:
+ return True
+
+ for key in filters:
+ if not hasattr(account, key):
+ return False
+
+ if wildcard:
+ if not fnmatch(getattr(account, key), filters[key]):
+ return False
+ else:
+ if getattr(account, key) != filters[key]:
+ return False
+
+ return True
+
+
+class DummyBackend(Backend):
+ """
+ This is a simple user backend that persists the users in a simple list.
+
+ The users are stored only in memory and during initialization two dummy
+ users (test and test2) are created.
+ """
+
+ def __init__(self, app):
+ super(DummyBackend, self).__init__(app)
+
+ self._storage = [
+ Account('test', 'test@accounts.spline.de', password='test'),
+ Account('test2', 'test2@accounts.spline.de', password='test2'),
+ ]
+
+ def auth(self, username, password):
+ """
+ Tries to authenticate a user with a given password. If the
+ authentication is successful an Account object will be returned.
+ """
+ acc = self.get_by_uid(username)
+ if acc.password != password:
+ raise ValueError("Invalid password")
+
+ return acc
+
+ def find(self, filters=None, wildcard=False):
+ """
+ Find accounts by a given filter.
+ """
+ results = []
+ for acc in self._storage:
+ if _match_filter(acc, filters, wildcard):
+ results.append(deepcopy(acc))
+
+ return results
+
+ def register(self, account):
+ """
+ Persists an account in the backend.
+ """
+ if account.password is None:
+ raise ValueError("Password required for register")
+
+ self._storage.append(deepcopy(account))
+
+ def update(self, account, as_admin=False):
+ """
+ Updates account information like passwords or email.
+ """
+ stored_account = self.get_by_uid(account.uid)
+ if not as_admin:
+ if stored_account.password != account.password:
+ raise ValueError("Invalid password")
+
+ self._storage = [acc for acc in self._storage if acc.uid != account.uid]
+ new_acc = deepcopy(account)
+
+ if account.new_password_root:
+ old, new = account.new_password_root
+ if old == stored_account.password:
+ new_acc.password = new
+
+ self._storage.append(new_acc)
+
+ def delete(self, account, password=None, as_admin=False):
+ """
+ Deletes an account permanently.
+ """
+ stored_account = self.get_by_uid(account.uid)
+ if not as_admin:
+ if stored_account.password != account.password:
+ raise ValueError("Invalid password")
+
+ self._storage = [acc for acc in self._storage if acc.uid != account.uid]
diff --git a/accounts/backend/user/ldap.py b/accounts/backend/user/ldap.py
new file mode 100644
index 0000000..5472caf
--- /dev/null
+++ b/accounts/backend/user/ldap.py
@@ -0,0 +1,230 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import ldap
+
+from . import Backend
+from accounts.models import Account
+
+
+class LdapBackend(Backend):
+
+ def __init__(self, app):
+ super(LdapBackend, self).__init__(app)
+
+ self.ldap_host = self.app.config['LDAP_HOST']
+ self.base_dn = self.app.config['LDAP_BASE_DN']
+ self.admin_user = self.app.config['LDAP_ADMIN_USER']
+ self.admin_pass = self.app.config['LDAP_ADMIN_PASS']
+ self.services = self.app.all_services
+
+ self.admin = False
+ self.binded = False
+
+ def auth(self, username, password):
+ """
+ Tries to authenticate a user with a given password. If the
+ authentication is successful an Account object will be returned.
+ """
+ self._bind_anonymous()
+ dn = self._format_dn([('uid', username), ('ou','users')])
+ dn_user, data_user = self.connection.search_s(dn, ldap.SCOPE_SUBTREE)[0]
+
+ self._bind_as_user(username, password)
+ uid = data_user['uid'][0]
+ mail = data_user['mail'][0]
+
+ dn = self._format_dn([('ou', 'services')])
+ filterstr = '(uid=%s)' % self._escape(uid)
+ data_service = self.connection.search_s(dn, ldap.SCOPE_SUBTREE, filterstr)
+
+ services = []
+ for entry in data_service:
+ cn = filter(lambda x: x.startswith('cn='), entry[0].split(','))[0]
+ services.append(cn.split('=')[-1])
+
+ acc = Account(uid, mail, services, dn_user, password)
+
+ self._unbind()
+
+ return acc
+
+ def find(self, filters={}, wildcard=False):
+ """
+ Find accounts by a given filter.
+ """
+ self._bind_anonymous()
+
+ filters['objectClass'] = 'inetOrgPerson'
+
+
+ filter_as_list = ['(%s=%s)' % (k,self._escape(v, wildcard)) for k,v in filters.items()]
+ filterstr = ''.join(filter_as_list)
+ if len(filter_as_list) > 1:
+ filterstr = '(&%s)' % filterstr
+
+ dn = self._format_dn([('ou','users')])
+ data = self.connection.search_s(dn, ldap.SCOPE_SUBTREE, filterstr)
+
+ accounts = []
+ for a in data:
+ accounts.append(Account(a[1]['uid'][0], a[1]['mail'][0]))
+
+ self._unbind()
+
+ return accounts
+
+ def register(self, account):
+ """
+ Persists an account in the backend.
+ """
+ self._bind_as_admin()
+
+ dn = self._format_dn([('uid', account.uid),('ou','users')])
+ uid = self._escape(account.uid)
+ attr = [
+ ('objectClass', ['top','inetOrgPerson']),
+ ('uid', uid), ('sn', 'n/a'), ('cn', uid),
+ ('mail', account.attributes['mail'])
+ ]
+ self.connection.add_s(dn, attr)
+ account.dn = dn
+
+ account.new_password_root = (None, account.password)
+ self._alter_passwords(account)
+
+ self._unbind()
+
+ def update(self, account, as_admin=False):
+ """
+ Updates account informations like passwords or email.
+ """
+ if as_admin:
+ self._bind_as_admin()
+ else:
+ self._bind_as_user(account.uid, account.password)
+
+ attr = [(ldap.MOD_REPLACE, k, v) for k, v in account.attributes.items()]
+ dn = self._format_dn([('uid',account.uid),('ou','users')])
+ self.connection.modify_s(dn, attr)
+ self._alter_passwords(account, as_admin=as_admin)
+
+ self._unbind()
+
+ def delete(self, account, password=None, as_admin=False):
+ """
+ Deletes an account permanently.
+ """
+ if isinstance(account, basestring):
+ raise NotImplementedError()
+ else:
+ user = account.uid
+ password = account.password
+
+ if as_admin:
+ self._bind_as_admin()
+ else:
+ self._bind_as_user(user, password)
+
+ dn = [self._format_dn([('uid',user),('cn',s),('ou','services')]) for s.id in account.services]
+ dn.append(self._format_dn([('uid', user), ('ou','users')]))
+
+ for x in dn:
+ self.connection.delete_s(x)
+
+ self._unbind()
+
+ def _format_dn(self, attr, with_base_dn = True):
+ if with_base_dn:
+ attr.extend(self.base_dn)
+
+ dn = ['%s=%s' % (item[0], self._escape(item[1])) for item in attr]
+
+ return ','.join(dn)
+
+ def _bind(self, dn, password):
+ self.connection = ldap.initialize(self.ldap_host)
+ self.connection.version = ldap.VERSION3
+
+ self.connection.simple_bind_s(dn, password)
+
+ def _bind_as_admin(self):
+ if self.binded:
+ self._unbind()
+
+ dn = self._format_dn([('cn', self.admin_user)])
+ self._bind(dn, self.admin_pass)
+
+ self.admin = True
+ self.binded = True
+
+ def _bind_as_user(self, username, password):
+ if self.binded:
+ self._unbind()
+
+ dn = self._format_dn([('uid', username),('ou', 'users')])
+ self._bind(dn, password)
+
+ self.binded = True
+ self.admin = True
+
+ def _bind_anonymous(self):
+ if self.binded:
+ self._unbind()
+
+ self._bind('','')
+
+ self.binded = True
+
+ def _unbind(self):
+ self.connection.unbind_s()
+ self.admin = False
+ self.binded = False
+
+ def _alter_passwords(self, account, as_admin=False):
+ if account.new_password_root:
+ dn = self._format_dn([('uid',account.uid),('ou','users')])
+ old, new = account.new_password_root
+ if as_admin:
+ self.connection.passwd_s(dn, None, new)
+ else:
+ try:
+ self.connection.passwd_s(dn, old, new)
+ except ldap.UNWILLING_TO_PERFORM:
+ raise InvalidPasswordError()
+
+ account.password = new
+
+ account.new_password_root = None
+
+ for service, passwords in account.new_password_services.items():
+ dn = self._format_dn([('uid',account.uid),('cn',service),('ou','services')])
+ old, new = passwords
+
+ if new != None:
+ if service not in account.services:
+ attr = [('objectClass', ['top', 'servicePassword']), ('uid', account.uid)]
+ self.connection.add_s(dn, attr)
+
+ if as_admin:
+ self.connection.passwd_s(dn, None, new)
+ else:
+ self.connection.passwd_s(dn, old, new)
+
+ else:
+ s = service.lower()
+ if s in account.services:
+ self.connection.delete_s(dn)
+ account.services.remove(s)
+
+ account.new_password_services = {}
+
+ def _escape(self, s, wildcard=False):
+ chars_to_escape = ['\\',',','=','+','<','>',';','"','\'','#','(',')','\0']
+
+ if not wildcard:
+ chars_to_escape.append('*')
+
+ escape = lambda x,y: x.replace(y,'\%02X' % ord(y))
+
+ return reduce(escape, chars_to_escape, s)