diff options
author | Alexander Sulfrian <alexander@sulfrian.net> | 2016-01-23 20:17:36 +0100 |
---|---|---|
committer | Alexander Sulfrian <alexander@sulfrian.net> | 2016-01-25 01:56:49 +0100 |
commit | 9f24d8bd26e7dd3b7e36294edee31be7a37fa650 (patch) | |
tree | 85f50a093f70d36fd58c52072ba2e5ccdaef91e7 /accounts/backend | |
parent | ea3983d891bc6e34a827902ac8cf15734923e14c (diff) | |
download | web-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__.py | 116 | ||||
-rw-r--r-- | accounts/backend/user/dummy.py | 104 | ||||
-rw-r--r-- | accounts/backend/user/ldap.py | 230 |
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) |