diff --git a/bin/yunohost-api b/bin/yunohost-api index 11e889c5..74778536 100755 --- a/bin/yunohost-api +++ b/bin/yunohost-api @@ -41,5 +41,5 @@ if __name__ == '__main__': # TODO: Add log argument # Rune the server - api(['yunohost'], 6787, {('GET', '/installed'): is_installed}, use_cache) + api(['yunohost', 'test'], 6787, {('GET', '/installed'): is_installed}, use_cache) sys.exit(0) diff --git a/data/actionsmap/test.yml b/data/actionsmap/test.yml index 723ed49c..40f632d7 100644 --- a/data/actionsmap/test.yml +++ b/data/actionsmap/test.yml @@ -19,6 +19,13 @@ _global: parameters: uri: ldap://localhost:389 base_dn: dc=yunohost,dc=org + test-profile: + vendor: ldap + help: Admin Password (profile) + parameters: + uri: ldap://localhost:389 + base_dn: dc=yunohost,dc=org + user_rdn: cn=admin argument_auth: true ############################# @@ -34,6 +41,11 @@ test: api: GET /test/auth configuration: authenticate: all + auth-profile: + api: GET /test/auth-profile + configuration: + authenticate: all + authenticator: test-profile auth-cli: api: GET /test/auth-cli configuration: diff --git a/lib/test/test.py b/lib/test/test.py index c22c3e9c..8a9e6e6c 100644 --- a/lib/test/test.py +++ b/lib/test/test.py @@ -1,12 +1,19 @@ def test_non_auth(): - print('non-auth') + return {'action': 'non-auth'} def test_auth(auth): - print('[default] / all / auth: %r' % auth) + return {'action': 'auth', + 'authenticator': 'default', 'authenticate': 'all'} + +def test_auth_profile(auth): + return {'action': 'auth-profile', + 'authenticator': 'test-profile', 'authenticate': 'all'} def test_auth_cli(): - print('[default] / cli') + return {'action': 'auth-cli', + 'authenticator': 'default', 'authenticate': ['cli']} def test_anonymous(): - print('[ldap-anonymous] / all') + return {'action': 'anonymous', + 'authenticator': 'ldap-anonymous', 'authenticate': 'all'} diff --git a/src/moulinette/__init__.py b/src/moulinette/__init__.py index 4e73f6db..b503a8e7 100755 --- a/src/moulinette/__init__.py +++ b/src/moulinette/__init__.py @@ -69,21 +69,20 @@ def api(namespaces, port, routes={}, use_cache=True): Keyword arguments: - namespaces -- The list of namespaces to use - - port -- Port to run on + - port -- Port number to run on - routes -- A dict of additional routes to add in the form of {(method, uri): callback} - use_cache -- False if it should parse the actions map file instead of using the cached one """ - from bottle import run from .actionsmap import ActionsMap from .interface.api import MoulinetteAPI amap = ActionsMap('api', namespaces, use_cache) moulinette = MoulinetteAPI(amap, routes) - run(moulinette.app, port=port) + moulinette.run(port) def cli(namespaces, args, use_cache=True): """Command line interface diff --git a/src/moulinette/actionsmap.py b/src/moulinette/actionsmap.py index 28c4306b..fb54e3e3 100644 --- a/src/moulinette/actionsmap.py +++ b/src/moulinette/actionsmap.py @@ -48,7 +48,7 @@ class _AMapSignals(object): """The list of available signals""" signals = { 'authenticate', 'prompt' } - def authenticate(self, authenticator, name, help): + def authenticate(self, authenticator, name, help, vendor=None): """Process the authentication Attempt to authenticate to the given authenticator and return @@ -60,6 +60,7 @@ class _AMapSignals(object): - authenticator -- The authenticator to use - name -- The authenticator name in the actions map - help -- A help message for the authenticator + - vendor -- Not expected (TODO: Remove it) Returns: The authenticator object @@ -227,15 +228,11 @@ class _AMapParser(object): - profile -- The profile of the configuration """ - try: - if name == 'authenticator': - value = self.global_conf[name][profile] - else: - value = self.global_conf[name] - except KeyError: - return None + if name == 'authenticator': + value = self.global_conf[name][profile] else: - return self._format_conf(name, value) + value = self.global_conf[name] + return self._format_conf(name, value) def set_global_conf(self, configuration): """Set global configuration @@ -366,13 +363,13 @@ class _AMapParser(object): """ if name == 'authenticator' and value: auth_conf, auth_params = value - 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_conf['name'], - auth_vendor, **auth_params)) + auth_conf['vendor'], + **auth_params)) return value @@ -486,7 +483,7 @@ class _HTTPArgumentParser(object): return action - def parse_args(self, args): + def parse_args(self, args={}, namespace=None): arg_strings = [] ## Append an argument to the current one @@ -514,7 +511,7 @@ class _HTTPArgumentParser(object): for dest, opt in self._optional.items(): if dest in args: arg_strings = append(arg_strings, args[dest], opt[0]) - return self._parser.parse_args(arg_strings) + return self._parser.parse_args(arg_strings, namespace) def _error(self, message): # TODO: Raise a proper exception @@ -596,16 +593,28 @@ class APIAMapParser(_AMapParser): """Parse arguments Keyword arguments: - - route -- The action route (e.g. 'GET /' ) + - route -- The action route as a 2-tuple (method, path) """ # Retrieve the parser for the route if route not in self.routes: raise MoulinetteError(22, "No parser for '%s %s' found" % key) + ret = argparse.Namespace() - # TODO: Implement authentication + # Perform authentication if needed + if self.get_conf(route, 'authenticate'): + auth_conf, klass = self.get_conf(route, 'authenticator') - return self._parsers[route].parse_args(args) + # TODO: Catch errors + auth = shandler.authenticate(klass(), **auth_conf) + if not auth.is_authenticated: + # TODO: Set proper error code + raise MoulinetteError(1, _("This action need authentication")) + if self.get_conf(route, 'argument_auth') and \ + self.get_conf(route, 'authenticate') == 'all': + ret.auth = auth + + return self._parsers[route].parse_args(args, ret) """ The dict of interfaces names and their associated parser class. @@ -906,6 +915,26 @@ class ActionsMap(object): """Return the instance of the interface's actions map parser""" return self._parser + def get_authenticator(self, profile='default'): + """Get an authenticator instance + + Retrieve the authenticator for the given profile and return a + new instance. + + Keyword arguments: + - profile -- An authenticator profile name + + Returns: + A new _BaseAuthenticator derived instance + + """ + try: + auth = self.parser.get_global_conf('authenticator', profile)[1] + except KeyError: + raise MoulinetteError(167, _("Unknown authenticator profile '%s'") % profile) + else: + return auth() + def connect(self, signal, handler): """Connect a signal to a handler diff --git a/src/moulinette/core.py b/src/moulinette/core.py index 4c4d6932..1b2805e9 100644 --- a/src/moulinette/core.py +++ b/src/moulinette/core.py @@ -316,6 +316,17 @@ def init_authenticator(_name, _vendor, **kwargs): if _vendor == 'ldap': return LDAPAuthenticator(name=_name, **kwargs) +def clean_session(session_id, profiles=[]): + sessiondir = pkg.get_cachedir('session') + if len(profiles) == 0: + profiles = os.listdir(sessiondir) + + for p in profiles: + try: + os.unlink(os.path.join(sessiondir, p, '%s.asc' % session_id)) + except OSError: + pass + # Moulinette core classes ---------------------------------------------- diff --git a/src/moulinette/interface/api.py b/src/moulinette/interface/api.py index a8136933..a47d000e 100644 --- a/src/moulinette/interface/api.py +++ b/src/moulinette/interface/api.py @@ -1,147 +1,118 @@ # -*- coding: utf-8 -*- -from bottle import Bottle, request, response, HTTPResponse -from beaker.middleware import SessionMiddleware +from bottle import run, request, response, Bottle, HTTPResponse +from json import dumps as json_encode -from ..core import MoulinetteError +from ..core import MoulinetteError, clean_session from ..helpers import YunoHostError, YunoHostLDAP +# API helpers ---------------------------------------------------------- + +import os +import binascii + +def random20(): + return binascii.hexlify(os.urandom(20)).decode('ascii') + + +# HTTP Responses ------------------------------------------------------- + +class HTTPOKResponse(HTTPResponse): + def __init__(self, output=''): + super(HTTPOKResponse, self).__init__(output, 200) + +class HTTPBadRequestResponse(HTTPResponse): + def __init__(self, output=''): + super(HTTPBadRequestResponse, self).__init__(output, 400) + +class HTTPUnauthorizedResponse(HTTPResponse): + def __init__(self, output=''): + super(HTTPUnauthorizedResponse, self).__init__(output, 401) + +class HTTPErrorResponse(HTTPResponse): + def __init__(self, output=''): + super(HTTPErrorResponse, self).__init__(output, 500) + + # API moulinette interface --------------------------------------------- -class _APIAuthPlugin(object): - """ - Manage the authentication for the API access. - - """ - name = 'apiauth' - api = 2 - - def __init__(self): - # TODO: Add options (e.g. session type, content type, ...) - pass - - @property - def app(self): - """Get Bottle application with session integration""" - if hasattr(self, '_app'): - return self._app - raise Exception(_("The APIAuth Plugin is not installed yet.")) - - def setup(self, app): - """ - Setup the plugin and install the session into the app - - Keyword argument: - app -- The associated application object - - """ - app.route('/login', name='login', method='POST', callback=self.login) - app.route('/logout', name='logout', method='GET', callback=self.logout) - - session_opts = { - 'session.type': 'file', - 'session.cookie_expires': True, - 'session.data_dir': pkg.get_cachedir('session'), - 'session.secure': True - } - self._app = SessionMiddleware(app, session_opts) - - def apply(self, callback, context): - """ - Check authentication before executing the route callback - - Keyword argument: - callback -- The route callback - context -- An instance of Route - - """ - # Check the authentication - if self._is_authenticated: - if context.name == 'login': - self.logout() - else: - return callback - - # Process login route - if context.name == 'login': - password = request.POST.get('password', None) - if password is not None and self.login(password): - raise HTTPResponse(status=200) - else: - raise HTTPResponse(_("Wrong password"), 401) - - # Deny access to the requested route - raise HTTPResponse(_("Unauthorized"), 401) - - def login(self, password): - """ - Attempt to log in with the given password - - Keyword argument: - password -- Cleartext password - - """ - try: YunoHostLDAP(password=password) - except YunoHostError: - return False - else: - session = self._beaker_session - session['authenticated'] = True - session.save() - return True - return False - - def logout(self): - """ - Log out and delete the session - - """ - # TODO: Delete the cached session file - session = self._beaker_session - session.delete() - - - ## Private methods - - @property - def _beaker_session(self): - """Get Beaker session""" - return request.environ.get('beaker.session') - - @property - def _is_authenticated(self): - """Check authentication""" - # TODO: Clear the session path on password changing to avoid invalid access - if 'authenticated' in self._beaker_session: - return True - return False - class _ActionsMapPlugin(object): - """ - Process action for the request using the actions map. + """Actions map Bottle Plugin + + Process relevant action for the request using the actions map and + manage authentication. + + Keyword arguments: + - actionsmap -- An ActionsMap instance """ name = 'actionsmap' api = 2 def __init__(self, actionsmap): + # Connect signals to handlers + actionsmap.connect('authenticate', self._do_authenticate) + self.actionsmap = actionsmap + # TODO: Save and load secrets? + self.secrets = {} def setup(self, app): - pass + """Setup plugin on the application + + Add routes according to the actions map to the application. + + Keyword arguments: + - app -- The application instance + + """ + ## Login wrapper + def _login(callback): + def wrapper(): + kwargs = {} + try: + kwargs['password'] = request.POST['password'] + except KeyError: + raise HTTPBadRequestResponse(_("Missing password parameter")) + try: + kwargs['profile'] = request.POST['profile'] + except KeyError: + pass + return callback(**kwargs) + return wrapper + + ## Logout wrapper + def _logout(callback): + def wrapper(): + kwargs = {} + try: + kwargs['profile'] = request.POST.get('profile') + except KeyError: + pass + return callback(**kwargs) + return wrapper + + # Append authentication routes + app.route('/login', name='login', method='POST', + callback=self.login, skip=['actionsmap'], apply=_login) + app.route('/logout', name='logout', method='GET', + callback=self.logout, skip=['actionsmap'], apply=_logout) + + # Append routes from the actions map + for (m, p) in self.actionsmap.parser.routes: + app.route(p, method=m, callback=self.process) def apply(self, callback, context): - """ - Process the relevant action for the request + """Apply plugin to the route callback - Keyword argument: + Install a wrapper which replace callback and process the + relevant action for the route. + + Keyword arguments: callback -- The route callback context -- An instance of Route """ - method = request.method - uri = context.rule - def wrapper(*args, **kwargs): # Bring arguments together params = kwargs @@ -151,14 +122,127 @@ class _ActionsMapPlugin(object): params[k] = v # Process the action - return self.actionsmap.process(params, route=(method, uri)) + return callback((request.method, context.rule), params) return wrapper + ## Routes callbacks + + def login(self, password, profile='default'): + """Log in to an authenticator profile + + Attempt to authenticate to a given authenticator profile and + register it with the current session - a new one will be created + if needed. + + Keyword arguments: + - password -- A clear text password + - profile -- The authenticator profile name to log in + + """ + # Retrieve session values + s_id = request.get_cookie('session.id') or random20() + try: + s_secret = self.secrets[s_id] + except KeyError: + s_hashes = {} + else: + s_hashes = request.get_cookie('session.hashes', + secret=s_secret) or {} + s_hash = random20() + + try: + # Attempt to authenticate + auth = self.actionsmap.get_authenticator(profile) + auth(password, token=(s_id, s_hash)) + except MoulinetteError as e: + if len(s_hashes) > 0: + try: self.logout(profile) + except: pass + # TODO: Replace by proper exception + if e.code == 13: + raise HTTPUnauthorizedResponse(e.message) + raise HTTPErrorResponse(e.message) + else: + # Update dicts with new values + s_hashes[profile] = s_hash + self.secrets[s_id] = s_secret = random20() + + response.set_cookie('session.id', s_id, secure=True) + response.set_cookie('session.hashes', s_hashes, secure=True, + secret=s_secret) + raise HTTPOKResponse() + + def logout(self, profile=None): + """Log out from an authenticator profile + + Attempt to unregister a given profile - or all by default - from + the current session. + + Keyword arguments: + - profile -- The authenticator profile name to log out + + """ + s_id = request.get_cookie('session.id') + try: + del self.secrets[s_id] + except KeyError: + raise HTTPUnauthorizedResponse(_("You are not logged in")) + else: + # TODO: Clean the session for profile only + # Delete cookie and clean the session + response.set_cookie('session.hashes', '', max_age=-1) + clean_session(s_id) + raise HTTPOKResponse() + + def process(self, _route, arguments={}): + """Process the relevant action for the route + + Call the actions map in order to process the relevant action for + the route with the given arguments and process the returned + value. + + Keyword arguments: + - _route -- The action route as a 2-tuple (method, path) + - arguments -- A dict of arguments for the route + + """ + try: + ret = self.actionsmap.process(arguments, route=_route) + except MoulinetteError as e: + raise HTTPErrorResponse(e.message) + else: + return ret + + + ## Signals handlers + + def _do_authenticate(self, authenticator, name, help): + """Process the authentication + + Handle the actionsmap._AMapSignals.authenticate signal. + + """ + s_id = request.get_cookie('session.id') + try: + s_secret = self.secrets[s_id] + s_hash = request.get_cookie('session.hashes', + secret=s_secret)[name] + except KeyError: + if name == 'default': + msg = _("Needing authentication") + else: + msg = _("Needing authentication to profile '%s'") % name + raise HTTPUnauthorizedResponse(msg) + else: + return authenticator(token=(s_id, s_hash)) + + class MoulinetteAPI(object): - """ - Initialize a HTTP server which serves the API to access to the - moulinette actions. + """Moulinette Application Programming Interface + + Initialize a HTTP server which serves the API to process moulinette + actions. Keyword arguments: - actionsmap -- The relevant ActionsMap instance @@ -166,43 +250,47 @@ class MoulinetteAPI(object): {(method, path): callback} """ - def __init__(self, actionsmap, routes={}): - # Initialize app and default routes # TODO: Return OK to 'OPTIONS' xhr requests (l173) - app = Bottle() - app.route(['/api', '/api/'], method='GET', - callback=self.doc, skip=['apiauth']) + app = Bottle(autojson=False) - # Append routes from the actions map - amap = _ActionsMapPlugin(actionsmap) - for (m, p) in actionsmap.parser.routes: - app.route(p, method=m, callback=self._error, apply=amap) - - # Append additional routes - # TODO: Add an option to skip auth for the route - for (m, p), c in routes.items(): - app.route(p, method=m, callback=c) - - # Define and install a plugin which sets proper header + ## Wrapper which sets proper header def apiheader(callback): def wrapper(*args, **kwargs): response.content_type = 'application/json' response.set_header('Access-Control-Allow-Origin', '*') - return callback(*args, **kwargs) + return json_encode(callback(*args, **kwargs)) return wrapper + + # Install plugins app.install(apiheader) + app.install(_ActionsMapPlugin(actionsmap)) - # Install authentication plugin - apiauth = _APIAuthPlugin() - app.install(apiauth) + # Append default routes + app.route(['/api', '/api/'], method='GET', + callback=self.doc, skip=['actionsmap']) - self._app = apiauth.app + # Append additional routes + # TODO: Add optional authentication to those routes? + for (m, p), c in routes.items(): + app.route(p, method=m, callback=c, skip=['actionsmap']) - @property - def app(self): - """Get Bottle application""" - return self._app + self._app = app + + def run(self, _port): + """Run the moulinette + + Start a server instance on the given port to serve moulinette + actions. + + Keyword arguments: + - _port -- Port number to run on + + """ + run(self._app, port=_port) + + + ## Routes handlers def doc(self, category=None): """ @@ -220,8 +308,4 @@ class MoulinetteAPI(object): with open('%s/../doc/%s.json' % (pkg.datadir, category)) as f: return f.read() except IOError: - return 'unknown' - - def _error(self, *args, **kwargs): - # TODO: Raise or return an error - print('error') + return None