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.
This commit is contained in:
0livd 2017-09-04 15:44:20 +02:00 committed by Alexis Metaireau
parent ee1ecbf3e7
commit 7a918c9349
13 changed files with 193 additions and 58 deletions

View file

@ -6,16 +6,25 @@ This document describes changes between each past release.
2.0 (unreleased) 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 ### 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 - Changed the recommended gunicorn configuration to use the wsgi module as an entrypoint
### Added ### Added
- Add a statistics tab (#257) - Add a statistics tab (#257)
- Add python3.6 support (#259) - 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 ### Removed

View file

@ -92,27 +92,35 @@ properly.
.. warning:: You **must** customize the ``SECRET_KEY`` on a production installation. .. warning:: You **must** customize the ``SECRET_KEY`` on a production installation.
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ +-------------------------------+---------------------------+----------------------------------------------------------------------------------------+
| Setting name | Default | What does it do? | | 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 | | 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 | | | | on the format used can be found on `the SQLAlchemy documentation |
| | | <http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls>`_. | | | | <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**. | | 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 | | MAIL_DEFAULT_SENDER | ``("Budget manager", | A python tuple describing the name and email adress to use when sending |
| | "budget@notmyidea.org")`` | emails. | | | "budget@notmyidea.org")`` | emails. |
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ +-------------------------------+---------------------------+----------------------------------------------------------------------------------------+
| ACTIVATE_DEMO_PROJECT | ``True`` | If set to `True`, a demo project will be available on the frontpage. | | 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. | | | | Hashed password to access protected endpoints. If left empty, all administrative |
| ADMIN_PASSWORD | | To generate the proper password HASH, use ``ihatemoney generate_password_hash`` | | ADMIN_PASSWORD | ``""`` | tasks are disabled. |
| | | and copy its output into the value of *ADMIN_PASSWORD*. | | | | 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 | | 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*) | | | | to ``"foo"``, it will be served from a "folder" (e.g: *http://domain.tld/foo*) |
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ +-------------------------------+---------------------------+----------------------------------------------------------------------------------------+
In a production environment In a production environment
--------------------------- ---------------------------

View file

@ -29,3 +29,9 @@ ACTIVATE_DEMO_PROJECT = True
# DO NOT enter the password in cleartext. Generate a password hash with # DO NOT enter the password in cleartext. Generate a password hash with
# "ihatemoney generate_password_hash" instead. # "ihatemoney generate_password_hash" instead.
ADMIN_PASSWORD = "" 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

View file

@ -169,6 +169,30 @@ footer{
background: url('../images/edit.png') no-repeat right; 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{ .balance .balance-value{
text-align:right; text-align:right;
} }

View file

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

View file

@ -7,13 +7,7 @@
to") }} <a href="{{ url_for(".create_project", project_id=create_project) }}">{{ _("create it") }}</a>{{ _("?") }} to") }} <a href="{{ url_for(".create_project", project_id=create_project) }}">{{ _("create it") }}</a>{{ _("?") }}
</p> </p>
{% endif %} {% 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"> <form class="form-horizontal" method="POST" accept-charset="utf-8">
{{ forms.authenticate(form) }} {{ forms.authenticate(form) }}
</form> </form>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,8 +1,8 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
{% if is_admin_dashboard_activated %}
<table id="bill_table" class="table table-striped"> <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') %} <tbody>{% for project in projects|sort(attribute='name') %}
<tr class="{{ loop.cycle("odd", "even") }}"> <tr class="{{ loop.cycle("odd", "even") }}">
<td>{{ project.name }}</td><td>{{ project.members | count }}</td><td>{{ project.get_bills().count() }}</td> <td>{{ project.name }}</td><td>{{ project.members | count }}</td><td>{{ project.get_bills().count() }}</td>
@ -13,9 +13,15 @@
<td></td> <td></td>
<td></td> <td></td>
{% endif %} {% 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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<div class="alert alert-danger">{{ _("The Dashboard is currently deactivated.") }}</div>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -28,9 +28,7 @@
</form> </form>
</div> </div>
<div class="col-xs-12 col-sm-5 col-md-3 offset-sm-1"> <div class="col-xs-12 col-sm-5 col-md-3 offset-sm-1">
{% if is_admin_mode_enabled %} {% if is_public_project_creation_allowed %}
<a href="{{ url_for(".create_project") }}">...{{ _("or create a new one") }}</a>
{% else %}
<form id="creation-form" class="form-horizontal" action="{{ url_for(".create_project") }}" method="post"> <form id="creation-form" class="form-horizontal" action="{{ url_for(".create_project") }}" method="post">
<fieldset class="form-group"> <fieldset class="form-group">
<legend>...{{ _("or create a new one") }}</legend> <legend>...{{ _("or create a new one") }}</legend>
@ -40,6 +38,8 @@
<button class="btn" type="submit">{{ _("let's get started") }}</button> <button class="btn" type="submit">{{ _("let's get started") }}</button>
</div> </div>
</form> </form>
{% else %}
<a href="{{ url_for(".create_project") }}">...{{ _("or create a new one") }}</a>
{% endif %} {% endif %}
</main> </main>
</div> </div>

View file

@ -64,6 +64,9 @@
{% endif %} {% 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 == "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> <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> </ul>
</div> </div>
</nav> </nav>

View file

@ -379,8 +379,17 @@ class BudgetTestCase(IhatemoneyTestCase):
c.get("/exit") c.get("/exit")
self.assertNotIn('raclette', session) 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): def test_admin_authentication(self):
self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") 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 # test the redirection to the authentication page when trying to access admin endpoints
resp = self.client.get("/create") resp = self.client.get("/create")
@ -401,7 +410,8 @@ class BudgetTestCase(IhatemoneyTestCase):
def test_login_throttler(self): def test_login_throttler(self):
self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") 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'})
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'}) 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')) self.assertIn("Invalid email address", resp.data.decode('utf-8'))
def test_dashboard(self): def test_dashboard(self):
response = self.client.get("/dashboard") # test that the dashboard is deactivated by default
self.assertEqual(response.status_code, 200) 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): def test_statistics_page(self):
self.post_project("raclette") self.post_project("raclette")

View file

@ -251,6 +251,10 @@ msgstr "le créer"
msgid "?" msgid "?"
msgstr " ?" 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 #: templates/create_project.html:4
msgid "Create a new project" msgid "Create a new project"
msgstr "Créer un nouveau projet" msgstr "Créer un nouveau projet"
@ -275,6 +279,10 @@ msgstr "Facture la plus récente"
msgid "Oldest bill" msgid "Oldest bill"
msgstr "Facture la plus ancienne" 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 #: templates/edit_project.html:6 templates/list_bills.html:24
msgid "you sure?" msgid "you sure?"
msgstr "c'est sûr ?" msgstr "c'est sûr ?"

View file

@ -34,17 +34,30 @@ main = Blueprint("main", __name__)
login_throttler = LoginThrottler(max_attempts=3, delay=1) 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. """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.
""" """
def check_admin(f):
@wraps(f) @wraps(f)
def admin_auth(*args, **kws): 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') is_admin = session.get('is_admin')
if is_admin or not current_app.config['ADMIN_PASSWORD']: if is_admin or is_admin_auth_bypassed:
return f(*args, **kws) return f(*args, **kws)
raise Redirect303(url_for('.admin', goto=request.path)) raise Redirect303(url_for('.admin', goto=request.path))
return admin_auth return admin_auth
return check_admin
@main.url_defaults @main.url_defaults
@ -59,10 +72,24 @@ def add_project_id(endpoint, values):
values['project_id'] = g.project.id values['project_id'] = g.project.id
@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 @main.url_value_preprocessor
def pull_project(endpoint, values): def pull_project(endpoint, values):
"""When a request contains a project_id value, transform it directly """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 If not, redirect the user to an authentication form
""" """
@ -76,7 +103,9 @@ def pull_project(endpoint, values):
if not project: if not project:
raise Redirect303(url_for(".create_project", raise Redirect303(url_for(".create_project",
project_id=project_id)) 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 # add project into kwargs and call the original function
g.project = project g.project = project
else: else:
@ -87,15 +116,20 @@ def pull_project(endpoint, values):
@main.route("/admin", methods=["GET", "POST"]) @main.route("/admin", methods=["GET", "POST"])
def admin(): def admin():
"""Admin authentication""" """Admin authentication.
When ADMIN_PASSWORD is empty, admin authentication is deactivated.
"""
form = AdminAuthenticationForm() form = AdminAuthenticationForm()
goto = request.args.get('goto', url_for('.home')) goto = request.args.get('goto', url_for('.home'))
is_admin_auth_enabled = bool(current_app.config['ADMIN_PASSWORD'])
if request.method == "POST": if request.method == "POST":
client_ip = request.remote_addr client_ip = request.remote_addr
if not login_throttler.is_login_allowed(client_ip): if not login_throttler.is_login_allowed(client_ip):
msg = _("Too many failed login attempts, please retry later.") msg = _("Too many failed login attempts, please retry later.")
form.errors['admin_password'] = [msg] 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(): if form.validate():
# Valid password # Valid password
if (check_password_hash(current_app.config['ADMIN_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.", msg = _("This admin password is not the right one. Only %(num)d attempts left.",
num=login_throttler.get_remaining_attempts(client_ip)) num=login_throttler.get_remaining_attempts(client_ip))
form.errors['admin_password'] = [msg] 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"]) @main.route("/authenticate", methods=["GET", "POST"])
@ -167,18 +202,17 @@ def authenticate(project_id=None):
def home(): def home():
project_form = ProjectForm() project_form = ProjectForm()
auth_form = AuthenticationForm() 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_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, return render_template("home.html", project_form=project_form,
is_demo_project_activated=is_demo_project_activated, 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) auth_form=auth_form, session=session)
@main.route("/create", methods=["GET", "POST"]) @main.route("/create", methods=["GET", "POST"])
@requires_admin @requires_admin(bypass=("ALLOW_PUBLIC_PROJECT_CREATION", True))
def create_project(): def create_project():
form = ProjectForm() form = ProjectForm()
if request.method == "GET" and 'project_id' in request.values: if request.method == "GET" and 'project_id' in request.values:
@ -295,7 +329,7 @@ def delete_project():
g.project.remove_project() g.project.remove_project()
flash(_('Project successfully deleted')) flash(_('Project successfully deleted'))
return redirect(url_for(".home")) return redirect(request.headers.get('Referer') or url_for('.home'))
@main.route("/exit") @main.route("/exit")
@ -530,5 +564,11 @@ def statistics():
@main.route("/dashboard") @main.route("/dashboard")
@requires_admin()
def dashboard(): 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
)