From e0d4ebb6f442690f41429d3f97cf51ac7df90214 Mon Sep 17 00:00:00 2001 From: Patrik Dufresne Date: Wed, 1 Mar 2017 10:28:28 -0500 Subject: [PATCH] Start implementation of a RESTful API for Rdiffweb. Replace the /ajax endpoint by /api. --- rdiffweb/api.py | 73 +++++++++++++++++++ rdiffweb/filter_authentication.py | 69 +++++++++--------- rdiffweb/plugins/remove_older/__init__.py | 2 +- .../remove_older/templates/remove_older.html | 2 +- .../remove_older/tests/test_remove_older.py | 2 +- rdiffweb/plugins/set_encoding/__init__.py | 2 +- .../set_encoding/templates/set_encoding.html | 2 +- .../set_encoding/tests/test_set_encoding.py | 2 +- rdiffweb/rdw_app.py | 3 +- rdiffweb/test.py | 6 ++ rdiffweb/tests/test_api.py | 62 ++++++++++++++++ 11 files changed, 185 insertions(+), 40 deletions(-) create mode 100644 rdiffweb/api.py create mode 100644 rdiffweb/tests/test_api.py diff --git a/rdiffweb/api.py b/rdiffweb/api.py new file mode 100644 index 00000000..162658ec --- /dev/null +++ b/rdiffweb/api.py @@ -0,0 +1,73 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# rdiffweb, A web interface to rdiff-backup repositories +# Copyright (C) 2017 rdiffweb contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +""" +Created on Nov 16, 2017 + +@author: Patrik Dufresne +""" + +from __future__ import unicode_literals + +import cherrypy +import logging + +from rdiffweb.core import Component + + +# Define the logger +logger = logging.getLogger(__name__) + + +class ApiCurrentuser(Component): + + @cherrypy.expose + @cherrypy.tools.json_out() + def repos(self): + """ + Return the list of repositories. + """ + return self.app.currentuser.repos + + @cherrypy.expose + @cherrypy.tools.json_out() + def index(self): + u = self.app.currentuser + return { + "is_admin": u.is_admin, + "email": u.email, + "user_root": u.user_root, + "repos": u.repos, + "username": u.username, + } + + +@cherrypy.config(**{'tools.authform.on': False, 'tools.i18n.on': False, 'tools.authbasic.on': True, 'tools.sessions.on': True, 'error_page.default': False}) +class ApiPage(Component): + """ + This class provide a restful API to access some of the rdiffweb resources. + """ + + def __init__(self, app): + Component.__init__(self, app) + self.currentuser = ApiCurrentuser(app) + + @cherrypy.expose + @cherrypy.tools.json_out() + def index(self): + return "ok" + diff --git a/rdiffweb/filter_authentication.py b/rdiffweb/filter_authentication.py index 0bd624b5..e6f3139d 100644 --- a/rdiffweb/filter_authentication.py +++ b/rdiffweb/filter_authentication.py @@ -31,12 +31,27 @@ from rdiffweb.core import RdiffError, RdiffWarning from rdiffweb.i18n import ugettext as _ from rdiffweb.page_main import MainPage from rdiffweb.rdw_helpers import quote_url +from cherrypy.lib import httpauth # Define the logger logger = logging.getLogger(__name__) +def check_username_and_password(username, password): + """Validate user credentials.""" + logger.debug("check credentials for [%s]", username) + try: + userobj = cherrypy.request.app.userdb.login(username, password) # @UndefinedVariable + except: + logger.exception("fail to validate user credential") + raise RdiffWarning(_("Fail to validate user credential.")) + if not userobj: + logger.warning("invalid username [%s] or password", username) + raise RdiffWarning(_("Invalid username or password.")) + return userobj + + class AuthFormTool(HandlerTool): """ Tool used to control authentication to various ressources. @@ -49,19 +64,6 @@ class AuthFormTool(HandlerTool): # Make sure to run after i18n tool (priority 60) self._priority = 70 - def check_username_and_password(self, username, password): - """Validate user credentials.""" - logger.debug("check credentials for [%s]", username) - try: - userobj = cherrypy.request.app.userdb.login(username, password) # @UndefinedVariable - except: - logger.exception("fail to validate user credential") - raise RdiffWarning(_("Fail to validate user credential.")) - if not userobj: - logger.warning("invalid username [%s] or password", username) - raise RdiffWarning(_("Invalid username or password.")) - return userobj - def do_check(self): """Assert username. Raise redirect, or return True if request handled.""" request = cherrypy.serving.request @@ -96,7 +98,7 @@ class AuthFormTool(HandlerTool): """Login. May raise redirect, or return True if request handled.""" response = cherrypy.serving.response try: - userobj = self.check_username_and_password(login, password) + userobj = check_username_and_password(login, password) except RdiffError as e: body = self.login_screen(redirect, login, str(e)) response.body = body @@ -183,36 +185,37 @@ class AuthFormTool(HandlerTool): cherrypy.tools.authform = AuthFormTool() -def authbasic(checkpassword): +def authbasic(): """Filter used to restrict access to resource via HTTP basic auth.""" - # Check if logged-in. - if cherrypy.session.get("user"): # @UndefinedVariable - # page passes credentials; allow to be processed - return False - # Proceed with basic authentication. request = cherrypy.serving.request - auth_header = request.headers.get('authorization') - if auth_header is not None: + ah = request.headers.get('authorization') + if ah is not None: try: - scheme, params = auth_header.split(' ', 1) + scheme, params = ah.split(' ', 1) if scheme.lower() == 'basic': + # Validate user credential. username, password = base64_decode(params).split(':', 1) - error_msg = checkpassword(username, password) - if error_msg: - logger.info('basic auth fail for %s: %s', username, error_msg) - else: - logger.info('basic auth succeeded for %s', username) - request.login = username - return # successful authentication - # split() error, base64.decodestring() error + try: + userobj = check_username_and_password(username, password) + except RdiffError as e: + logger.info('basic auth fail for %s', username, e) + raise cherrypy.HTTPError(403) + + # User successfully login. + logger.debug('setting request.login to %s', userobj) + cherrypy.serving.request.login = userobj + return + except (ValueError, binascii.Error): raise cherrypy.HTTPError(400, 'Bad Request') - # Respond with 401 status and a WWW-Authenticate header - cherrypy.serving.response.headers['www-authenticate'] = 'Basic realm="rdiffweb"' + # Inform the user-agent this path is protected. + cherrypy.serving.response.headers[ + 'www-authenticate'] = httpauth.basicAuth('rdiffweb') raise cherrypy.HTTPError(401, "You are not authorized to access that resource") + cherrypy.tools.authbasic = cherrypy._cptools.HandlerTool(authbasic) diff --git a/rdiffweb/plugins/remove_older/__init__.py b/rdiffweb/plugins/remove_older/__init__.py index ee7098b5..dfca6696 100644 --- a/rdiffweb/plugins/remove_older/__init__.py +++ b/rdiffweb/plugins/remove_older/__init__.py @@ -76,7 +76,7 @@ class RemoveOlderPlugin(ITemplateFilterPlugin, JobPlugin): def activate(self): # Add page - self.app.root.ajax.remove_older = RemoveOlderPage(self.app) + self.app.root.api.remove_older = RemoveOlderPage(self.app) # Call original ITemplateFilterPlugin.activate(self) diff --git a/rdiffweb/plugins/remove_older/templates/remove_older.html b/rdiffweb/plugins/remove_older/templates/remove_older.html index 2981b5f2..31ad9543 100644 --- a/rdiffweb/plugins/remove_older/templates/remove_older.html +++ b/rdiffweb/plugins/remove_older/templates/remove_older.html @@ -2,7 +2,7 @@ {% import 'macros.html' as macros %} {% call macros.panel(title=_("Remove older"), class='default') %}
-
+
diff --git a/rdiffweb/plugins/remove_older/tests/test_remove_older.py b/rdiffweb/plugins/remove_older/tests/test_remove_older.py index 93a2a676..2514f446 100644 --- a/rdiffweb/plugins/remove_older/tests/test_remove_older.py +++ b/rdiffweb/plugins/remove_older/tests/test_remove_older.py @@ -48,7 +48,7 @@ class RemoveOlderTest(WebCase): self.getPage("/settings/" + repo + "/") def _remove_older(self, repo, value): - self.getPage("/ajax/remove-older/" + repo + "/", method="POST", + self.getPage("/api/remove-older/" + repo + "/", method="POST", body={'keepdays': value}) def test_page_set_keepdays(self): diff --git a/rdiffweb/plugins/set_encoding/__init__.py b/rdiffweb/plugins/set_encoding/__init__.py index 4e3a987c..65f3b2b5 100644 --- a/rdiffweb/plugins/set_encoding/__init__.py +++ b/rdiffweb/plugins/set_encoding/__init__.py @@ -82,7 +82,7 @@ class SetEncodingPlugin(ITemplateFilterPlugin): def activate(self): # Add page - self.app.root.ajax.set_encoding = SetEncodingPage(self.app) + self.app.root.api.set_encoding = SetEncodingPage(self.app) # Call original ITemplateFilterPlugin.activate(self) diff --git a/rdiffweb/plugins/set_encoding/templates/set_encoding.html b/rdiffweb/plugins/set_encoding/templates/set_encoding.html index 0e851be2..54c121a4 100644 --- a/rdiffweb/plugins/set_encoding/templates/set_encoding.html +++ b/rdiffweb/plugins/set_encoding/templates/set_encoding.html @@ -23,7 +23,7 @@ {% call macros.panel(title=_("Character encoding"), class='default') %}
- +
diff --git a/rdiffweb/plugins/set_encoding/tests/test_set_encoding.py b/rdiffweb/plugins/set_encoding/tests/test_set_encoding.py index 0b7026ef..b78f7cb9 100644 --- a/rdiffweb/plugins/set_encoding/tests/test_set_encoding.py +++ b/rdiffweb/plugins/set_encoding/tests/test_set_encoding.py @@ -44,7 +44,7 @@ class SetEncodingTest(WebCase): self.getPage("/settings/" + repo + "/") def _set_encoding(self, repo, encoding): - self.getPage("/ajax/set-encoding/" + repo + "/", method="POST", + self.getPage("/api/set-encoding/" + repo + "/", method="POST", body={'new_encoding': encoding}) def test_check_encoding(self): diff --git a/rdiffweb/rdw_app.py b/rdiffweb/rdw_app.py index 3fa2c867..d112ab22 100644 --- a/rdiffweb/rdw_app.py +++ b/rdiffweb/rdw_app.py @@ -34,6 +34,7 @@ from rdiffweb import i18n # @UnusedImport from rdiffweb import rdw_config, page_main from rdiffweb import rdw_plugin from rdiffweb import rdw_templating +from rdiffweb.api import ApiPage from rdiffweb.dispatch import static, empty from rdiffweb.page_admin import AdminPage from rdiffweb.page_browse import BrowsePage @@ -66,7 +67,7 @@ class Root(LocationsPage): self.admin = AdminPage(app) self.prefs = PreferencesPage(app) self.settings = SettingsPage(app) - self.ajax = empty() + self.api = ApiPage(app) # Register static dir. static_dir = pkg_resources.resource_filename('rdiffweb', 'static') # @UndefinedVariable diff --git a/rdiffweb/test.py b/rdiffweb/test.py index 641e39c6..44676e58 100644 --- a/rdiffweb/test.py +++ b/rdiffweb/test.py @@ -29,6 +29,7 @@ from builtins import str, delattr import cherrypy from cherrypy.test import helper from future.utils import native_str +import json import os import pkg_resources import shutil @@ -217,6 +218,11 @@ class WebCase(helper.CPWebCase): headers.extend(self.cookies) helper.CPWebCase.getPage(self, url, headers, method, body, protocol) + def getJson(self, *args, **kwargs): + self.getPage(*args, **kwargs) + self.assertStatus(200) + return json.loads(self.body.decode('utf8')) + def _login(self, username=USERNAME, password=PASSWORD): self.getPage("/login/", method='POST', body={'login': username, 'password': password}) self.assertStatus('303 See Other') diff --git a/rdiffweb/tests/test_api.py b/rdiffweb/tests/test_api.py new file mode 100644 index 00000000..883e08f6 --- /dev/null +++ b/rdiffweb/tests/test_api.py @@ -0,0 +1,62 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# rdiffweb, A web interface to rdiff-backup repositories +# Copyright (C) 2017 rdiffweb contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +""" +Created on Nov 16, 2017 + +@author: Patrik Dufresne +""" + +from __future__ import unicode_literals + +import logging +import unittest + +from rdiffweb.test import WebCase + + +class APITest(WebCase): + + login = True + + reset_app = True + + reset_testcases = True + + @classmethod + def setup_server(cls): + WebCase.setup_server(enabled_plugins=['SQLite']) + + def test_get_currentuser(self): + data = self.getJson('/api/currentuser/') + self.assertEqual(data.get('username'), 'admin') + self.assertEqual(data.get('is_admin'), True) + self.assertEqual(data.get('email'), '') + # This value change on every execution. + # self.assertEqual(data.get('user_root'), 'admin') + self.assertIn('/tmp/rdiffweb_tests', data.get('user_root')) + self.assertEqual(data.get('repos'), ['testcases/']) + + def test_get_currentuser_repos(self): + data = self.getJson('/api/currentuser/repos') + self.assertEqual(data, ['testcases/']) + + +if __name__ == "__main__": + # import sys;sys.argv = ['', 'Test.testName'] + logging.basicConfig(level=logging.DEBUG) + unittest.main() -- GitLab