diff options
| author | 0livd <github@destras.fr> | 2017-09-04 15:44:20 +0200 |
|---|---|---|
| committer | Alexis Metaireau <alexis@notmyidea.org> | 2017-09-04 15:44:20 +0200 |
| commit | 7a918c93498c2eb4f85b3b0198c03f3c2edf51fe (patch) | |
| tree | 885fe498c3f27acb8fc73f5d37765c7e42669762 | |
| parent | ee1ecbf3e747daa711744a05027a667a302cc7e9 (diff) | |
| download | ihatemoney-mirror-7a918c93498c2eb4f85b3b0198c03f3c2edf51fe.zip ihatemoney-mirror-7a918c93498c2eb4f85b3b0198c03f3c2edf51fe.tar.gz ihatemoney-mirror-7a918c93498c2eb4f85b3b0198c03f3c2edf51fe.tar.bz2 | |
Enhance the dashboard. (#262)
* Update to a more flexible admin authentication
* Admin can now access any project
* Add delete and edit options in the dashboard
* Add a link to the dashboard in the nav bar
This is a rework of the changes proposed by @Olivd, so they can apply on top of
the latest master without trouble. All credit goes to him for the code.
| -rw-r--r-- | CHANGELOG.rst | 13 | ||||
| -rw-r--r-- | docs/installation.rst | 50 | ||||
| -rw-r--r-- | ihatemoney/default_settings.py | 6 | ||||
| -rw-r--r-- | ihatemoney/static/css/main.css | 24 | ||||
| -rw-r--r-- | ihatemoney/templates/admin.html | 12 | ||||
| -rw-r--r-- | ihatemoney/templates/authenticate.html | 6 | ||||
| -rw-r--r-- | ihatemoney/templates/dashboard.html | 12 | ||||
| -rw-r--r-- | ihatemoney/templates/home.html | 6 | ||||
| -rw-r--r-- | ihatemoney/templates/layout.html | 3 | ||||
| -rw-r--r-- | ihatemoney/tests/tests.py | 31 | ||||
| -rw-r--r-- | ihatemoney/translations/fr/LC_MESSAGES/messages.mo | bin | 8789 -> 9024 bytes | |||
| -rw-r--r-- | ihatemoney/translations/fr/LC_MESSAGES/messages.po | 8 | ||||
| -rw-r--r-- | ihatemoney/web.py | 80 |
13 files changed, 193 insertions, 58 deletions
diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 94b802c..1dd87ce 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,16 +6,25 @@ This document describes changes between each past release. 2.0 (unreleased) ---------------- +### Breaking changes + +- ``ADMIN_PASSWORD`` is now hashed rather than plain text. The ``ihatemoney generate_password_hash`` command can now be used to generate a proper password HASH (#236) +- Turn the WSGI file into a python module, renamed from budget/ihatemoney.wsgi to ihatemoney/wsgi.py. Please update your Apache configuration! +- Admin privileges are now required to access the dashboard + ### Changed -- **BREAKING CHANGE** Use a hashed ``ADMIN_PASSWORD`` instead of a clear text one, ``./budget/manage.py generate_password_hash`` can be used to generate a proper password HASH (#236) -- **BREAKING CHANGE** Turn the WSGI file into a python module, renamed from budget/ihatemoney.wsgi to budget/wsgi.py. Please update your Apache configuration! - Changed the recommended gunicorn configuration to use the wsgi module as an entrypoint ### Added - Add a statistics tab (#257) - Add python3.6 support (#259) +- Public project creation can now be deactivated using the ALLOW_PUBLIC_PROJECT_CREATION setting. +- If admin credentials are defined, they can be used to access any project. +- It is now possible to edit and delete projects directly from the dashboard. +- The dashboard can now be deactivated using the ACTIVATE_ADMIN_DASHBOARD setting. +- When activated, a link to the dashboard appears in the navigation bar. ### Removed diff --git a/docs/installation.rst b/docs/installation.rst index e0f70df..dcc6231 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -92,27 +92,35 @@ properly. .. warning:: You **must** customize the ``SECRET_KEY`` on a production installation. -+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ -| Setting name | Default | What does it do? | -+============================+===========================+========================================================================================+ -| SQLALCHEMY_DATABASE_URI | ``sqlite:///budget.db`` | Specifies the type of backend to use and its location. More information | -| | | on the format used can be found on `the SQLAlchemy documentation | -| | | <http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls>`_. | -+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ -| SECRET_KEY | ``tralala`` | The secret key used to encrypt the cookies. **This needs to be changed**. | -+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ -| MAIL_DEFAULT_SENDER | ``("Budget manager", | A python tuple describing the name and email adress to use when sending | -| | "budget@notmyidea.org")`` | emails. | -+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ -| ACTIVATE_DEMO_PROJECT | ``True`` | If set to `True`, a demo project will be available on the frontpage. | -+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ -| | ``""`` | If not empty, the specified password must be entered to create new projects. | -| ADMIN_PASSWORD | | To generate the proper password HASH, use ``ihatemoney generate_password_hash`` | -| | | and copy its output into the value of *ADMIN_PASSWORD*. | -+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ -| APPLICATION_ROOT | ``""`` | If empty, ihatemoney will be served at domain root (e.g: *http://domain.tld*), if set | -| | | to ``"foo"``, it will be served from a "folder" (e.g: *http://domain.tld/foo*) | -+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ ++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+ +| Setting name | Default | What does it do? | ++===============================+===========================+========================================================================================+ +| SQLALCHEMY_DATABASE_URI | ``sqlite:///budget.db`` | Specifies the type of backend to use and its location. More information | +| | | on the format used can be found on `the SQLAlchemy documentation | +| | | <http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls>`_. | ++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+ +| SECRET_KEY | ``tralala`` | The secret key used to encrypt the cookies. **This needs to be changed**. | ++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+ +| MAIL_DEFAULT_SENDER | ``("Budget manager", | A python tuple describing the name and email adress to use when sending | +| | "budget@notmyidea.org")`` | emails. | ++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+ +| ACTIVATE_DEMO_PROJECT | ``True`` | If set to `True`, a demo project will be available on the frontpage. | ++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+ +| | | Hashed password to access protected endpoints. If left empty, all administrative | +| ADMIN_PASSWORD | ``""`` | tasks are disabled. | +| | | To generate the proper password HASH, use ``ihatemoney generate_password_hash`` | +| | | and copy the output into the value of *ADMIN_PASSWORD*. | ++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+ +| ALLOW_PUBLIC_PROJECT_CREATION | ``True`` | If set to `True`, everyone can create a project without entering the admin password | +| | | If set to `False`, the password needs to be entered (and as such, defined in the | +| | | settings). | ++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+ +| ACTIVATE_ADMIN_DASHBOARD | ``False`` | If set to `True`, the dashboard will become accessible entering the admin password | +| | | If set to `True`, a non empty ADMIN_PASSWORD needs to be set | ++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+ +| APPLICATION_ROOT | ``""`` | If empty, ihatemoney will be served at domain root (e.g: *http://domain.tld*), if set | +| | | to ``"foo"``, it will be served from a "folder" (e.g: *http://domain.tld/foo*) | ++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+ In a production environment --------------------------- diff --git a/ihatemoney/default_settings.py b/ihatemoney/default_settings.py index fcb41db..c7ce297 100644 --- a/ihatemoney/default_settings.py +++ b/ihatemoney/default_settings.py @@ -29,3 +29,9 @@ ACTIVATE_DEMO_PROJECT = True # DO NOT enter the password in cleartext. Generate a password hash with # "ihatemoney generate_password_hash" instead. ADMIN_PASSWORD = "" + +# If set to True (default value) anyone can create a new project. +ALLOW_PUBLIC_PROJECT_CREATION = True + +# If set to True, an administration dashboard is available. +ACTIVATE_ADMIN_DASHBOARD = False diff --git a/ihatemoney/static/css/main.css b/ihatemoney/static/css/main.css index 54a0008..73802a4 100644 --- a/ihatemoney/static/css/main.css +++ b/ihatemoney/static/css/main.css @@ -169,6 +169,30 @@ footer{ background: url('../images/edit.png') no-repeat right; } +project-actions { + padding-top: 10px; + text-align: center; +} + +.project-actions > .delete, .project-actions > .edit { + font-size: 0px; + display: block; + width: 16px; + height: 16px; + margin: 2px; + margin-left: 5px; + float: left; +} + +.project-actions > .delete{ + background: url('../images/delete.png') no-repeat right; +} + +.project-actions > .edit{ + background: url('../images/edit.png') no-repeat right; +} + + .balance .balance-value{ text-align:right; } diff --git a/ihatemoney/templates/admin.html b/ihatemoney/templates/admin.html new file mode 100644 index 0000000..031d27c --- /dev/null +++ b/ihatemoney/templates/admin.html @@ -0,0 +1,12 @@ +{% extends "layout.html" %} +{% block content %} +<h2>Authentication</h2> + +{% if is_admin_auth_enabled %} +<form class="form-horizontal" method="POST" accept-charset="utf-8"> + {{ forms.admin(form) }} +</form> +{% else %} +<div class="alert alert-danger">{{ _("Administration tasks are currently disabled.") }}</div> +{% endif %} +{% endblock %} diff --git a/ihatemoney/templates/authenticate.html b/ihatemoney/templates/authenticate.html index f241c48..98914d0 100644 --- a/ihatemoney/templates/authenticate.html +++ b/ihatemoney/templates/authenticate.html @@ -7,13 +7,7 @@ to") }} <a href="{{ url_for(".create_project", project_id=create_project) }}">{{ _("create it") }}</a>{{ _("?") }} </p> {% endif %} -{% if admin_auth %} -<form class="form-horizontal" method="POST" accept-charset="utf-8"> - {{ forms.admin(form) }} -</form> -{% else %} <form class="form-horizontal" method="POST" accept-charset="utf-8"> {{ forms.authenticate(form) }} </form> -{% endif %} {% endblock %} diff --git a/ihatemoney/templates/dashboard.html b/ihatemoney/templates/dashboard.html index 3f50915..b1220bd 100644 --- a/ihatemoney/templates/dashboard.html +++ b/ihatemoney/templates/dashboard.html @@ -1,8 +1,8 @@ {% extends "layout.html" %} {% block content %} - +{% if is_admin_dashboard_activated %} <table id="bill_table" class="table table-striped"> - <thead><tr><th>{{ _("Project") }}</th><th>{{ _("Number of members") }}</th><th>{{ _("Number of bills") }}</th><th>{{_("Newest bill")}}</th><th>{{_("Oldest bill")}}</th></tr></thead> + <thead><tr><th>{{ _("Project") }}</th><th>{{ _("Number of members") }}</th><th>{{ _("Number of bills") }}</th><th>{{_("Newest bill")}}</th><th>{{_("Oldest bill")}}</th><th>{{_("Actions")}}</th></tr></thead> <tbody>{% for project in projects|sort(attribute='name') %} <tr class="{{ loop.cycle("odd", "even") }}"> <td>{{ project.name }}</td><td>{{ project.members | count }}</td><td>{{ project.get_bills().count() }}</td> @@ -13,9 +13,15 @@ <td></td> <td></td> {% endif %} + <td class="project-actions"> + <a class="edit" href="{{ url_for(".edit_project", project_id=project.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a> + <a class="delete" href="{{ url_for(".delete_project", project_id=project.id) }}" title="{{ _("delete") }}">{{ _('delete') }}</a> + </td> </tr> {% endfor %} </tbody> </table> +{% else %} +<div class="alert alert-danger">{{ _("The Dashboard is currently deactivated.") }}</div> +{% endif %} {% endblock %} - diff --git a/ihatemoney/templates/home.html b/ihatemoney/templates/home.html index 9bfe467..a628ecc 100644 --- a/ihatemoney/templates/home.html +++ b/ihatemoney/templates/home.html @@ -28,9 +28,7 @@ </form> </div> <div class="col-xs-12 col-sm-5 col-md-3 offset-sm-1"> - {% if is_admin_mode_enabled %} - <a href="{{ url_for(".create_project") }}">...{{ _("or create a new one") }}</a> - {% else %} + {% if is_public_project_creation_allowed %} <form id="creation-form" class="form-horizontal" action="{{ url_for(".create_project") }}" method="post"> <fieldset class="form-group"> <legend>...{{ _("or create a new one") }}</legend> @@ -40,6 +38,8 @@ <button class="btn" type="submit">{{ _("let's get started") }}</button> </div> </form> + {% else %} + <a href="{{ url_for(".create_project") }}">...{{ _("or create a new one") }}</a> {% endif %} </main> </div> diff --git a/ihatemoney/templates/layout.html b/ihatemoney/templates/layout.html index 36f01f8..8510911 100644 --- a/ihatemoney/templates/layout.html +++ b/ihatemoney/templates/layout.html @@ -64,6 +64,9 @@ {% endif %} <li class="nav-item{% if g.lang == "fr" %} active{% endif %}"><a class="nav-link" href="{{ url_for(".change_lang", lang="fr") }}">fr</a></li> <li class="nav-item{% if g.lang == "en" %} active{% endif %}"><a class="nav-link" href="{{ url_for(".change_lang", lang="en") }}">en</a></li> + {% if g.show_admin_dashboard_link %} + <li class="nav-item{% if request.url_rule.endpoint == "main.dashboard" %} active{% endif %}"><a class="nav-link" href="{{ url_for(".dashboard") }}">{{ _("Dashboard") }}</a></li> + {% endif %} </ul> </div> </nav> diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index ac3551c..36ca6fc 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -379,8 +379,17 @@ class BudgetTestCase(IhatemoneyTestCase): c.get("/exit") self.assertNotIn('raclette', session) + # test that whith admin credentials, one can access every project + self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") + with self.app.test_client() as c: + resp = c.post("/admin?goto=%2Fraclette", data={'admin_password': 'pass'}) + self.assertNotIn("Authentication", resp.data.decode('utf-8')) + self.assertTrue(session['is_admin']) + def test_admin_authentication(self): self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") + # Disable public project creation so we have an admin endpoint to test + self.app.config['ALLOW_PUBLIC_PROJECT_CREATION'] = False # test the redirection to the authentication page when trying to access admin endpoints resp = self.client.get("/create") @@ -401,7 +410,8 @@ class BudgetTestCase(IhatemoneyTestCase): def test_login_throttler(self): self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") - # Authenticate 3 times with a wrong passsword + # Activate admin login throttling by authenticating 4 times with a wrong passsword + self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) @@ -624,8 +634,23 @@ class BudgetTestCase(IhatemoneyTestCase): self.assertIn("Invalid email address", resp.data.decode('utf-8')) def test_dashboard(self): - response = self.client.get("/dashboard") - self.assertEqual(response.status_code, 200) + # test that the dashboard is deactivated by default + resp = self.client.post( + "/admin?goto=%2Fdashboard", + data={'admin_password': 'adminpass'}, + follow_redirects=True + ) + self.assertIn('<div class="alert alert-danger">', resp.data.decode('utf-8')) + + # test access to the dashboard when it is activated + self.app.config['ACTIVATE_ADMIN_DASHBOARD'] = True + self.app.config['ADMIN_PASSWORD'] = generate_password_hash("adminpass") + resp = self.client.post( + "/admin?goto=%2Fdashboard", + data={'admin_password': 'adminpass'}, + follow_redirects=True + ) + self.assertIn('<thead><tr><th>Project</th><th>Number of members', resp.data.decode('utf-8')) def test_statistics_page(self): self.post_project("raclette") diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo Binary files differindex d6011d5..56b50d3 100644 --- a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo +++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po index e8b9793..93a80a9 100644 --- a/ihatemoney/translations/fr/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.po @@ -251,6 +251,10 @@ msgstr "le créer" msgid "?" msgstr " ?" +#: templates/authenticate.html:7 +msgid "Administration tasks are currently disabled." +msgstr "Les tâches d'administration sont actuellement désactivées." + #: templates/create_project.html:4 msgid "Create a new project" msgstr "Créer un nouveau projet" @@ -275,6 +279,10 @@ msgstr "Facture la plus récente" msgid "Oldest bill" msgstr "Facture la plus ancienne" +#: templates/dashboard.html:25 +msgid "The Dashboard is currently deactivated." +msgstr "La page d'administration est actuellement désactivée." + #: templates/edit_project.html:6 templates/list_bills.html:24 msgid "you sure?" msgstr "c'est sûr ?" diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 82e1591..753fe42 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -34,17 +34,30 @@ main = Blueprint("main", __name__) login_throttler = LoginThrottler(max_attempts=3, delay=1) -def requires_admin(f): +def requires_admin(bypass=None): """Require admin permissions for @requires_admin decorated endpoints. - Has no effect if ADMIN_PASSWORD is empty (default value) + + This has no effect if ADMIN_PASSWORD is empty. + + :param bypass: Used to conditionnaly bypass the admin authentication. + It expects a tuple containing the name of an application + setting and its expected value. + e.g. if you use @require_admin(bypass=("ALLOW_PUBLIC_PROJECT_CREATION", True)) + Admin authentication will be bypassed when ALLOW_PUBLIC_PROJECT_CREATION is + set to True. """ - @wraps(f) - def admin_auth(*args, **kws): - is_admin = session.get('is_admin') - if is_admin or not current_app.config['ADMIN_PASSWORD']: - return f(*args, **kws) - raise Redirect303(url_for('.admin', goto=request.path)) - return admin_auth + def check_admin(f): + @wraps(f) + def admin_auth(*args, **kws): + is_admin_auth_bypassed = False + if bypass is not None and current_app.config.get(bypass[0]) == bypass[1]: + is_admin_auth_bypassed = True + is_admin = session.get('is_admin') + if is_admin or is_admin_auth_bypassed: + return f(*args, **kws) + raise Redirect303(url_for('.admin', goto=request.path)) + return admin_auth + return check_admin @main.url_defaults @@ -60,9 +73,23 @@ def add_project_id(endpoint, values): @main.url_value_preprocessor +def set_show_admin_dashboard_link(endpoint, values): + """Sets the "show_admin_dashboard_link" variable application wide + in order to use it in the layout template. + """ + + g.show_admin_dashboard_link = ( + current_app.config["ACTIVATE_ADMIN_DASHBOARD"] + and current_app.config["ADMIN_PASSWORD"] + ) + + +@main.url_value_preprocessor def pull_project(endpoint, values): """When a request contains a project_id value, transform it directly - into a project by checking the credentials are stored in session. + into a project by checking the credentials stored in the session. + + With administration credentials, one can access any project. If not, redirect the user to an authentication form """ @@ -76,7 +103,9 @@ def pull_project(endpoint, values): if not project: raise Redirect303(url_for(".create_project", project_id=project_id)) - if project.id in session and session[project.id] == project.password: + + is_admin = session.get('is_admin') + if (project.id in session and session[project.id] == project.password) or is_admin: # add project into kwargs and call the original function g.project = project else: @@ -87,15 +116,20 @@ def pull_project(endpoint, values): @main.route("/admin", methods=["GET", "POST"]) def admin(): - """Admin authentication""" + """Admin authentication. + + When ADMIN_PASSWORD is empty, admin authentication is deactivated. + """ form = AdminAuthenticationForm() goto = request.args.get('goto', url_for('.home')) + is_admin_auth_enabled = bool(current_app.config['ADMIN_PASSWORD']) if request.method == "POST": client_ip = request.remote_addr if not login_throttler.is_login_allowed(client_ip): msg = _("Too many failed login attempts, please retry later.") form.errors['admin_password'] = [msg] - return render_template("authenticate.html", form=form, admin_auth=True) + return render_template("admin.html", form=form, admin_auth=True, + is_admin_auth_enabled=is_admin_auth_enabled) if form.validate(): # Valid password if (check_password_hash(current_app.config['ADMIN_PASSWORD'], @@ -109,7 +143,8 @@ def admin(): msg = _("This admin password is not the right one. Only %(num)d attempts left.", num=login_throttler.get_remaining_attempts(client_ip)) form.errors['admin_password'] = [msg] - return render_template("authenticate.html", form=form, admin_auth=True) + return render_template("admin.html", form=form, admin_auth=True, + is_admin_auth_enabled=is_admin_auth_enabled) @main.route("/authenticate", methods=["GET", "POST"]) @@ -167,18 +202,17 @@ def authenticate(project_id=None): def home(): project_form = ProjectForm() auth_form = AuthenticationForm() - # If ADMIN_PASSWORD is empty we consider that admin mode is disabled - is_admin_mode_enabled = bool(current_app.config['ADMIN_PASSWORD']) is_demo_project_activated = current_app.config['ACTIVATE_DEMO_PROJECT'] + is_public_project_creation_allowed = current_app.config['ALLOW_PUBLIC_PROJECT_CREATION'] return render_template("home.html", project_form=project_form, is_demo_project_activated=is_demo_project_activated, - is_admin_mode_enabled=is_admin_mode_enabled, + is_public_project_creation_allowed=is_public_project_creation_allowed, auth_form=auth_form, session=session) @main.route("/create", methods=["GET", "POST"]) -@requires_admin +@requires_admin(bypass=("ALLOW_PUBLIC_PROJECT_CREATION", True)) def create_project(): form = ProjectForm() if request.method == "GET" and 'project_id' in request.values: @@ -295,7 +329,7 @@ def delete_project(): g.project.remove_project() flash(_('Project successfully deleted')) - return redirect(url_for(".home")) + return redirect(request.headers.get('Referer') or url_for('.home')) @main.route("/exit") @@ -530,5 +564,11 @@ def statistics(): @main.route("/dashboard") +@requires_admin() def dashboard(): - return render_template("dashboard.html", projects=Project.query.all()) + is_admin_dashboard_activated = current_app.config['ACTIVATE_ADMIN_DASHBOARD'] + return render_template( + "dashboard.html", + projects=Project.query.all(), + is_admin_dashboard_activated=is_admin_dashboard_activated + ) |
