Add support for Authenticators in the API

* Update the API to support authenticators and session token
* Add a function to clean sessions
* Make the API fit in better with actions map
* Minor changes on test namespace
This commit is contained in:
Jerome Lebleu 2014-03-22 17:01:30 +01:00
parent 5da9f6add8
commit 3fae8bf1ff
7 changed files with 320 additions and 178 deletions

View file

@ -41,5 +41,5 @@ if __name__ == '__main__':
# TODO: Add log argument # TODO: Add log argument
# Rune the server # 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) sys.exit(0)

View file

@ -19,6 +19,13 @@ _global:
parameters: parameters:
uri: ldap://localhost:389 uri: ldap://localhost:389
base_dn: dc=yunohost,dc=org 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 argument_auth: true
############################# #############################
@ -34,6 +41,11 @@ test:
api: GET /test/auth api: GET /test/auth
configuration: configuration:
authenticate: all authenticate: all
auth-profile:
api: GET /test/auth-profile
configuration:
authenticate: all
authenticator: test-profile
auth-cli: auth-cli:
api: GET /test/auth-cli api: GET /test/auth-cli
configuration: configuration:

View file

@ -1,12 +1,19 @@
def test_non_auth(): def test_non_auth():
print('non-auth') return {'action': 'non-auth'}
def test_auth(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(): def test_auth_cli():
print('[default] / cli') return {'action': 'auth-cli',
'authenticator': 'default', 'authenticate': ['cli']}
def test_anonymous(): def test_anonymous():
print('[ldap-anonymous] / all') return {'action': 'anonymous',
'authenticator': 'ldap-anonymous', 'authenticate': 'all'}

View file

@ -69,21 +69,20 @@ def api(namespaces, port, routes={}, use_cache=True):
Keyword arguments: Keyword arguments:
- namespaces -- The list of namespaces to use - 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 - routes -- A dict of additional routes to add in the form of
{(method, uri): callback} {(method, uri): callback}
- use_cache -- False if it should parse the actions map file - use_cache -- False if it should parse the actions map file
instead of using the cached one instead of using the cached one
""" """
from bottle import run
from .actionsmap import ActionsMap from .actionsmap import ActionsMap
from .interface.api import MoulinetteAPI from .interface.api import MoulinetteAPI
amap = ActionsMap('api', namespaces, use_cache) amap = ActionsMap('api', namespaces, use_cache)
moulinette = MoulinetteAPI(amap, routes) moulinette = MoulinetteAPI(amap, routes)
run(moulinette.app, port=port) moulinette.run(port)
def cli(namespaces, args, use_cache=True): def cli(namespaces, args, use_cache=True):
"""Command line interface """Command line interface

View file

@ -48,7 +48,7 @@ class _AMapSignals(object):
"""The list of available signals""" """The list of available signals"""
signals = { 'authenticate', 'prompt' } signals = { 'authenticate', 'prompt' }
def authenticate(self, authenticator, name, help): def authenticate(self, authenticator, name, help, vendor=None):
"""Process the authentication """Process the authentication
Attempt to authenticate to the given authenticator and return Attempt to authenticate to the given authenticator and return
@ -60,6 +60,7 @@ class _AMapSignals(object):
- authenticator -- The authenticator to use - authenticator -- The authenticator to use
- name -- The authenticator name in the actions map - name -- The authenticator name in the actions map
- help -- A help message for the authenticator - help -- A help message for the authenticator
- vendor -- Not expected (TODO: Remove it)
Returns: Returns:
The authenticator object The authenticator object
@ -227,14 +228,10 @@ class _AMapParser(object):
- profile -- The profile of the configuration - profile -- The profile of the configuration
""" """
try:
if name == 'authenticator': if name == 'authenticator':
value = self.global_conf[name][profile] value = self.global_conf[name][profile]
else: else:
value = self.global_conf[name] value = self.global_conf[name]
except KeyError:
return None
else:
return self._format_conf(name, value) return self._format_conf(name, value)
def set_global_conf(self, configuration): def set_global_conf(self, configuration):
@ -366,13 +363,13 @@ class _AMapParser(object):
""" """
if name == 'authenticator' and value: if name == 'authenticator' and value:
auth_conf, auth_params = value auth_conf, auth_params = value
auth_vendor = auth_conf.pop('vendor')
# Return authenticator configuration and an instanciator for # Return authenticator configuration and an instanciator for
# it as a 2-tuple # it as a 2-tuple
return (auth_conf, return (auth_conf,
lambda: init_authenticator(auth_conf['name'], lambda: init_authenticator(auth_conf['name'],
auth_vendor, **auth_params)) auth_conf['vendor'],
**auth_params))
return value return value
@ -486,7 +483,7 @@ class _HTTPArgumentParser(object):
return action return action
def parse_args(self, args): def parse_args(self, args={}, namespace=None):
arg_strings = [] arg_strings = []
## Append an argument to the current one ## Append an argument to the current one
@ -514,7 +511,7 @@ class _HTTPArgumentParser(object):
for dest, opt in self._optional.items(): for dest, opt in self._optional.items():
if dest in args: if dest in args:
arg_strings = append(arg_strings, args[dest], opt[0]) 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): def _error(self, message):
# TODO: Raise a proper exception # TODO: Raise a proper exception
@ -596,16 +593,28 @@ class APIAMapParser(_AMapParser):
"""Parse arguments """Parse arguments
Keyword 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 # Retrieve the parser for the route
if route not in self.routes: if route not in self.routes:
raise MoulinetteError(22, "No parser for '%s %s' found" % key) 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. 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 the instance of the interface's actions map parser"""
return self._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): def connect(self, signal, handler):
"""Connect a signal to a handler """Connect a signal to a handler

View file

@ -316,6 +316,17 @@ def init_authenticator(_name, _vendor, **kwargs):
if _vendor == 'ldap': if _vendor == 'ldap':
return LDAPAuthenticator(name=_name, **kwargs) 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 ---------------------------------------------- # Moulinette core classes ----------------------------------------------

View file

@ -1,147 +1,118 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from bottle import Bottle, request, response, HTTPResponse from bottle import run, request, response, Bottle, HTTPResponse
from beaker.middleware import SessionMiddleware from json import dumps as json_encode
from ..core import MoulinetteError from ..core import MoulinetteError, clean_session
from ..helpers import YunoHostError, YunoHostLDAP 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 --------------------------------------------- # 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): class _ActionsMapPlugin(object):
""" """Actions map Bottle Plugin
Process action for the request using the actions map.
Process relevant action for the request using the actions map and
manage authentication.
Keyword arguments:
- actionsmap -- An ActionsMap instance
""" """
name = 'actionsmap' name = 'actionsmap'
api = 2 api = 2
def __init__(self, actionsmap): def __init__(self, actionsmap):
# Connect signals to handlers
actionsmap.connect('authenticate', self._do_authenticate)
self.actionsmap = actionsmap self.actionsmap = actionsmap
# TODO: Save and load secrets?
self.secrets = {}
def setup(self, app): def setup(self, app):
"""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 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): def apply(self, callback, context):
""" """Apply plugin to the route callback
Process the relevant action for the request
Keyword argument: Install a wrapper which replace callback and process the
relevant action for the route.
Keyword arguments:
callback -- The route callback callback -- The route callback
context -- An instance of Route context -- An instance of Route
""" """
method = request.method
uri = context.rule
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
# Bring arguments together # Bring arguments together
params = kwargs params = kwargs
@ -151,14 +122,127 @@ class _ActionsMapPlugin(object):
params[k] = v params[k] = v
# Process the action # Process the action
return self.actionsmap.process(params, route=(method, uri)) return callback((request.method, context.rule), params)
return wrapper return wrapper
class MoulinetteAPI(object): ## 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
""" """
Initialize a HTTP server which serves the API to access to the # Retrieve session values
moulinette actions. 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):
"""Moulinette Application Programming Interface
Initialize a HTTP server which serves the API to process moulinette
actions.
Keyword arguments: Keyword arguments:
- actionsmap -- The relevant ActionsMap instance - actionsmap -- The relevant ActionsMap instance
@ -166,43 +250,47 @@ class MoulinetteAPI(object):
{(method, path): callback} {(method, path): callback}
""" """
def __init__(self, actionsmap, routes={}): def __init__(self, actionsmap, routes={}):
# Initialize app and default routes
# TODO: Return OK to 'OPTIONS' xhr requests (l173) # TODO: Return OK to 'OPTIONS' xhr requests (l173)
app = Bottle() app = Bottle(autojson=False)
app.route(['/api', '/api/<category:re:[a-z]+>'], method='GET',
callback=self.doc, skip=['apiauth'])
# Append routes from the actions map ## Wrapper which sets proper header
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
def apiheader(callback): def apiheader(callback):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
response.content_type = 'application/json' response.content_type = 'application/json'
response.set_header('Access-Control-Allow-Origin', '*') response.set_header('Access-Control-Allow-Origin', '*')
return callback(*args, **kwargs) return json_encode(callback(*args, **kwargs))
return wrapper return wrapper
# Install plugins
app.install(apiheader) app.install(apiheader)
app.install(_ActionsMapPlugin(actionsmap))
# Install authentication plugin # Append default routes
apiauth = _APIAuthPlugin() app.route(['/api', '/api/<category:re:[a-z]+>'], method='GET',
app.install(apiauth) 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 self._app = app
def app(self):
"""Get Bottle application""" def run(self, _port):
return self._app """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): def doc(self, category=None):
""" """
@ -220,8 +308,4 @@ class MoulinetteAPI(object):
with open('%s/../doc/%s.json' % (pkg.datadir, category)) as f: with open('%s/../doc/%s.json' % (pkg.datadir, category)) as f:
return f.read() return f.read()
except IOError: except IOError:
return 'unknown' return None
def _error(self, *args, **kwargs):
# TODO: Raise or return an error
print('error')