mirror of
https://github.com/YunoHost/moulinette.git
synced 2024-09-03 20:06:31 +02:00
* 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
311 lines
9.6 KiB
Python
311 lines
9.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from bottle import run, request, response, Bottle, HTTPResponse
|
|
from json import dumps as json_encode
|
|
|
|
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 _ActionsMapPlugin(object):
|
|
"""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):
|
|
"""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):
|
|
"""Apply plugin to the route callback
|
|
|
|
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
|
|
|
|
"""
|
|
def wrapper(*args, **kwargs):
|
|
# Bring arguments together
|
|
params = kwargs
|
|
for a in args:
|
|
params[a] = True
|
|
for k, v in request.params.items():
|
|
params[k] = v
|
|
|
|
# Process the action
|
|
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):
|
|
"""Moulinette Application Programming Interface
|
|
|
|
Initialize a HTTP server which serves the API to process moulinette
|
|
actions.
|
|
|
|
Keyword arguments:
|
|
- actionsmap -- The relevant ActionsMap instance
|
|
- routes -- A dict of additional routes to add in the form of
|
|
{(method, path): callback}
|
|
|
|
"""
|
|
def __init__(self, actionsmap, routes={}):
|
|
# TODO: Return OK to 'OPTIONS' xhr requests (l173)
|
|
app = Bottle(autojson=False)
|
|
|
|
## 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 json_encode(callback(*args, **kwargs))
|
|
return wrapper
|
|
|
|
# Install plugins
|
|
app.install(apiheader)
|
|
app.install(_ActionsMapPlugin(actionsmap))
|
|
|
|
# Append default routes
|
|
app.route(['/api', '/api/<category:re:[a-z]+>'], method='GET',
|
|
callback=self.doc, skip=['actionsmap'])
|
|
|
|
# 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'])
|
|
|
|
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):
|
|
"""
|
|
Get API documentation for a category (all by default)
|
|
|
|
Keyword argument:
|
|
category -- Name of the category
|
|
|
|
"""
|
|
if category is None:
|
|
with open('%s/../doc/resources.json' % pkg.datadir) as f:
|
|
return f.read()
|
|
|
|
try:
|
|
with open('%s/../doc/%s.json' % (pkg.datadir, category)) as f:
|
|
return f.read()
|
|
except IOError:
|
|
return None
|