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
# 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)

View file

@ -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:

View file

@ -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'}

View file

@ -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

View file

@ -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

View file

@ -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 ----------------------------------------------

View file

@ -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/<category:re:[a-z]+>'], 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/<category:re:[a-z]+>'], 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