This commit is contained in:
0livd 2017-07-03 21:54:46 +00:00 committed by GitHub
commit 99ce4bb2e4
14 changed files with 164 additions and 49 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ dist
docs/_build/ docs/_build/
.tox .tox
dist dist
.cache/

View file

@ -6,12 +6,23 @@ This document describes changes between each past release.
2.0 (unreleased) 2.0 (unreleased)
---------------- ----------------
### Breaking Changes
- 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)
- Turn the WSGI file into a python module, renamed from budget/ihatemoney.wsgi to budget/wsgi.py. Please update your Apache configuration!
- Admin privileges are 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
- Add a new setting to allow public project creation (ALLOW_PUBLIC_PROJECT_CREATION)
- With admin credentials, one can access every project
- Add delete and edit project actions in the dashboard
- Add a new setting to activate the dashboard (ACTIVATE_ADMIN_DASHBOARD)
- Add a link to the dashboard in the navigation bar when it is activated
### Removed ### Removed

View file

@ -12,3 +12,7 @@ MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org")
ACTIVATE_DEMO_PROJECT = True ACTIVATE_DEMO_PROJECT = True
ADMIN_PASSWORD = "" ADMIN_PASSWORD = ""
ALLOW_PUBLIC_PROJECT_CREATION = True
ACTIVATE_ADMIN_DASHBOARD = False

View file

@ -169,6 +169,29 @@ 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 not activated.") }}</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,16 @@
<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

@ -70,6 +70,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

@ -376,8 +376,17 @@ class BudgetTestCase(TestCase):
c.get("/exit") c.get("/exit")
self.assertNotIn('raclette', session) self.assertNotIn('raclette', session)
# test that whith admin credentials, one can access every project
run.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass")
with run.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):
run.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") run.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass")
# Disable public project creation so we have an admin endpoint to test
run.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.app.get("/create") resp = self.app.get("/create")
@ -598,8 +607,17 @@ class BudgetTestCase(TestCase):
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.app.get("/dashboard") # test that the dashboard is deactivated by default
self.assertEqual(response.status_code, 200) resp = self.app.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
run.app.config['ACTIVATE_ADMIN_DASHBOARD'] = True
run.app.config['ADMIN_PASSWORD'] = generate_password_hash("adminpass")
resp = self.app.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_settle_page(self): def test_settle_page(self):
self.post_project("raclette") self.post_project("raclette")

View file

@ -247,6 +247,10 @@ msgstr "le créer"
msgid "?" msgid "?"
msgstr " ?" msgstr " ?"
#: templates/authenticate.html:7
msgid "Administration tasks are currently not activated."
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"
@ -271,6 +275,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,26 @@ main = Blueprint("main", __name__)
mail = Mail() mail = Mail()
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) Has no effect if ADMIN_PASSWORD is empty (default value)
The bypass variable is optionnal and 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 check_admin(f):
def admin_auth(*args, **kws): @wraps(f)
is_admin = session.get('is_admin') def admin_auth(*args, **kws):
if is_admin or not current_app.config['ADMIN_PASSWORD']: is_admin_auth_bypassed = False
return f(*args, **kws) if bypass is not None and current_app.config.get(bypass[0]) == bypass[1]:
raise Redirect303(url_for('.admin', goto=request.path)) is_admin_auth_bypassed = True
return admin_auth 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 @main.url_defaults
@ -59,10 +68,21 @@ 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):
"""Set show_admin_dashboard_link application wide
so this variable can be used 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 are stored in session.
With admin credentials, one can access every project.
If not, redirect the user to an authentication form If not, redirect the user to an authentication form
""" """
@ -76,7 +96,8 @@ 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,9 +108,12 @@ 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":
if form.validate(): if form.validate():
if check_password_hash(current_app.config['ADMIN_PASSWORD'], form.admin_password.data): if check_password_hash(current_app.config['ADMIN_PASSWORD'], form.admin_password.data):
@ -99,7 +123,8 @@ def admin():
else: else:
msg = _("This admin password is not the right one") msg = _("This admin password is not the right one")
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,
is_admin_auth_enabled=is_admin_auth_enabled)
@main.route("/authenticate", methods=["GET", "POST"]) @main.route("/authenticate", methods=["GET", "POST"])
@ -157,18 +182,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_public_project_creation_allowed = current_app.config['ALLOW_PUBLIC_PROJECT_CREATION']
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']
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:
@ -284,7 +308,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")
@ -497,5 +521,8 @@ def settle_bill():
@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)

View file

@ -72,23 +72,30 @@ ihatemoney relies on a configuration file. If you run the application for the
first time, you will need to take a few moments to configure the application first time, you will need to take a few moments to configure the application
properly. properly.
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ +------------------------------+---------------------------+----------------------------------------------------------------------------------------+
| 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`. |
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ +------------------------------+---------------------------+----------------------------------------------------------------------------------------+
| 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. When left empty, all administrative |
| ADMIN_PASSWORD | | To generate the proper password HASH, use ``./budget/manage.py 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 ``./budget/manage.py generate_password_hash``|
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ | | | and copy its 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`, a non empty ADMIN_PASSWORD needs to be set |
+------------------------------+---------------------------+----------------------------------------------------------------------------------------+
| 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 |
+------------------------------+---------------------------+----------------------------------------------------------------------------------------+
.. _`the SQLAlechemy documentation`: http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls .. _`the SQLAlechemy documentation`: http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls