From 5da9f6add820df0b01958b66a56b4dfb3e2f5820 Mon Sep 17 00:00:00 2001 From: Jerome Lebleu Date: Thu, 20 Mar 2014 21:19:30 +0100 Subject: [PATCH] Work on Authenticators (support for session token and LDAP) --- data/actionsmap/test.yml | 11 +- data/actionsmap/yunohost.yml | 11 +- src/moulinette/actionsmap.py | 9 +- src/moulinette/core.py | 189 ++++++++++++++++++++++++++------ src/moulinette/interface/cli.py | 2 +- 5 files changed, 169 insertions(+), 53 deletions(-) diff --git a/data/actionsmap/test.yml b/data/actionsmap/test.yml index d3947cd1..723ed49c 100644 --- a/data/actionsmap/test.yml +++ b/data/actionsmap/test.yml @@ -8,18 +8,17 @@ _global: - api authenticator: default: - type: ldap + vendor: ldap help: Admin Password parameters: uri: ldap://localhost:389 - base: dc=yunohost,dc=org - anonymous: false + base_dn: dc=yunohost,dc=org + user_rdn: cn=admin ldap-anonymous: - type: ldap + vendor: ldap parameters: uri: ldap://localhost:389 - base: dc=yunohost,dc=org - anonymous: true + base_dn: dc=yunohost,dc=org argument_auth: true ############################# diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index cbb06872..39d39aeb 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -38,18 +38,17 @@ _global: - api authenticator: default: - type: ldap + vendor: ldap help: Admin Password parameters: uri: ldap://localhost:389 - base: dc=yunohost,dc=org - anonymous: false + base_dn: dc=yunohost,dc=org + user_rdn: cn=admin ldap-anonymous: - type: ldap + vendor: ldap parameters: uri: ldap://localhost:389 - base: dc=yunohost,dc=org - anonymous: true + base_dn: dc=yunohost,dc=org arguments: -v: full: --version diff --git a/src/moulinette/actionsmap.py b/src/moulinette/actionsmap.py index 21e704a7..28c4306b 100644 --- a/src/moulinette/actionsmap.py +++ b/src/moulinette/actionsmap.py @@ -330,7 +330,7 @@ class _AMapParser(object): for auth_name, auth_conf in auth.items(): # Add authenticator name auths[auth_name] = ({ 'name': auth_name, - 'type': auth_conf.get('type'), + 'vendor': auth_conf.get('vendor'), 'help': auth_conf.get('help', None) }, auth_conf.get('parameters', {})) @@ -366,12 +366,13 @@ class _AMapParser(object): """ if name == 'authenticator' and value: auth_conf, auth_params = value - auth_type = auth_conf.pop('type') + auth_vendor = auth_conf.pop('vendor') # Return authenticator configuration and an instanciator for # it as a 2-tuple return (auth_conf, - lambda: init_authenticator(auth_type, **auth_params)) + lambda: init_authenticator(auth_conf['name'], + auth_vendor, **auth_params)) return value @@ -994,7 +995,7 @@ class ActionsMap(object): actionsmaps[n] = yaml.load(f) # Cache actions map into pickle file - with pkg.open_cache('%s.pkl' % n, subdir='actionsmap') as f: + with pkg.open_cachefile('%s.pkl' % n, 'w', subdir='actionsmap') as f: pickle.dump(actionsmaps[n], f) return actionsmaps diff --git a/src/moulinette/core.py b/src/moulinette/core.py index 35d52432..4c4d6932 100644 --- a/src/moulinette/core.py +++ b/src/moulinette/core.py @@ -4,6 +4,7 @@ import os import sys import time import gettext +import logging from .helpers import colorize @@ -109,7 +110,7 @@ class Package(object): os.makedirs(path) return path - def open_cache(self, filename, subdir='', mode='w'): + def open_cachefile(self, filename, mode='r', **kwargs): """Open a cache file and return a stream Attempt to open in 'mode' the cache file 'filename' from the @@ -119,85 +120,201 @@ class Package(object): Keyword arguments: - filename -- The cache filename - - subdir -- A subdirectory which contains the file - mode -- The mode in which the file is opened + - **kwargs -- Optional arguments for get_cachedir """ - return open('%s/%s' % (self.get_cachedir(subdir), filename), mode) + # Set make_dir if not given + kwargs['make_dir'] = kwargs.get('make_dir', + True if mode[0] == 'w' else False) + return open('%s/%s' % (self.get_cachedir(**kwargs), filename), mode) # Authenticators ------------------------------------------------------- +import ldap +import gnupg + +class AuthenticationError(Exception): + pass + class _BaseAuthenticator(object): + def __init__(self, name): + self._name = name + + @property + def name(self): + """Return the name of the authenticator instance""" + return self._name + + ## Virtual properties # Each authenticator classes must implement these properties. - """The name of the authenticator""" - name = None + """The vendor of the authenticator""" + vendor = None @property def is_authenticated(self): """Either the instance is authenticated or not""" raise NotImplementedError("derived class '%s' must override this property" % \ - self.__class__.__name__) + self.__class__.__name__) ## Virtual methods # Each authenticator classes must implement these methods. - def authenticate(password=None, token=None): + def authenticate(password=None): """Attempt to authenticate - Attempt to authenticate with given password or session token. + Attempt to authenticate with given password. It should raise an + AuthenticationError exception if authentication fails. Keyword arguments: - password -- A clear text password - - token -- A session token - - Returns: - An optional session token """ raise NotImplementedError("derived class '%s' must override this method" % \ - self.__class__.__name__) + self.__class__.__name__) -class LDAPAuthenticator(object): + ## Authentication methods - def __init__(self, uri, base, anonymous=False): - # TODO: Initialize LDAP connection + def __call__(self, password=None, token=None): + """Attempt to authenticate - if anonymous: - self._authenticated = True - else: - self._authenticated = False + Attempt to authenticate either with password or with session + token if 'password' is None. If the authentication succeed, the + instance is returned and the session is registered for the token + if 'token' and 'password' are given. + The token is composed by the session identifier and a session + hash - to use for encryption - as a 2-tuple. + Keyword arguments: + - password -- A clear text password + - token -- The session token in the form of (id, hash) - ## Implement virtual properties + Returns: + The authenticated instance - name = 'ldap' + """ + if self.is_authenticated: + return self + store_session = True if password and token else False - @property - def is_authenticated(self): - return self._authenticated + if token: + try: + # Extract id and hash from token + s_id, s_hash = token + except TypeError: + if not password: + raise MoulinetteError(22, _("Invalid format for token")) + else: + # TODO: Log error + store_session = False + else: + if password is None: + # Retrieve session + password = self._retrieve_session(s_id, s_hash) + try: + # Attempt to authenticate + self.authenticate(password) + except AuthenticationError as e: + raise MoulinetteError(13, str(e)) + except Exception as e: + logging.error("authentication (name: '%s', type: '%s') fails: %s" % \ + (self.name, self.vendor, e)) + raise MoulinetteError(13, _("Unable to authenticate")) - ## Implement virtual methods - - def authenticate(self, password=None, token=None): - # TODO: Perform LDAP authentication - if password == 'test': - self._authenticated = True - else: - raise MoulinetteError(13, _("Invalid password")) + # Store session + if store_session: + self._store_session(s_id, s_hash, password) return self -def init_authenticator(_name, **kwargs): - if _name == 'ldap': - return LDAPAuthenticator(**kwargs) + ## Private methods + + def _open_sessionfile(self, session_id, mode='r'): + """Open a session file for this instance in given mode""" + return pkg.open_cachefile('%s.asc' % session_id, mode, + subdir='session/%s' % self.name) + + def _store_session(self, session_id, session_hash, password): + """Store a session and its associated password""" + gpg = gnupg.GPG() + gpg.encoding = 'utf-8' + with self._open_sessionfile(session_id, 'w') as f: + f.write(str(gpg.encrypt(password, None, symmetric=True, + passphrase=session_hash))) + + def _retrieve_session(self, session_id, session_hash): + """Retrieve a session and return its associated password""" + try: + with self._open_sessionfile(session_id, 'r') as f: + enc_pwd = f.read() + except IOError: + # TODO: Set proper error code + raise MoulinetteError(167, _("Unable to retrieve session")) + else: + gpg = gnupg.GPG() + gpg.encoding = 'utf-8' + return str(gpg.decrypt(enc_pwd, passphrase=session_hash)) + + +class LDAPAuthenticator(_BaseAuthenticator): + + def __init__(self, uri, base_dn, user_rdn=None, **kwargs): + super(LDAPAuthenticator, self).__init__(**kwargs) + + self.uri = uri + self.basedn = base_dn + if user_rdn: + self.userdn = '%s,%s' % (user_rdn, base_dn) + self.con = None + else: + # Initialize anonymous usage + self.userdn = '' + self.authenticate(None) + + + ## Implement virtual properties + + vendor = 'ldap' + + @property + def is_authenticated(self): + try: + # Retrieve identity + who = self.con.whoami_s() + except: + return False + else: + if who[3:] == self.userdn: + return True + return False + + + ## Implement virtual methods + + def authenticate(self, password): + try: + con = ldap.initialize(self.uri) + if self.userdn: + con.simple_bind_s(self.userdn, password) + else: + con.simple_bind_s() + except ldap.INVALID_CREDENTIALS: + raise AuthenticationError(_("Invalid password")) + else: + self.con = con + + +def init_authenticator(_name, _vendor, **kwargs): + if _vendor == 'ldap': + return LDAPAuthenticator(name=_name, **kwargs) # Moulinette core classes ---------------------------------------------- diff --git a/src/moulinette/interface/cli.py b/src/moulinette/interface/cli.py index 481413c0..1262b336 100644 --- a/src/moulinette/interface/cli.py +++ b/src/moulinette/interface/cli.py @@ -108,7 +108,7 @@ class MoulinetteCLI(object): """ # TODO: Allow token authentication? msg = help or _("Password") - return authenticator.authenticate(password=self._do_prompt(msg, True, False)) + return authenticator(password=self._do_prompt(msg, True, False)) def _do_prompt(self, message, is_password, confirm): """Prompt for a value