diff --git a/.travis.yml b/.travis.yml index 51c3295..27ef003 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ services: - elasticsearch install: - - "pip install flake8" + - "pip install flake8 sphinx" - "python setup.py install" before_script: @@ -16,3 +16,4 @@ before_script: script: - 'flake8' - python setup.py test + - "python setup.py build_sphinx" diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..7c6aeab --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,35 @@ +=================== +Libreant changelog +=================== + +0.3 ++++ + +Major changes: +-------------- +- Implemented a role-based access control layer. + This means that libreant now support the common ``login`` procedure. + This functionality isn't documented yet, anyway you can use the brand new ``libreant-users`` command to manage users, groups and capabilities, + and enable this feature at runtime with the ``--users-db`` parameter. + The default user is (user: admin, password: admin) + +Web interface: +-------------- +- Added possibility to delete a volume through a button on the single-volume-view page. +- New user menu (only in users-mode) +- New login/logut pages. +- Improoved error messages/pages + +Deployment: +----------- +- Removed elasticsearch strong dependecy. + Now libreant can be started with elasticsearch still not ready or not running. +- Bugfix: make libreant command exits with code 1 on exception. +- Fixed ``elasticsearch-py`` version dependency. Now the version must be ``>=1`` and ``<2``. +- Reloader is used only in debug mode (``--debug``). +- More uniform logs. + +Documentation: +-------------- +- The suggested version for elasticsearch installation has been updated: ``1.4`` -> ``1.7`` +- A lot of packages have been inserted in the official docs. diff --git a/DATA.mdwn b/DATA.mdwn deleted file mode 100644 index 130d2d2..0000000 --- a/DATA.mdwn +++ /dev/null @@ -1,25 +0,0 @@ -Rationale -=========== - -You have installed elasticsearch, setup your virtualenv and your python dependencies. Now you want to test it -a bit. Or maybe you want to develop, and need some "lorem ipsum" data. This howto is for you! - -HowTo -===== - -First of all, make sure that elasticsearch is running. - -Then, "enter" your virtualenv. This typically is - -```sh -source ve/bin/activate -``` - -Now run -```sh -curl -s 'http://boyska.s.pt-labs.net/libreant/contrib/colibri.json' 'http://boyska.s.pt-labs.net/libreant/contrib/rabbia/rabbia.json' 'http://boyska.s.pt-labs.net/libreant/contrib/csv2json/info_forte.json' | webant-manage db_import - -``` - -This will fetch three sample "libraries" and add every book to your elasticsearch. - -Of course you can exclude the ones that you don't like, or create your own. diff --git a/MANIFEST.in b/MANIFEST.in index cab3948..726d9c0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ recursive-include webant/templates * recursive-include webant/translations *.po include msgfmt.py include README.rst +include CHANGELOG.rst diff --git a/Vagrantfile b/Vagrantfile index f330258..a5846f4 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -14,7 +14,7 @@ end $script = < + +{% endblock scripts%} diff --git a/webant/templates/error.html b/webant/templates/error.html index 59000bd..846c416 100644 --- a/webant/templates/error.html +++ b/webant/templates/error.html @@ -6,7 +6,7 @@ {% endblock %} {% block navbar %} -{% import 'navbar.html' as navbar %} +{% import 'navbar.html' as navbar with context %} {{navbar.navbar(search=True)}} {% endblock %} diff --git a/webant/templates/index.html b/webant/templates/index.html index 30df406..c620e46 100644 --- a/webant/templates/index.html +++ b/webant/templates/index.html @@ -7,7 +7,7 @@ {% endblock %} {% block navbar %} -{% import 'navbar.html' as navbar %} +{% import 'navbar.html' as navbar with context %} {{navbar.navbar(search=False)}} {% endblock %} diff --git a/webant/templates/login.html b/webant/templates/login.html new file mode 100644 index 0000000..93799ed --- /dev/null +++ b/webant/templates/login.html @@ -0,0 +1,41 @@ +{% extends "bootstrap/base.html" %} +{% import "bootstrap/fixes.html" as fixes %} +{% import 'searchbar.html' as searchbar %} + +{% block title %} +Libreant | {%trans%}Login{%endtrans%} +{% endblock %} + +{% block navbar %} +{% import 'navbar.html' as navbar with context%} +{{navbar.navbar()}} +{% endblock %} + +{% block styles %} +{{ super() }} +{% endblock styles %} + +{% block content %} +
+
+
+ {% if message %} + + {% endif %} +
+
+ + +
+
+ + +
+ +
+
+
+
+{% endblock content %} diff --git a/webant/templates/navbar.html b/webant/templates/navbar.html index 5f0e729..f30c118 100644 --- a/webant/templates/navbar.html +++ b/webant/templates/navbar.html @@ -1,7 +1,7 @@ {% macro navbar(search=True, search_query="") %} diff --git a/webant/templates/recents.html b/webant/templates/recents.html index d047192..c47e7e0 100644 --- a/webant/templates/recents.html +++ b/webant/templates/recents.html @@ -22,7 +22,7 @@ {% endblock %} {% block navbar %} -{% import 'navbar.html' as navbar %} +{% import 'navbar.html' as navbar with context %} {{ navbar.navbar(search=True) }} {% endblock %} diff --git a/webant/templates/search.html b/webant/templates/search.html index 040f7af..dd4109f 100644 --- a/webant/templates/search.html +++ b/webant/templates/search.html @@ -7,7 +7,7 @@ {% endblock %} {% block navbar %} -{% import 'navbar.html' as navbar %} +{% import 'navbar.html' as navbar with context %} {{navbar.navbar(search=False)}} {% endblock %} diff --git a/webant/test/__init__.py b/webant/test/__init__.py new file mode 100644 index 0000000..c951e12 --- /dev/null +++ b/webant/test/__init__.py @@ -0,0 +1,13 @@ +import unittest +from webant import create_app +from conf.defaults import get_def_conf + + +class WebantTestCase(unittest.TestCase): + + def setUp(self): + conf = get_def_conf() + conf['USERS_DATABASE'] = "sqlite:///:memory:" + conf['PWD_ROUNDS'] = 1 + conf['TESTING'] = True + self.wtc = create_app(conf).test_client() diff --git a/webant/test/api/__init__.py b/webant/test/api/__init__.py new file mode 100644 index 0000000..cd79233 --- /dev/null +++ b/webant/test/api/__init__.py @@ -0,0 +1,42 @@ +from webant.test import WebantTestCase +from flask.json import loads, dumps + + +class WebantTestApiCase(WebantTestCase): + + API_PREFIX = '/api/v1' + + GRP_URI = API_PREFIX + '/groups/' + USR_URI = API_PREFIX + '/users/' + CAP_URI = API_PREFIX + '/capabilities/' + + def add_user(self, userData): + res = self.wtc.post(self.API_PREFIX + '/users/', + data=dumps(userData), + content_type="application/json") + if not res.status_code == 201: + raise ApiClientError(res) + return loads(res.data)['data']['id'] + + def add_group(self, groupData): + res = self.wtc.post(self.GRP_URI, + data=dumps(groupData), + content_type="application/json") + if not res.status_code == 201: + raise ApiClientError(res) + return loads(res.data)['data']['id'] + + def add_capability(self, capData): + res = self.wtc.post(self.CAP_URI, + data=dumps(capData), + content_type="application/json") + if not res.status_code == 201: + raise ApiClientError(res) + return loads(res.data)['data']['id'] + + +class ApiClientError(Exception): + + def __init__(self, res): + super(ApiClientError, self).__init__(loads(res.data)) + self.res = res diff --git a/webant/test/api/test_api_capabilities.py b/webant/test/api/test_api_capabilities.py new file mode 100644 index 0000000..f2296cb --- /dev/null +++ b/webant/test/api/test_api_capabilities.py @@ -0,0 +1,152 @@ +from webant.test.api import WebantTestApiCase, ApiClientError +from nose.tools import eq_ +from flask.json import loads, dumps + + +class TestApiCapabilities(WebantTestApiCase): + + def test_get_capability_not_exist(self): + r = self.wtc.get(self.CAP_URI + 'a1s2d') + eq_(r.status_code, 404) + r = self.wtc.get(self.CAP_URI + '10233') + eq_(r.status_code, 404) + + def test_add_capability(self): + self.add_capability({'domain':'/res1/id1/res2/*', 'actions':['READ','UPDATE']}) + + def test_add_capability_wrong_content_type(self): + r = self.wtc.post(self.CAP_URI, + data=dumps({'domain':'/res1/id1/res2/*', 'actions':['READ','UPDATE']})) + eq_(r.status_code, 415) + r = self.wtc.post(self.CAP_URI, + data="asdasd", + content_type="application/json") + eq_(r.status_code, 400) + + def test_add_capability_no_actions(self): + with self.assertRaises(ApiClientError) as ace: + self.add_capability({'domain':'/res1/id1/res2/*'}) + eq_(ace.res.status_code, 400) + + def test_add_capability_no_domain(self): + with self.assertRaises(ApiClientError) as ace: + self.add_capability({'actions':['UPDATE', 'DELETE']}) + eq_(ace.res.status_code, 400) + + def test_add_capability_same_name(self): + capData = {'domain':'/res1/id1/res2/*', 'actions':['READ','UPDATE']} + self.add_capability(capData) + self.add_capability(capData) + + def test_get_capability(self): + capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']} + capID = self.add_capability(capData) + rg = self.wtc.get(self.CAP_URI + str(capID)) + eq_(rg.status_code, 200) + capDataRet = loads(rg.data)['data'] + eq_(capDataRet['domain'], capData['domain']) + eq_(capDataRet['actions'], capData['actions']) + + def test_delete_capability(self): + capData = {'domain':'*', 'actions':['DELETE']} + capID = self.add_capability(capData) + r = self.wtc.delete(self.CAP_URI + str(capID)) + eq_(r.status_code, 200) + + def test_delete_capability_not_exists(self): + r = self.wtc.delete(self.CAP_URI + str(2)) + eq_(r.status_code, 404) + + def test_update_capability(self): + capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']} + capID = self.add_capability(capData) + r = self.wtc.patch(self.CAP_URI + str(capID), + data=dumps({'actions':['READ']}), + content_type="application/json") + eq_(r.status_code, 200) + r = self.wtc.get(self.CAP_URI + str(capID)) + eq_(loads(r.data)['data']['actions'], ['READ']) + r = self.wtc.patch(self.CAP_URI + str(capID), + data=dumps({'domain':'*'}), + content_type="application/json") + eq_(r.status_code, 200) + r = self.wtc.get(self.CAP_URI + str(capID)) + eq_(loads(r.data)['data']['domain'], '*') + + def test_update_capability_wrong_content_type(self): + capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']} + capID = self.add_capability(capData) + r = self.wtc.patch(self.CAP_URI + str(capID), + data=dumps(capData)) + eq_(r.status_code, 415) + r = self.wtc.patch(self.CAP_URI + str(capID), + data="asdasd", + content_type="application/json") + eq_(r.status_code, 400) + + def test_update_capability_not_exist(self): + capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']} + r = self.wtc.patch(self.CAP_URI + str(50), + content_type="application/json", + data=dumps(capData)) + eq_(r.status_code, 404) + + def test_add_capability_to_group(self): + gid = self.add_group({'name':'groupName'}) + capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']} + capID = self.add_capability(capData) + r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID)) + eq_(r.status_code, 200) + + def test_add_capabilty_to_group_not_exist(self): + gid = self.add_group({'name':'groupName'}) + capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']} + capID = self.add_capability(capData) + r = self.wtc.put(self.GRP_URI + str(123) + '/capabilities/' + str(capID)) + eq_(r.status_code, 404) + r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(123)) + eq_(r.status_code, 404) + + def test_get_capability_in_group(self): + gid = self.add_group({'name':'groupName'}) + capData = {'domain':'res1/*', 'actions':['CREATE']} + capID = self.add_capability(capData) + r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID)) + r = self.wtc.get(self.GRP_URI + str(gid) + '/capabilities/') + eq_(r.status_code, 200) + eq_(loads(r.data)['data'][0]['id'], capID) + + def test_get_capabilities_of_group_not_exist(self): + r = self.wtc.get(self.GRP_URI + str(1233) + '/capabilities/') + eq_(r.status_code, 404) + + def test_get_groups_with_capability(self): + gid = self.add_group({'name':'groupName'}) + capData = {'domain':'res1/*', 'actions':['CREATE']} + capID = self.add_capability(capData) + r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID)) + r = self.wtc.get(self.CAP_URI + str(capID) + '/groups/') + eq_(r.status_code, 200) + eq_(loads(r.data)['data'][0]['id'], gid) + + def test_get_groups_with_capability_not_exist(self): + r = self.wtc.get(self.CAP_URI + str(1233) + '/groups/') + eq_(r.status_code, 404) + + def test_remove_capabilty_from_group(self): + gid = self.add_group({'name':'groupName'}) + capData = {'domain':'res1/*', 'actions':['CREATE']} + capID = self.add_capability(capData) + r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID)) + r = self.wtc.delete(self.GRP_URI + str(gid) + '/capabilities/' + str(capID)) + eq_(r.status_code, 200) + + def test_remove_capability_from_group_not_exist(self): + gid = self.add_group({'name':'groupName'}) + capData = {'domain':'res1/*', 'actions':['CREATE']} + capID = self.add_capability(capData) + r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID)) + r = self.wtc.delete(self.GRP_URI + str(gid) + '/capabilities/' + str(3214)) + eq_(r.status_code, 404) + r = self.wtc.delete(self.GRP_URI + str(3123) + '/capabilities/' + str(capID)) + eq_(r.status_code, 404) diff --git a/webant/test/api/test_api_groups.py b/webant/test/api/test_api_groups.py new file mode 100644 index 0000000..b180aff --- /dev/null +++ b/webant/test/api/test_api_groups.py @@ -0,0 +1,129 @@ +from webant.test.api import WebantTestApiCase, ApiClientError +from nose.tools import eq_ +from flask.json import loads, dumps + + +class TestApiGroups(WebantTestApiCase): + + def test_get_group_not_exist(self): + r = self.wtc.get(self.GRP_URI + 'a1s2d') + eq_(r.status_code, 404) + r = self.wtc.get(self.GRP_URI + '10233') + eq_(r.status_code, 404) + + def test_add_group(self): + self.add_group({'name':'groupName'}) + + def test_add_group_wrong_content_type(self): + r = self.wtc.post(self.GRP_URI, + data=dumps({'name':'groupStoName'})) + eq_(r.status_code, 415) + r = self.wtc.post(self.GRP_URI, + data="asdasd", + content_type="application/json") + eq_(r.status_code, 400) + + def test_add_group_no_name(self): + with self.assertRaises(ApiClientError) as ace: + self.add_group({'altro':'altroValue'}) + eq_(ace.res.status_code, 400) + + def test_add_group_same_name(self): + self.add_group({'name':'groupName'}) + with self.assertRaises(ApiClientError) as ace: + self.add_group({'name':'groupName'}) + eq_(ace.res.status_code, 409) + + def test_get_group(self): + gid = self.add_group({'name':'groupName'}) + rg = self.wtc.get(self.GRP_URI + str(gid)) + eq_(rg.status_code, 200) + + def test_delete_group(self): + gid = self.add_group({'name':'groupName'}) + r = self.wtc.delete(self.GRP_URI + str(gid)) + eq_(r.status_code, 200) + + def test_delete_grop_not_exists(self): + r = self.wtc.delete(self.GRP_URI + str(56)) + eq_(r.status_code, 404) + + def test_update_group(self): + gid = self.add_group({'name':'groupName'}) + r = self.wtc.patch(self.GRP_URI + str(gid), + data=dumps({'name':'otherGroupName'}), + content_type="application/json") + eq_(r.status_code, 200) + r = self.wtc.get(self.GRP_URI + str(gid)) + eq_(loads(r.data)['data']['name'], 'otherGroupName') + + def test_update_group_wrong_content_type(self): + gid = self.add_group({'name':'groupName'}) + r = self.wtc.patch(self.GRP_URI + str(gid), + data=dumps({'name':'groupStoName'})) + eq_(r.status_code, 415) + r = self.wtc.patch(self.GRP_URI + str(gid), + data="asdasd", + content_type="application/json") + eq_(r.status_code, 400) + + def test_update_group_not_exist(self): + self.add_group({'name':'groupName'}) + r = self.wtc.patch(self.GRP_URI + str(50), + content_type="application/json", + data=dumps({'name':'seStaAFaTardi'})) + eq_(r.status_code, 404) + + def test_add_user_to_group(self): + gid = self.add_group({'name':'groupName'}) + uid = self.add_user({'name':'userName', 'password':'pwd'}) + r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid)) + eq_(r.status_code, 200) + + def test_add_user_to_group_not_exist(self): + uid = self.add_user({'name':'userName', 'password':'pwd'}) + gid = self.add_group({'name':'groupName'}) + r = self.wtc.put(self.GRP_URI + str(123) + '/users/' + str(uid)) + eq_(r.status_code, 404) + r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(123)) + eq_(r.status_code, 404) + + def test_get_users_in_group(self): + gid = self.add_group({'name':'groupName'}) + uid = self.add_user({'name':'userName', 'password':'pwd'}) + r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid)) + r = self.wtc.get(self.GRP_URI + str(gid) + '/users/') + eq_(r.status_code, 200) + eq_(loads(r.data)['data'][0]['id'], uid) + + def test_get_users_from_group_not_exist(self): + r = self.wtc.get(self.GRP_URI + str(1233) + '/users/') + eq_(r.status_code, 404) + + def test_get_groups_from_user(self): + gid = self.add_group({'name':'groupName'}) + uid = self.add_user({'name':'userName', 'password':'pwd'}) + r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid)) + r = self.wtc.get(self.USR_URI + str(uid) + '/groups/') + eq_(r.status_code, 200) + eq_(loads(r.data)['data'][0]['id'], gid) + + def test_get_groups_from_user_not_exist(self): + r = self.wtc.get(self.USR_URI + str(1233) + '/groups/') + eq_(r.status_code, 404) + + def test_remove_user_from_group(self): + gid = self.add_group({'name':'groupName'}) + uid = self.add_user({'name':'userName', 'password':'pwd'}) + r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid)) + r = self.wtc.delete(self.GRP_URI + str(gid) + '/users/' + str(uid)) + eq_(r.status_code, 200) + + def test_remove_user_from_group_not_exist(self): + gid = self.add_group({'name':'groupName'}) + uid = self.add_user({'name':'userName', 'password':'pwd'}) + r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid)) + r = self.wtc.delete(self.GRP_URI + str(gid) + '/users/' + str(3214)) + eq_(r.status_code, 404) + r = self.wtc.delete(self.GRP_URI + str(3123) + '/users/' + str(uid)) + eq_(r.status_code, 404) diff --git a/webant/test/api/test_api_users.py b/webant/test/api/test_api_users.py new file mode 100644 index 0000000..8ad23fd --- /dev/null +++ b/webant/test/api/test_api_users.py @@ -0,0 +1,85 @@ +from webant.test.api import WebantTestApiCase, ApiClientError +from nose.tools import eq_ +from flask.json import dumps + + +class TestApiUsers(WebantTestApiCase): + + def test_add_user(self): + self.add_user({'name':'testName', 'password': 'testPassword'}) + + def test_add_user_wrong_content_type(self): + r = self.wtc.post(self.USR_URI, + data=dumps({'name':'testName', 'password': 'testPassword'})) + eq_(r.status_code, 415) + r = self.wtc.post(self.USR_URI, + data="asdasd", + content_type="application/json") + eq_(r.status_code, 400) + + def test_get_user_not_exist(self): + r = self.wtc.get(self.API_PREFIX + '/users/12') + eq_(r.status_code, 404) + + def test_get_user(self): + userData = {'name':'testName', 'password': 'testPassword'} + uid = self.add_user(userData) + rg = self.wtc.get(self.API_PREFIX + '/users/' + str(uid)) + eq_(rg.status_code, 200) + + def test_add_user_no_name(self): + with self.assertRaises(ApiClientError) as ace: + self.add_user({'password':'testPassword'}) + eq_(ace.res.status_code, 400) + + def test_add_user_no_pass(self): + with self.assertRaises(ApiClientError) as ace: + self.add_user({'name':'testName'}) + eq_(ace.res.status_code, 400) + + def test_add_user_same_name(self): + user_data = {'name':'testName', 'password': 'testPassword'} + self.add_user(user_data) + with self.assertRaises(ApiClientError) as ace: + self.add_user(user_data) + eq_(ace.res.status_code, 409) + + def test_delete_user(self): + userData = {'name':'testName', 'password': 'testPassword'} + uid = self.add_user(userData) + r = self.wtc.delete(self.API_PREFIX + '/users/' + str(uid)) + eq_(r.status_code, 200) + + def test_delete_user_not_exists(self): + r = self.wtc.delete(self.API_PREFIX + '/users/' + str(56)) + eq_(r.status_code, 404) + + def test_update_user(self): + userData = {'name':'testName', 'password': 'testPassword'} + uid = self.add_user(userData) + userData = {'name':'testName2', 'password': 'testPassword2'} + r = self.wtc.patch(self.API_PREFIX + '/users/' + str(uid), + data=dumps(userData), + content_type="application/json") + eq_(r.status_code, 200) + + def test_update_user_wrong_content_type(self): + userData = {'name':'testName', 'password': 'testPassword'} + uid = self.add_user(userData) + userData = {'name':'testName2', 'password': 'testPassword2'} + r = self.wtc.patch(self.API_PREFIX + '/users/' + str(uid), + data=dumps(userData)) + eq_(r.status_code, 415) + r = self.wtc.patch(self.API_PREFIX + '/users/' + str(uid), + data="asdasd", + content_type="application/json") + eq_(r.status_code, 400) + + def test_update_user_not_exist(self): + userData = {'name':'testName', 'password': 'testPassword'} + self.add_user(userData) + userData = {'name':'testName2', 'password': 'testPassword2'} + r = self.wtc.patch(self.API_PREFIX + '/users/' + str(50), + content_type="application/json", + data=dumps(userData)) + eq_(r.status_code, 404) diff --git a/webant/test/test_api_archivant.py b/webant/test/test_api_archivant.py new file mode 100644 index 0000000..bba8ecc --- /dev/null +++ b/webant/test/test_api_archivant.py @@ -0,0 +1,10 @@ +from webant.test import WebantTestCase +from nose.tools import eq_ + + +class TestApiArchivant(WebantTestCase): + + API_PREFIX = '/api/v1' + + def test_get_volumes(self): + eq_(self.wtc.get(self.API_PREFIX + '/volumes/').status_code, 200) diff --git a/webant/util.py b/webant/util.py index e0e2b1c..574e605 100644 --- a/webant/util.py +++ b/webant/util.py @@ -1,5 +1,7 @@ import functools -from flask import send_file +from flask import send_file, session +from authbone import Authenticator, Authorizator +from users.api import get_user, get_anonymous_user, NotFoundException def memoize(obj): @@ -48,3 +50,96 @@ def send_attachment_file(archivant, volumeID, attachmentID): mimetype=attachment['metadata']['mime'], attachment_filename=attachment['metadata']['name'], as_attachment=True) + + +def routes_collector(gatherer): + """Decorator utility to collect flask routes in a dictionary. + + This function together with :func:`add_routes` provides an + easy way to split flask routes declaration in multiple modules. + + :param gatherer: dict in which will be collected routes + + The decorator provided by this function should be used as the + `original flask decorator `_ + example:: + + routes = [] + route = routes_collector(routes) + + @route('/volumes/', methods=['GET', 'POST']) + def volumes(): + return 'page body' + + After you've collected your routes you can use :func:`add_routes` to register + them onto the main blueprint/flask_app. + """ + def hatFunc(rule, **options): + def decorator(f): + rule_dict = {'rule':rule, 'view_func':f} + rule_dict.update(options) + gatherer.append(rule_dict) + return decorator + return hatFunc + + +def add_routes(fapp, routes, prefix=""): + """Batch routes registering + + Register routes to a blueprint/flask_app previously collected + with :func:`routes_collector`. + + :param fapp: bluprint or flask_app to whom attach new routes. + :param routes: dict of routes collected by :func:`routes_collector` + :param prefix: url prefix under which register all routes + """ + for r in routes: + r['rule'] = prefix + r['rule'] + fapp.add_url_rule(**r) + + +class AuthtFromSession(Authenticator): + + USERID_KEY = 'user_id' + + def login(self, userID): + session[self.USERID_KEY] = userID + + def logout(self): + session.pop(self.USERID_KEY) + + def is_logged_in(self): + return self.USERID_KEY in session + + def auth_data_getter(self): + return session.get(self.USERID_KEY, None) + + def authenticate(self, userID): + try: + return get_user(id=userID) + except NotFoundException: + return None + + def bad_auth_data_callback(self): + self.identity_elaborator(get_anonymous_user()) + + def not_authenticated_callback(self): + self.identity_elaborator(get_anonymous_user()) + + +class AuthzFromSession(Authorizator): + + def check_capability(self, identity, capability): + return identity.can(capability[0], capability[1]) + + +class TransparentAutht(AuthtFromSession): + + def perform_authentication(self, *args, **kwargs): + pass + + +class TransparentAuthz(AuthzFromSession): + + def perform_authorization(self, *args, **kwargs): + pass diff --git a/webant/webant.py b/webant/webant.py index 0ba210f..71833bd 100644 --- a/webant/webant.py +++ b/webant/webant.py @@ -1,13 +1,14 @@ import tempfile import os -from flask import Flask, render_template, request, abort, Response, redirect, url_for, make_response +from flask import Flask, render_template, request, Response, redirect, url_for from werkzeug import secure_filename from flask_bootstrap import Bootstrap from elasticsearch import exceptions as es_exceptions from flask.ext.babel import Babel, gettext from babel.dates import format_timedelta from datetime import datetime +from logging import getLogger from presets import PresetManager from constants import isoLangs @@ -17,6 +18,9 @@ from agherant import agherant from api.blueprint_api import api from webserver_utils import gevent_run +import users +import util +from authbone.authorization import CapabilityMissingException class LibreantCoreApp(Flask): @@ -27,29 +31,59 @@ def __init__(self, import_name, conf={}): 'FSDB_PATH': "", 'SECRET_KEY': 'really insecure, please change me!', 'ES_HOSTS': None, - 'ES_INDEXNAME': 'libreant' + 'ES_INDEXNAME': 'libreant', + 'USERS_DATABASE': "", + 'PWD_ROUNDS': None, + 'PWD_SALT_SIZE': None } defaults.update(conf) self.config.update(defaults) + '''dirty trick: prevent default flask handler to be created + in flask version > 0.10.1 will be a nicer way to disable default loggers + tanks to this new code mitsuhiko/flask@84ad89ffa4390d3327b4d35983dbb4d84293b8e2 + ''' + self._logger = getLogger(self.import_name) + self.archivant = Archivant(conf={k: self.config[k] for k in ('FSDB_PATH', 'ES_HOSTS', 'ES_INDEXNAME')}) self.presetManager = PresetManager(self.config['PRESET_PATHS']) + if self.config['USERS_DATABASE']: + self.usersDB = users.init_db(self.config['USERS_DATABASE'], + pwd_salt_size=self.config['PWD_SALT_SIZE'], + pwd_rounds=self.config['PWD_ROUNDS']) + users.populate_with_defaults() + else: + self.logger.warning("""It has not been set any value for 'USERS_DATABASE', \ +all operations about users will be unsupported. Are all admins.""") + self.usersDB = None + + @property + def users_enabled(self): + return bool(self.usersDB) + class LibreantViewApp(LibreantCoreApp): def __init__(self, import_name, conf={}): defaults = { 'BOOTSTRAP_SERVE_LOCAL': True, 'AGHERANT_DESCRIPTIONS': [], + 'API_URL': "/api/v1" } defaults.update(conf) super(LibreantViewApp, self).__init__(import_name, defaults) if self.config['AGHERANT_DESCRIPTIONS']: self.register_blueprint(agherant, url_prefix='/agherant') - self.register_blueprint(api, url_prefix='/api/v1') + self.register_blueprint(api, url_prefix=self.config['API_URL']) Bootstrap(self) self.babel = Babel(self) self.available_translations = [l.language for l in self.babel.list_translations()] + if self.users_enabled: + self.autht = util.AuthtFromSession() + self.authz = util.AuthzFromSession(authenticator=self.autht) + else: + self.autht = util.TransparentAutht() + self.authz = util.TransparentAuthz() def create_app(conf={}): @@ -63,7 +97,7 @@ def index(): def search(): query = request.args.get('q', None) if query is None: - abort(400, "No query given") + return renderErrorPage(message='No query given', httpCode=400) res = app.archivant._db.user_search(query)['hits']['hits'] books = [] for b in res: @@ -81,9 +115,10 @@ def search(): books=books, query=query), mimetype='text/xml') else: - abort(500) + return renderErrorPage(message='Unknown format requested', httpCode=400) @app.route('/add', methods=['POST']) + @app.authz.requires_capability(('/volumes', users.Action.CREATE)) def upload(): requiredFields = ['_language'] optFields = ['_preset'] @@ -127,6 +162,7 @@ def upload(): return redirect(url_for('view_volume', volumeID=addedVolumeID)) @app.route('/add', methods=['GET']) + @app.authz.requires_capability(('/volumes', users.Action.CREATE)) def add(): reqPreset = request.args.get('preset', None) @@ -146,14 +182,25 @@ def description(): mimetype='text/xml') @app.route('/view/') + @app.autht.requires_authentication def view_volume(volumeID): + app.authz.perform_authorization(('volumes/{}'.format(volumeID), users.Action.READ)) try: volume = app.archivant.get_volume(volumeID) except NotFoundException: return renderErrorPage(message='no volume found with id "{}"'.format(volumeID), httpCode=404) + # hide button from action toolbar if current user has not capability to perform them + hideFromToolbar = None + if app.users_enabled: + currentDomain = 'volumes/{}'.format(volumeID) + hideFromToolbar = {} + hideFromToolbar['delete'] = not app.autht.currIdentity.can(currentDomain, users.Action.DELETE) similar = app.archivant._db.mlt(volume['id'])['hits']['hits'][:10] return render_template('details.html', - volume=volume, similar=similar) + volume=volume, + similar=similar, + hide_from_toolbar=hideFromToolbar, + api_url=app.config['API_URL']) @app.route('/download//') def download_attachment(volumeID, attachmentID): @@ -175,6 +222,47 @@ def get_locale(): return request.values['lang'] return request.accept_languages.best_match(app.available_translations) + if app.users_enabled: + @app.route('/login', methods=['GET']) + def login_form(): + if app.autht.is_logged_in(): + return renderErrorPage('you are already logged in', 400) + return render_template('login.html') + + @app.route('/login', methods=['POST']) + def login(): + name = request.form.get('username', None) + pwd = request.form.get('password', None) + if not name: + return render_template('login.html', message='Missing username'), 400 + elif not pwd: + return render_template('login.html', message='Missing password'), 400 + try: + usr = users.api.get_user(name=name) + except users.api.NotFoundException: + return render_template('login.html', message='"{}" is not registered'.format(name)), 400 + if usr.verify_password(pwd): + app.autht.login(usr.id) + return redirect(url_for('index'), code=302) + return render_template('login.html', message='Wrong password') + + @app.route('/logout') + def logout(): + if not app.autht.is_logged_in(): + return renderErrorPage('you are not logged in', 400) + app.autht.logout() + return redirect(url_for('index'), code=302) + + def current_user(): + app.autht.perform_authentication() + if users.api.is_anonymous(app.autht.currIdentity): + return None + return app.autht.currIdentity + + @app.context_processor + def user_processor(): + return dict(users_enabled = True, current_user = current_user) + @app.template_filter('timepassedformat') def timepassedformat_filter(timestamp): '''given a timestamp it returns a string @@ -190,9 +278,8 @@ def timepassedformat_filter(timestamp): @app.errorhandler(es_exceptions.ConnectionError) def handle_elasticsearch_down(error): - rsp = make_response('DB connection error', 503) - app.logger.error("Error connecting to DB; check your configuration") - return rsp + app.logger.exception("Error connecting to DB; check your configuration") + return renderErrorPage(message='DB connection error', httpCode=503) @app.errorhandler(404) def not_found(error): @@ -203,6 +290,10 @@ def not_found(error): def internal_server_error(error): return renderErrorPage(message='Internal Server Error', httpCode=500) + @app.errorhandler(CapabilityMissingException) + def not_authenticated_handler(error): + return renderErrorPage(message='Authorization Required', httpCode=401) + return app diff --git a/webant/webserver_utils.py b/webant/webserver_utils.py index 68c60b3..f5b5ca7 100644 --- a/webant/webserver_utils.py +++ b/webant/webserver_utils.py @@ -7,16 +7,20 @@ def gevent_run(app): from gevent.wsgi import WSGIServer import gevent.monkey from werkzeug.debug import DebuggedApplication - from werkzeug.serving import run_with_reloader gevent.monkey.patch_socket() run_app = app if app.config['DEBUG']: run_app = DebuggedApplication(app) - @run_with_reloader def run_server(): port = int(app.config.get('PORT', 5000)) address = app.config.get('ADDRESS', '') print('Listening on http://%s:%d/' % (address or '0.0.0.0', port)) http_server = WSGIServer((address, port), run_app) http_server.serve_forever() + + if app.config['DEBUG']: + from werkzeug.serving import run_with_reloader + run_with_reloader(run_server) + else: + run_server()