Public project creation and admin permissions (#210)

* Add a @requires_admin decorator

It can be used to protect specific endpoints with ADMIN_PASSWORD
(a password that is stored unencrypted in the settings)
The decorator has no effect if ADMIN_PASSWORD is an empty string (default value)

* Require admin permissions to access create project endpoint

When ADMIN_PASSWORD is not empty, project creation form on the
home page will be replaced by a link to the create project endpoint
so one is able to enter the admin password before filling the form
This commit is contained in:
0livd 2017-05-18 10:48:09 +01:00 committed by Alexis Metaireau
parent 091553be56
commit ea8eda35a7
9 changed files with 93 additions and 2 deletions

View file

@ -10,3 +10,5 @@ SECRET_KEY = "tralala"
MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org") MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org")
ACTIVATE_DEMO_PROJECT = True ACTIVATE_DEMO_PROJECT = True
ADMIN_PASSWORD = ""

View file

@ -83,6 +83,11 @@ class AuthenticationForm(FlaskForm):
submit = SubmitField(_("Get in")) submit = SubmitField(_("Get in"))
class AdminAuthenticationForm(FlaskForm):
admin_password = PasswordField(_("Admin password"), validators=[Required()])
submit = SubmitField(_("Get in"))
class PasswordReminder(FlaskForm): class PasswordReminder(FlaskForm):
id = StringField(_("Project identifier"), validators=[Required()]) id = StringField(_("Project identifier"), validators=[Required()])
submit = SubmitField(_("Send me the code by email")) submit = SubmitField(_("Send me the code by email"))

View file

@ -7,7 +7,13 @@
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

@ -45,6 +45,16 @@
{% endmacro %} {% endmacro %}
{% macro admin(form) %}
{% include "display_errors.html" %}
{{ form.hidden_tag() }}
{{ input(form.admin_password) }}
{{ submit(form.submit) }}
{% endmacro %}
{% macro create_project(form, home=False) %} {% macro create_project(form, home=False) %}
{% include "display_errors.html" %} {% include "display_errors.html" %}

View file

@ -28,6 +28,9 @@
</form> </form>
</div> </div>
<div class="col-3 offset-md-1"> <div class="col-3 offset-md-1">
{% if is_admin_mode_enabled %}
<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>
@ -37,6 +40,7 @@
<button class="btn" type="submit">{{ _("let's get started") }}</button> <button class="btn" type="submit">{{ _("let's get started") }}</button>
</div> </div>
</form> </form>
{% endif %}
</main> </main>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -44,6 +44,8 @@ class TestCase(unittest.TestCase):
# clean after testing # clean after testing
models.db.session.remove() models.db.session.remove()
models.db.drop_all() models.db.drop_all()
# reconfigure app with default settings
run.configure()
def login(self, project, password=None, test_client=None): def login(self, project, password=None, test_client=None):
password = password or project password = password or project
@ -373,6 +375,25 @@ class BudgetTestCase(TestCase):
c.get("/exit") c.get("/exit")
self.assertNotIn('raclette', session) self.assertNotIn('raclette', session)
def test_admin_authentication(self):
run.app.config['ADMIN_PASSWORD'] = "pass"
# test the redirection to the authentication page when trying to access admin endpoints
resp = self.app.get("/create")
self.assertIn('<a href="/admin?goto=%2Fcreate">', resp.data.decode('utf-8'))
# test right password
resp = self.app.post("/admin?goto=%2Fcreate", data={'admin_password': 'pass'})
self.assertIn('<a href="/create">/create</a>', resp.data.decode('utf-8'))
# test wrong password
resp = self.app.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
self.assertNotIn('<a href="/create">/create</a>', resp.data.decode('utf-8'))
# test empty password
resp = self.app.post("/admin?goto=%2Fcreate", data={'admin_password': ''})
self.assertNotIn('<a href="/create">/create</a>', resp.data.decode('utf-8'))
def test_manage_bills(self): def test_manage_bills(self):
self.post_project("raclette") self.post_project("raclette")

View file

@ -41,6 +41,10 @@ msgstr "Email"
msgid "Project identifier" msgid "Project identifier"
msgstr "Identifiant du projet" msgstr "Identifiant du projet"
#: forms.py:87
msgid "Admin password"
msgstr "Mot de passe administrateur"
#: forms.py:87 templates/send_invites.html:5 #: forms.py:87 templates/send_invites.html:5
msgid "Create the project" msgid "Create the project"
msgstr "Créer le projet" msgstr "Créer le projet"
@ -158,6 +162,10 @@ msgstr "remboursements"
msgid "Export file format" msgid "Export file format"
msgstr "Format du fichier d'export" msgstr "Format du fichier d'export"
#: web.py:95
msgid "This admin password is not the right one"
msgstr "Le mot de passe administrateur que vous avez entré n'est pas correct"
#: web.py:95 #: web.py:95
msgid "This private code is not the right one" msgid "This private code is not the right one"
msgstr "Le code que vous avez entré n'est pas correct" msgstr "Le code que vous avez entré n'est pas correct"

View file

@ -16,11 +16,12 @@ from flask_babel import get_locale, gettext as _
from smtplib import SMTPRecipientsRefused from smtplib import SMTPRecipientsRefused
import werkzeug import werkzeug
from sqlalchemy import orm from sqlalchemy import orm
from functools import wraps
# local modules # local modules
from models import db, Project, Person, Bill from models import db, Project, Person, Bill
from forms import AuthenticationForm, EditProjectForm, InviteForm, \ from forms import AdminAuthenticationForm, AuthenticationForm, EditProjectForm, \
MemberForm, PasswordReminder, ProjectForm, get_billform_for, \ InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for, \
ExportForm ExportForm
from utils import Redirect303, list_of_dicts2json, list_of_dicts2csv from utils import Redirect303, list_of_dicts2json, list_of_dicts2csv
@ -28,6 +29,19 @@ main = Blueprint("main", __name__)
mail = Mail() mail = Mail()
def requires_admin(f):
"""Require admin permissions for @requires_admin decorated endpoints.
Has no effect if ADMIN_PASSWORD is empty (default value)
"""
@wraps(f)
def admin_auth(*args, **kws):
admin_password = session.get('admin_password', '')
if not admin_password == current_app.config['ADMIN_PASSWORD']:
raise Redirect303(url_for('.admin', goto=request.path))
return f(*args, **kws)
return admin_auth
@main.url_defaults @main.url_defaults
def add_project_id(endpoint, values): def add_project_id(endpoint, values):
"""Add the project id to the url calls if it is expected. """Add the project id to the url calls if it is expected.
@ -66,6 +80,23 @@ def pull_project(endpoint, values):
url_for(".authenticate", project_id=project_id)) url_for(".authenticate", project_id=project_id))
@main.route("/admin", methods=["GET", "POST"])
def admin():
"""Admin authentication"""
form = AdminAuthenticationForm()
goto = request.args.get('goto', url_for('.home'))
if request.method == "POST":
if form.validate():
if form.admin_password.data == current_app.config['ADMIN_PASSWORD']:
session['admin_password'] = form.admin_password.data
session.update()
return redirect(goto)
else:
msg = _("This admin password is not the right one")
form.errors['admin_password'] = [msg]
return render_template("authenticate.html", form=form, admin_auth=True)
@main.route("/authenticate", methods=["GET", "POST"]) @main.route("/authenticate", methods=["GET", "POST"])
def authenticate(project_id=None): def authenticate(project_id=None):
"""Authentication form""" """Authentication form"""
@ -121,14 +152,18 @@ 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']
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,
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
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: