diff --git a/hiboo/account/login.py b/hiboo/account/login.py index d5add1917402e790ebb514527d23809ecc9aef7e..4eb56c44dc33db8324d1744e1daad74d134d19be 100644 --- a/hiboo/account/login.py +++ b/hiboo/account/login.py @@ -3,10 +3,14 @@ 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(): @@ -113,4 +117,53 @@ 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")) + auth = user.auths[models.Auth.TOTP] + form = forms.TotpForm() + if form.validate_on_submit(): + if auth.check_totp(form.totp.data): + del session["totp_reseted"] + flask.flash(_("TOTP is valid"), "success") + return flask.redirect(flask.url_for(".signin_password")) + else: + flask.flash(_("Invalid or expired TOTP, try again or disable and re-enable TOTP to reset your private key"), "danger") + return flask.redirect(flask.url_for(".totp_reset", token=token)) + if "totp_reseted" not in session: + auth.set_otp_key() + models.log(models.History.MFA, comment=str(_("TOTP has been reseted")), + user=user) + models.db.session.add(auth) + models.db.session.commit() + flask.flash(_("Successfully reset TOTP key"), "success") + session["totp_reseted"] = True + key = auth.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_reset.html", + key=key, name=user.username, issuer=issuer, qr=qr, form=form + ) diff --git a/hiboo/account/templates/account_auth_totp_reset.html b/hiboo/account/templates/account_auth_totp_reset.html new file mode 100644 index 0000000000000000000000000000000000000000..c01602286d89c7236b89a4dae3c523a89a253543 --- /dev/null +++ b/hiboo/account/templates/account_auth_totp_reset.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} {% trans %}Reset Two-factor authentication{% endtrans %} {% endblock %} +{% block subtitle %}{% trans %}Reset Time-based One-Time Password (TOTP) key{% endtrans %}{% endblock %} + +{% block content %} + +<blockquote class="quote-info"> + <h5>{% trans %}Howto{% endtrans %}</h5> + <p>{% trans %}Scan this QR code or use text informations to configure a TOTP client{% 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> + {{ macros.form(form) }} +</div> + +{% endblock %} + +{% block actions %} +<a href="{{ url_for(".totp_disable") }}" class="btn btn-warning">{% trans %}Disable TOTP{% endtrans %}</a> +{% endblock %} diff --git a/hiboo/user/templates/user_details.html b/hiboo/user/templates/user_details.html index 6ed369d2f00d46f55a789034354024eb3b92c8d7..892d5e18abe05274e4a7606dd1dbebc3110aa934 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")