Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • acides/hiboo
  • frju365/hiboo
  • pascoual/hiboo
  • thedarky/hiboo
  • jeremy/hiboo
  • cyrinux/hiboo
  • a.f/hiboo
  • mickge/hiboo
  • llaq/hiboo
  • vaguelysalaried/hiboo
  • felinn-glotte/hiboo
  • AntoninDelFabbro/hiboo
  • docemmetbrown/hiboo
13 results
Show changes
Commits on Source (22)
...@@ -12,8 +12,8 @@ class LoginForm(flask_wtf.FlaskForm): ...@@ -12,8 +12,8 @@ class LoginForm(flask_wtf.FlaskForm):
class TotpForm(flask_wtf.FlaskForm): class TotpForm(flask_wtf.FlaskForm):
totp = fields.PasswordField(_('Time-based One-Time Password'), [validators.DataRequired()]) totp = fields.PasswordField(_('Enter the one-time password delivered by your client'), [validators.DataRequired()])
submit = fields.SubmitField(_('Validate')) submit = fields.SubmitField(_('Confirm'))
class SignupForm(flask_wtf.FlaskForm): class SignupForm(flask_wtf.FlaskForm):
......
...@@ -3,22 +3,26 @@ from hiboo.account import blueprint, forms ...@@ -3,22 +3,26 @@ from hiboo.account import blueprint, forms
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from flask import session from flask import session
from authlib.jose import JsonWebToken from authlib.jose import JsonWebToken
from io import BytesIO
import datetime import datetime
import flask_login import flask_login
import flask import flask
import pyotp
import qrcode
import base64
@blueprint.route("/signin/password", methods=["GET", "POST"]) @blueprint.route("/signin/password", methods=["GET", "POST"])
def signin_password(): def signin_password():
form = forms.LoginForm() form = forms.LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = models.User.login(form.username.data, form.password.data) 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 session["username"] = user.username
return flask.redirect(utils.url_for(".signin_totp")) return flask.redirect(utils.url_for(".signin_totp"))
elif user: elif user:
flask_login.login_user(user) flask_login.login_user(user)
if form.remember_me.data == True: if form.remember_me.data is True:
session.permanent = True session.permanent = True
return flask.redirect(utils.url_or_intent(".home")) return flask.redirect(utils.url_or_intent(".home"))
else: else:
...@@ -73,7 +77,7 @@ def signup(): ...@@ -73,7 +77,7 @@ def signup():
else: else:
user = models.User() user = models.User()
user.username = form.username.data 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) auth.set_password(form.password.data)
user.auths = {models.Auth.PASSWORD: auth} user.auths = {models.Auth.PASSWORD: auth}
models.db.session.add(user) models.db.session.add(user)
...@@ -113,4 +117,33 @@ def password_reset(): ...@@ -113,4 +117,33 @@ def password_reset():
models.db.session.commit() models.db.session.commit()
flask.flash(_("Successfully reset your password"), "success") flask.flash(_("Successfully reset your password"), "success")
return flask.redirect(flask.url_for(".signin_password")) 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)
...@@ -33,43 +33,61 @@ def password(): ...@@ -33,43 +33,61 @@ def password():
@security.authentication_required() @security.authentication_required()
def totp(): def totp():
user = flask_login.current_user user = flask_login.current_user
if models.Auth.TOTP in user.auths: form = forms.TotpForm()
key = user.auths[models.Auth.TOTP].value if form.validate_on_submit():
issuer = flask.current_app.config['WEBSITE_NAME'] if user.auths[models.Auth.TOTP].check_totp(form.totp.data):
totp_uri = pyotp.totp.TOTP(key).provisioning_uri( flask.flash(_("TOTP is valid"), "success")
name=user.username, return flask.redirect(flask.url_for(".totp"))
issuer_name=issuer) else:
img = qrcode.make(totp_uri).get_image() flask.flash(_("Invalid or expired TOTP"), "danger")
buffered = BytesIO() return flask.redirect(flask.url_for(".totp"))
img.save(buffered, format="PNG") enabled = models.Auth.TOTP in user.auths and user.auths[models.Auth.TOTP].enabled
qr = base64.b64encode(buffered.getvalue()).decode('ascii') return flask.render_template("account_auth_totp.html", form=form, enabled=enabled)
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")
@blueprint.route("/auth/totp/setup", methods=["GET", "POST"]) @blueprint.route("/auth/totp/enable", methods=["GET", "POST"])
@security.authentication_required() @security.authentication_required()
@security.confirmation_required("setup TOTP") def totp_enable():
def totp_setup():
user = flask_login.current_user user = flask_login.current_user
auth = models.Auth(models.Auth.TOTP) if models.Auth.TOTP not in user.auths:
auth.set_otp_key() auth = models.Auth(models.Auth.TOTP, enabled=False)
user.auths[models.Auth.TOTP] = auth auth.set_otp_key()
models.log(models.History.MFA, comment=str(_("TOTP has been enabled")), user.auths[models.Auth.TOTP] = auth
user=flask_login.current_user) models.db.session.add(auth)
models.db.session.add(auth) models.db.session.commit()
models.db.session.commit() key = user.auths[models.Auth.TOTP].value
flask.flash(_("Successfully setup TOTP"), "success") issuer = flask.current_app.config['WEBSITE_NAME']
return flask.redirect(flask.url_for(".totp")) 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.authentication_required()
@security.confirmation_required("disable TOTP") @security.confirmation_required("disable TOTP")
def totp_delete(): def totp_disable():
user = flask_login.current_user user = flask_login.current_user
auth = user.auths[models.Auth.TOTP] auth = user.auths[models.Auth.TOTP]
models.log(models.History.MFA, comment=str(_("TOTP has been disabled")), models.log(models.History.MFA, comment=str(_("TOTP has been disabled")),
......
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% trans %}Two-factor authentication{% endtrans %} {% endblock %} {% block title %}{% trans %}Two-factor authentication (2FA){% endtrans %}{% endblock %}
{% block subtitle %}{% trans %}with Time-based One-Time Password (TOTP){% endtrans %}{% endblock %} {% block subtitle %}{% trans %}with time-based one-time password (TOTP){% endtrans %}{% endblock %}
{% block content %} {% block content %}
{% if not key %}
<div class="col"> <div class="col-md-6 col">
<blockquote class="quote-warning"> <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 %}
<h5>{% trans %}Not configured{% endtrans %}</h5> </p>
<p>{% trans %}Two-factor authentication with Time-based One-Time Passowrd is not setup.{% endtrans %} </div>
<br>
{% trans %}Click on "Setup TOTP" to get started.{% endtrans %} {% 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> </p>
</blockquote> </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> </div>
{% else %} {% else %}
<blockquote class="quote-info"> <div class="col-md-6 col">
<h5>{% trans %}Howto{% endtrans %}</h5> <blockquote class="quote-info">
<p>{% trans %}Scan this QR code or use text informations{% endtrans %}</p> <h5>{% trans %}Two-factor authentication is disabled{% endtrans %}</h5>
</blockquote> <p>{% trans %}Click on <i>Enable TOTP</i> to configure it{% endtrans %}</p>
<div class="row"> </blockquote>
<div class="col-md-6 col text-center">
<img src="data:image/png;base64,{{ qr }}" class="rounded mb-4" width=250 height=250> <blockquote class="quote-warning">
</div> <h5>{% trans %}Attention{% endtrans %}</h5>
<div class="col-md-6 col"> <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>
<ul class="list-group", style="max-width: 500px"> </blockquote>
<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> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block actions %} {% 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 %} {% 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 %} {% endif %}
{% endblock %} {% endblock %}
{% 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 %}
{% extends "base.html" %} {% 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 subtitle %}{% trans %}to access your account{% endtrans %}{% endblock %}
{% block content %} {% block content %}
......
...@@ -164,10 +164,12 @@ class Auth(db.Model): ...@@ -164,10 +164,12 @@ class Auth(db.Model):
TOTP: "blue" TOTP: "blue"
} }
def __init__(self, realm): def __init__(self, realm, enabled=False):
self.realm = realm self.realm = realm
self.enabled = enabled
realm = db.Column(db.String(25), server_default=PASSWORD) 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_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid))
user = db.relationship(User, user = db.relationship(User,
backref=db.backref('auths', backref=db.backref('auths',
......
...@@ -82,7 +82,9 @@ ...@@ -82,7 +82,9 @@
<section class="content"> <section class="content">
<div class="container-fluid"> <div class="container-fluid">
{% for category, message in get_flashed_messages(with_categories=True) or [] %} {% for category, message in get_flashed_messages(with_categories=True) or [] %}
<div class="alert alert-{{ category or "info" }}">{{ message }}</div> <div class="col-md-6 col">
<div class="alert alert-{{ category or "info" }}">{{ message }}</div>
</div>
{% endfor %} {% endfor %}
{% if current_user.time_to_deletion and current_user.time_to_deletion() %} {% if current_user.time_to_deletion and current_user.time_to_deletion() %}
<div class="alert alert-warning">{% trans %}Your account has no active profile, it will be deleted in{% endtrans %} {{ utils.babel.dates.format_timedelta(current_user.time_to_deletion()) }}</div> <div class="alert alert-warning">{% trans %}Your account has no active profile, it will be deleted in{% endtrans %} {{ utils.babel.dates.format_timedelta(current_user.time_to_deletion()) }}</div>
......
...@@ -119,7 +119,7 @@ ...@@ -119,7 +119,7 @@
{% macro form_field(field) %} {% macro form_field(field) %}
{% if field.type == 'SubmitField' %} {% 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') %} {% elif field.type not in ('HiddenField', 'CSRFTokenField') %}
{{ form_fields((field,), **kwargs) }} {{ form_fields((field,), **kwargs) }}
{% endif %} {% endif %}
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
<dt class="col-sm-3">{% trans %}Created at{% endtrans %}</dt> <dt class="col-sm-3">{% trans %}Created at{% endtrans %}</dt>
<dd class="col-sm-9">{{ user.created_at }}</dd> <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> <dd class="col-sm-9">{{ user.created_at }}</dd>
<dt class="col-sm-3">{% trans %}Auth. methods{% endtrans %}</dt> <dt class="col-sm-3">{% trans %}Auth. methods{% endtrans %}</dt>
...@@ -31,10 +31,10 @@ ...@@ -31,10 +31,10 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if user.time_to_deletion() %} {% if user.time_to_deletion() %}
<dt class="col-sm-3">{% trans %}Deleted in{% endtrans %}</dt> <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> <dd class="col-sm-9">{{ utils.babel.dates.format_timedelta(user.time_to_deletion()) }}</dd>
{% endif %} {% endif %}
</dl> </dl>
</div> </div>
</div> </div>
...@@ -76,4 +76,7 @@ ...@@ -76,4 +76,7 @@
{% block actions %} {% block actions %}
<a href="{{ url_for(".password_reset", user_uuid=user.uuid) }}" class="btn btn-warning">{% trans %}Password reset{% endtrans %}</a> <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 %} {% endblock %}
...@@ -48,6 +48,25 @@ def password_reset(user_uuid): ...@@ -48,6 +48,25 @@ def password_reset(user_uuid):
return flask.redirect(flask.url_for(".details", user_uuid=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"]) @blueprint.route("/invite", methods=["GET", "POST"])
@security.admin_required() @security.admin_required()
@security.confirmation_required("generate a signup link") @security.confirmation_required("generate a signup link")
......
""" 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')