diff --git a/hiboo/account/forms.py b/hiboo/account/forms.py index 4c05b54eb139cbac6b0fcbf10e71099d398ebefa..3a8437aec5f9652240842427e1fb7100c653931f 100644 --- a/hiboo/account/forms.py +++ b/hiboo/account/forms.py @@ -12,8 +12,8 @@ class LoginForm(flask_wtf.FlaskForm): class TotpForm(flask_wtf.FlaskForm): - totp = fields.PasswordField(_('Time-based One-Time Password'), [validators.DataRequired()]) - submit = fields.SubmitField(_('Validate')) + totp = fields.PasswordField(_('Enter the one-time password delivered by your client'), [validators.DataRequired()]) + submit = fields.SubmitField(_('Confirm')) class SignupForm(flask_wtf.FlaskForm): diff --git a/hiboo/account/login.py b/hiboo/account/login.py index 08132659b62f9597bb2d387b06e4583ab6263310..7d76bdab197b4d9059242f7d43b3e6f717f224bd 100644 --- a/hiboo/account/login.py +++ b/hiboo/account/login.py @@ -3,22 +3,26 @@ from hiboo.account import blueprint, forms from flask_babel import lazy_gettext as _ from flask import session from authlib.jose import JsonWebToken +from io import BytesIO import datetime import flask_login import flask +import pyotp +import qrcode +import base64 @blueprint.route("/signin/password", methods=["GET", "POST"]) def signin_password(): form = forms.LoginForm() if form.validate_on_submit(): user = models.User.login(form.username.data, form.password.data) - if user and models.Auth.TOTP in user.auths: + if user and models.Auth.TOTP in user.auths and user.auths[models.Auth.TOTP].enabled: session["username"] = user.username return flask.redirect(utils.url_for(".signin_totp")) elif user: flask_login.login_user(user) - if form.remember_me.data == True: + if form.remember_me.data is True: session.permanent = True return flask.redirect(utils.url_or_intent(".home")) else: @@ -73,7 +77,7 @@ def signup(): else: user = models.User() user.username = form.username.data - auth = models.Auth(models.Auth.PASSWORD) + auth = models.Auth(models.Auth.PASSWORD, enabled=True) auth.set_password(form.password.data) user.auths = {models.Auth.PASSWORD: auth} models.db.session.add(user) @@ -113,4 +117,33 @@ def password_reset(): models.db.session.commit() flask.flash(_("Successfully reset your password"), "success") return flask.redirect(flask.url_for(".signin_password")) - return flask.render_template("account_password_reset.html", form=form) + return flask.render_template("account_auth_password_reset.html", form=form) + +@blueprint.route("/auth/totp/reset", methods=["GET", "POST"]) +def totp_reset(): + token = flask.request.args.get('token') or flask.abort(403) + key = flask.current_app.config["SECRET_KEY"] + jwt = JsonWebToken(['HS512']) + claims_options = { + 'exp': {'essential': True, 'value': datetime.datetime.now().timestamp()}, + 'aud': {'essential': True, 'value': flask.url_for('.totp_reset')}, + 'user_uuid': {'essential': True} + } + try: + claims = jwt.decode(token, key, claims_options=claims_options) + claims.validate() + user = models.User.query.get(claims["user_uuid"]) or flask.abort(404) + except Exception as e: + flask.flash(_("Invalid or expired reset link"), "danger") + return flask.redirect(flask.url_for(".signin_password")) + form = forms.LoginForm() + if form.validate_on_submit(): + user = models.User.login(form.username.data, form.password.data) + if user: + flask_login.login_user(user) + if form.remember_me.data is True: + session.permanent = True + return flask.redirect(flask.url_for(".totp_disable")) + else: + flask.flash(_("Wrong credentials"), "danger") + return flask.render_template("account_signin_password.html", token=token, form=form) diff --git a/hiboo/account/settings.py b/hiboo/account/settings.py index ef92a09c5a53efed64e85c2de1e0f80e5a9c42b7..3b6383827a5f46d9ca4056300b5ae3d81d3e71a6 100644 --- a/hiboo/account/settings.py +++ b/hiboo/account/settings.py @@ -33,43 +33,61 @@ def password(): @security.authentication_required() def totp(): user = flask_login.current_user - if models.Auth.TOTP in user.auths: - key = user.auths[models.Auth.TOTP].value - issuer = flask.current_app.config['WEBSITE_NAME'] - totp_uri = pyotp.totp.TOTP(key).provisioning_uri( - name=user.username, - issuer_name=issuer) - img = qrcode.make(totp_uri).get_image() - buffered = BytesIO() - img.save(buffered, format="PNG") - qr = base64.b64encode(buffered.getvalue()).decode('ascii') - return flask.render_template( - "account_auth_totp.html", - key=key, name=user.username, issuer=issuer, qr=qr - ) - return flask.render_template("account_auth_totp.html") + form = forms.TotpForm() + if form.validate_on_submit(): + if user.auths[models.Auth.TOTP].check_totp(form.totp.data): + flask.flash(_("TOTP is valid"), "success") + return flask.redirect(flask.url_for(".totp")) + else: + flask.flash(_("Invalid or expired TOTP"), "danger") + return flask.redirect(flask.url_for(".totp")) + enabled = models.Auth.TOTP in user.auths and user.auths[models.Auth.TOTP].enabled + return flask.render_template("account_auth_totp.html", form=form, enabled=enabled) -@blueprint.route("/auth/totp/setup", methods=["GET", "POST"]) +@blueprint.route("/auth/totp/enable", methods=["GET", "POST"]) @security.authentication_required() -@security.confirmation_required("setup TOTP") -def totp_setup(): +def totp_enable(): user = flask_login.current_user - auth = models.Auth(models.Auth.TOTP) - auth.set_otp_key() - user.auths[models.Auth.TOTP] = auth - models.log(models.History.MFA, comment=str(_("TOTP has been enabled")), - user=flask_login.current_user) - models.db.session.add(auth) - models.db.session.commit() - flask.flash(_("Successfully setup TOTP"), "success") - return flask.redirect(flask.url_for(".totp")) + if models.Auth.TOTP not in user.auths: + auth = models.Auth(models.Auth.TOTP, enabled=False) + auth.set_otp_key() + user.auths[models.Auth.TOTP] = auth + models.db.session.add(auth) + models.db.session.commit() + key = user.auths[models.Auth.TOTP].value + issuer = flask.current_app.config['WEBSITE_NAME'] + totp_uri = pyotp.totp.TOTP(key).provisioning_uri( + name=user.username, + issuer_name=issuer) + img = qrcode.make(totp_uri).get_image() + buffered = BytesIO() + img.save(buffered, format="PNG") + qr = base64.b64encode(buffered.getvalue()).decode('ascii') + form = forms.TotpForm() + if form.validate_on_submit(): + if user.auths[models.Auth.TOTP].check_totp(form.totp.data): + models.log(models.History.MFA, comment=str(_("TOTP has been enabled")), + user=user) + user.auths[models.Auth.TOTP].enabled = True + models.db.session.add(user) + models.db.session.commit() + flask.flash(_("Successfully enabled TOTP"), "success") + return flask.redirect(flask.url_for(".totp")) + else: + flask.flash(_("Failed to enable TOTP, wrong TOTP"), "danger") + return flask.redirect(flask.url_for(".totp_enable")) + flask.flash(_("Scan this QR code or use the informations below it to configure your TOTP client"), "info") + return flask.render_template( + "account_auth_totp_enable.html", + key=key, name=user.username, issuer=issuer, qr=qr, form=form + ) -@blueprint.route("/auth/totp/delete", methods=["GET", "POST"]) +@blueprint.route("/auth/totp/disable", methods=["GET", "POST"]) @security.authentication_required() @security.confirmation_required("disable TOTP") -def totp_delete(): +def totp_disable(): user = flask_login.current_user auth = user.auths[models.Auth.TOTP] models.log(models.History.MFA, comment=str(_("TOTP has been disabled")), diff --git a/hiboo/account/templates/account_auth_totp.html b/hiboo/account/templates/account_auth_totp.html index 19880741acfebe0b8f439e05a5feb7d277764a44..5894c414b9fd584af49722110e8e3c1f44d6288b 100644 --- a/hiboo/account/templates/account_auth_totp.html +++ b/hiboo/account/templates/account_auth_totp.html @@ -1,55 +1,60 @@ {% extends "base.html" %} -{% block title %} {% trans %}Two-factor authentication{% endtrans %} {% endblock %} -{% block subtitle %}{% trans %}with Time-based One-Time Password (TOTP){% endtrans %}{% endblock %} +{% block title %}{% trans %}Two-factor authentication (2FA){% endtrans %}{% endblock %} +{% block subtitle %}{% trans %}with time-based one-time password (TOTP){% endtrans %}{% endblock %} {% block content %} -{% if not key %} -<div class="col"> - <blockquote class="quote-warning"> - <h5>{% trans %}Not configured{% endtrans %}</h5> - <p>{% trans %}Two-factor authentication with Time-based One-Time Passowrd is not setup.{% endtrans %} - <br> - {% trans %}Click on "Setup TOTP" to get started.{% endtrans %} +<div class="col-md-6 col"> + <p>{% trans %}TOTP is an optional secondary layer of the authentication process used to enforce the protection of your account with a one-time password. You can read <a href="https://en.wikipedia.org/wiki/Time-based_one-time_password">this Wikipedia page</a> if you want to learn more about this mechanism.{% endtrans %} + </p> +</div> + +{% if enabled %} + +<div class="col-md-6 col"> + + <blockquote class="quote-success"> + <h5>{% trans %}Two-factor authentication is enabled{% endtrans %}</h5> + <p>{% trans %}Click on <i>Disable TOTP</i> to disable it{% endtrans %}</p> </p> </blockquote> + + <div class="card"> + <div class="card-body"> + <h5>{% trans %}Test your one-time password{% endtrans %}</h5> + <p>{% trans %}Feel free to use this form in order to check your client configuration{% endtrans %}</p> + </div> + <div class="card-footer"> + {{ macros.form(form) }} + </div> + </div> + </div> {% else %} -<blockquote class="quote-info"> - <h5>{% trans %}Howto{% endtrans %}</h5> - <p>{% trans %}Scan this QR code or use text informations{% endtrans %}</p> -</blockquote> -<div class="row"> - <div class="col-md-6 col text-center"> - <img src="data:image/png;base64,{{ qr }}" class="rounded mb-4" width=250 height=250> - </div> - <div class="col-md-6 col"> - <ul class="list-group", style="max-width: 500px"> - <li class="list-group-item d-flex justify-content-between"> - {% trans %}Secret key{% endtrans %}<code>{{ key }}</code> - </li> - <li class="list-group-item d-flex justify-content-between"> - {% trans %}Name{% endtrans %}<code>{{ name }}</code> - </li> - <li class="list-group-item d-flex justify-content-between"> - {% trans %}Issuer{% endtrans %}<code>{{ issuer }}</code> - </li> - </ul> - </div> +<div class="col-md-6 col"> + <blockquote class="quote-info"> + <h5>{% trans %}Two-factor authentication is disabled{% endtrans %}</h5> + <p>{% trans %}Click on <i>Enable TOTP</i> to configure it{% endtrans %}</p> + </blockquote> + + <blockquote class="quote-warning"> + <h5>{% trans %}Attention{% endtrans %}</h5> + <p>{% trans %}You will need a working TOTP client in order to complete this configuration. Several open-source apps can help you for this (and some on mobile are available on <a href="https://search.f-droid.org/?q=totp&lang=fr">F-Droid</a>){% endtrans %}</p> + </blockquote> </div> {% endif %} {% endblock %} {% block actions %} -{% if not key %} +{% if enabled %} -<a href="{{ url_for(".totp_setup") }}" class="btn btn-info">{% trans %}Setup TOTP{% endtrans %}</a> +<a href="{{ url_for(".totp_disable") }}" class="btn btn-warning">{% trans %}Disable TOTP{% endtrans %}</a> {% else %} -<a href="{{ url_for(".totp_delete") }}" class="btn btn-warning">{% trans %}Delete TOTP{% endtrans %}</a> +<a href="{{ url_for(".totp_enable") }}" class="btn btn-info">{% trans %}Enable TOTP{% endtrans %}</a> {% endif %} {% endblock %} diff --git a/hiboo/account/templates/account_auth_totp_enable.html b/hiboo/account/templates/account_auth_totp_enable.html new file mode 100644 index 0000000000000000000000000000000000000000..49995d33f1dce77cce2bc7976c6f85a1db559e73 --- /dev/null +++ b/hiboo/account/templates/account_auth_totp_enable.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Two-factor authentication (2FA){% endtrans %}{% endblock %} +{% block subtitle %}{% trans %}with time-based one-time password (TOTP){% endtrans %}{% endblock %} + +{% block content %} + +<div class="col-md-6 col"> + + <div class="card"> + <img class="card-img-top w-50 mx-auto" src="data:image/png;base64,{{ qr }}" alt="TOTP QR code"> + <ul class="list-group list-group-flush"> + <li class="list-group-item d-flex justify-content-between align-items-center"> + <b>{% trans %}Secret key{% endtrans %}</b> <code>{{ key }}</code> + </li> + <li class="list-group-item d-flex justify-content-between align-items-center"> + <b>{% trans %}Name{% endtrans %}</b> <code>{{ name }}</code> + </li> + <li class="list-group-item d-flex justify-content-between align-items-center"> + <b>{% trans %}Issuer{% endtrans %}</b> <code>{{ issuer }}</code> + </li> + </ul> + <div class="card-footer"> + {{ macros.form(form) }} + </div> + </div> + +</div> + +{% endblock %} + +{% block actions %} +<a href="{{ url_for(".totp_disable") }}" class="btn btn-warning">{% trans %}Cancel{% endtrans %}</a> +{% endblock %} diff --git a/hiboo/account/templates/account_signin_totp.html b/hiboo/account/templates/account_signin_totp.html index 47a1513cac7177882e4139669fe8c9c588a1602c..9c916cb9607ac6e94650d6bf8432fb725e284a11 100644 --- a/hiboo/account/templates/account_signin_totp.html +++ b/hiboo/account/templates/account_signin_totp.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}{% trans %}Time-based One-Time Password (TOTP) verify{% endtrans %}{% endblock %} +{% block title %}{% trans %}Time-based one-time password (TOTP) verify{% endtrans %}{% endblock %} {% block subtitle %}{% trans %}to access your account{% endtrans %}{% endblock %} {% block content %} diff --git a/hiboo/models.py b/hiboo/models.py index 8ca33eeccdfb28daf72ad52f6110c50227eb0482..fd60980fcf687453a883a46dddda71ddc491a233 100644 --- a/hiboo/models.py +++ b/hiboo/models.py @@ -164,10 +164,12 @@ class Auth(db.Model): TOTP: "blue" } - def __init__(self, realm): + def __init__(self, realm, enabled=False): self.realm = realm + self.enabled = enabled realm = db.Column(db.String(25), server_default=PASSWORD) + enabled = db.Column(db.Boolean(), nullable=False, default=1) user_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid)) user = db.relationship(User, backref=db.backref('auths', diff --git a/hiboo/templates/macros.html b/hiboo/templates/macros.html index 117e6b222d9b059e57246295db546371165fa46d..1db0c5a55b44a8e25f0098d888e0ee41e378e8d9 100644 --- a/hiboo/templates/macros.html +++ b/hiboo/templates/macros.html @@ -119,7 +119,7 @@ {% macro form_field(field) %} {% if field.type == 'SubmitField' %} - {{ form_fields((field,), label=False, class="btn btn-default", **kwargs) }} + {{ form_fields((field,), label=False, class="btn btn-info", **kwargs) }} {% elif field.type not in ('HiddenField', 'CSRFTokenField') %} {{ form_fields((field,), **kwargs) }} {% endif %} diff --git a/hiboo/user/templates/user_details.html b/hiboo/user/templates/user_details.html index 5e11aba5fc67135e8da94e288e2e4ae459cdb82a..7ef57b5cc15b5256e6ddacb4d4ae04a218485df6 100644 --- a/hiboo/user/templates/user_details.html +++ b/hiboo/user/templates/user_details.html @@ -18,7 +18,7 @@ <dt class="col-sm-3">{% trans %}Created at{% endtrans %}</dt> <dd class="col-sm-9">{{ user.created_at }}</dd> - <dt class="col-sm-3">{% trans %}Updated at{% endtrans %}</dt> + <dt class="col-sm-3">{% trans %}Updated at{% endtrans %}</dt> <dd class="col-sm-9">{{ user.created_at }}</dd> <dt class="col-sm-3">{% trans %}Auth. methods{% endtrans %}</dt> @@ -31,10 +31,10 @@ {% endfor %} {% endif %} - {% if user.time_to_deletion() %} - <dt class="col-sm-3">{% trans %}Deleted in{% endtrans %}</dt> - <dd class="col-sm-9">{{ utils.babel.dates.format_timedelta(user.time_to_deletion()) }}</dd> - {% endif %} + {% if user.time_to_deletion() %} + <dt class="col-sm-3">{% trans %}Deleted in{% endtrans %}</dt> + <dd class="col-sm-9">{{ utils.babel.dates.format_timedelta(user.time_to_deletion()) }}</dd> + {% endif %} </dl> </div> </div> @@ -76,4 +76,7 @@ {% block actions %} <a href="{{ url_for(".password_reset", user_uuid=user.uuid) }}" class="btn btn-warning">{% trans %}Password reset{% endtrans %}</a> +{% if user.auths["totp"] %} +<a href="{{ url_for(".totp_reset", user_uuid=user.uuid) }}" class="btn btn-warning">{% trans %}TOTP reset{% endtrans %}</a> +{% endif %} {% endblock %} diff --git a/hiboo/user/views.py b/hiboo/user/views.py index cf242338e6891b3be861c365fa6892f0f5b9da45..fb5e08805165483a36e88e7d0d3c20d38ce30344 100644 --- a/hiboo/user/views.py +++ b/hiboo/user/views.py @@ -48,6 +48,25 @@ def password_reset(user_uuid): return flask.redirect(flask.url_for(".details", user_uuid=user.uuid)) +@blueprint.route("/auth/totp/reset/<user_uuid>", methods=["GET", "POST"]) +@security.admin_required() +@security.confirmation_required("generate a totp reset link") +def totp_reset(user_uuid): + user = models.User.query.get(user_uuid) or flask.abort(404) + expired = datetime.datetime.now() + datetime.timedelta(days=1) + payload = { + "exp": int(expired.timestamp()), + "aud": flask.url_for('account.totp_reset'), + "user_uuid": user.uuid + } + header = {"alg": "HS512"} + key = flask.current_app.config["SECRET_KEY"] + token = jwt.encode(header, payload, key) + reset_link = flask.url_for("account.totp_reset", token=token, _external=True) + flask.flash(_("Reset link: {}").format(reset_link), "success") + return flask.redirect(flask.url_for(".details", user_uuid=user.uuid)) + + @blueprint.route("/invite", methods=["GET", "POST"]) @security.admin_required() @security.confirmation_required("generate a signup link") diff --git a/migrations/versions/f9130c1a10f7_add_enableable_auth.py b/migrations/versions/f9130c1a10f7_add_enableable_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..1724277796b2b8831f131cec259afc0ded9da517 --- /dev/null +++ b/migrations/versions/f9130c1a10f7_add_enableable_auth.py @@ -0,0 +1,26 @@ +""" add enableable auth + +Revision ID: f9130c1a10f7 +Revises: 07709c08a6d7 +Create Date: 2023-02-10 14:57:20.853487 +""" + +from alembic import op +import sqlalchemy as sa +import hiboo + + +revision = 'f9130c1a10f7' +down_revision = '07709c08a6d7' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('auth') as batch_op: + batch_op.add_column(sa.Column('enabled', sa.Boolean(), server_default="1", nullable=False)) + + +def downgrade(): + with op.batch_alter_table('auth') as batch_op: + batch_op.drop_column('auth', 'enabled')