diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 94b802c6..1dd87ce4 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 e0f70df3..dcc62312 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 |
-| | | `_. |
-+----------------------------+---------------------------+----------------------------------------------------------------------------------------+
-| 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 |
+| | | `_. |
++-------------------------------+---------------------------+----------------------------------------------------------------------------------------+
+| 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 fcb41db5..c7ce2973 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 54a00081..73802a49 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 00000000..031d27cd
--- /dev/null
+++ b/ihatemoney/templates/admin.html
@@ -0,0 +1,12 @@
+{% extends "layout.html" %}
+{% block content %}
+Authentication
+
+{% if is_admin_auth_enabled %}
+
+{% else %}
+{{ _("Administration tasks are currently disabled.") }}
+{% endif %}
+{% endblock %}
diff --git a/ihatemoney/templates/authenticate.html b/ihatemoney/templates/authenticate.html
index f241c487..98914d09 100644
--- a/ihatemoney/templates/authenticate.html
+++ b/ihatemoney/templates/authenticate.html
@@ -7,13 +7,7 @@
to") }} {{ _("create it") }}{{ _("?") }}
{% endif %}
-{% if admin_auth %}
-
-{% else %}
-{% endif %}
{% endblock %}
diff --git a/ihatemoney/templates/dashboard.html b/ihatemoney/templates/dashboard.html
index 3f50915a..b1220bd4 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 %}
- {{ _("Project") }} | {{ _("Number of members") }} | {{ _("Number of bills") }} | {{_("Newest bill")}} | {{_("Oldest bill")}} |
+ {{ _("Project") }} | {{ _("Number of members") }} | {{ _("Number of bills") }} | {{_("Newest bill")}} | {{_("Oldest bill")}} | {{_("Actions")}} |
{% for project in projects|sort(attribute='name') %}
{{ project.name }} | {{ project.members | count }} | {{ project.get_bills().count() }} |
@@ -13,9 +13,15 @@
|
|
{% endif %}
+
+ {{ _('edit') }}
+ {{ _('delete') }}
+ |
{% endfor %}
+{% else %}
+{{ _("The Dashboard is currently deactivated.") }}
+{% endif %}
{% endblock %}
-
diff --git a/ihatemoney/templates/home.html b/ihatemoney/templates/home.html
index 9bfe4671..a628eccc 100644
--- a/ihatemoney/templates/home.html
+++ b/ihatemoney/templates/home.html
@@ -28,9 +28,7 @@
+ {% else %}
+ ...{{ _("or create a new one") }}
{% endif %}
diff --git a/ihatemoney/templates/layout.html b/ihatemoney/templates/layout.html
index 36f01f89..85109112 100644
--- a/ihatemoney/templates/layout.html
+++ b/ihatemoney/templates/layout.html
@@ -64,6 +64,9 @@
{% endif %}
fr
en
+ {% if g.show_admin_dashboard_link %}
+ {{ _("Dashboard") }}
+ {% endif %}
diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py
index ac3551c0..36ca6fc2 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('', 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('Project | 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
index d6011d51..56b50d39 100644
Binary files a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo and b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo differ
diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po
index e8b9793d..93a80a9d 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 82e15917..753fe42d 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
@@ -59,10 +72,24 @@ def add_project_id(endpoint, values):
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
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
+ )
|
---|