Skip to content
Snippets Groups Projects
Commit 4e568be8 authored by kaiyou's avatar kaiyou
Browse files

Merge branch '14-support-du-2fa-totp' into 'master'

Resolve "Support du 2FA TOTP"

Closes #14

See merge request acides/hiboo/hiboo!31
parents 52cfa00f 68ea35cd
No related branches found
No related tags found
No related merge requests found
......@@ -7,6 +7,13 @@ pre {
border-radius: .25rem;
}
:not(pre) > code {
padding: .25rem;
color: #fff;
background: #212529;
border-radius: .25rem;
}
// dark theme switcher
.theme-switch {
......
......@@ -11,6 +11,11 @@ class LoginForm(flask_wtf.FlaskForm):
submit = fields.SubmitField(_('Sign in'))
class TotpForm(flask_wtf.FlaskForm):
totp = fields.PasswordField(_('Time-based One-Time Password'), [validators.DataRequired()])
submit = fields.SubmitField(_('Validate'))
class SignupForm(flask_wtf.FlaskForm):
username = fields.StringField(_('Username'), [
validators.DataRequired(),
......
......@@ -13,7 +13,10 @@ def signin():
form = forms.LoginForm()
if form.validate_on_submit():
user = models.User.login(form.username.data, form.password.data)
if user:
if user and models.Auth.TOTP in user.auths:
session["username"] = user.username
return flask.redirect(flask.url_for(".totp_verify"))
elif user:
flask_login.login_user(user)
if form.remember_me.data == True:
session.permanent = True
......@@ -24,6 +27,21 @@ def signin():
action=utils.url_for(".signin"))
@blueprint.route("/totp/verify", methods=["GET", "POST"])
def totp_verify():
form = forms.TotpForm()
username = session.get("username") or flask.abort(403)
user = models.User.query.filter_by(username=username).first() or flask.abort(403)
if form.validate_on_submit():
if user.auths[models.Auth.TOTP].check_totp(form.totp.data):
flask_login.login_user(user)
session.pop("username")
return flask.redirect(utils.url_or_intent(".home"))
else:
flask.flash(_("Wrong TOTP"), "danger")
return flask.render_template("account_totp_verify.html", form=form)
@blueprint.route("/signout")
@security.authentication_required()
def signout():
......@@ -55,9 +73,9 @@ def signup():
else:
user = models.User()
user.username = form.username.data
auth = models.Auth()
auth = models.Auth(models.Auth.PASSWORD)
auth.set_password(form.password.data)
user.auths.append(auth)
user.auths = {models.Auth.PASSWORD: auth}
models.db.session.add(user)
models.db.session.add(auth)
models.log(models.History.SIGNUP,
......@@ -80,7 +98,7 @@ def reset(token_uuid):
if form.validate_on_submit():
token.expired_at = datetime.datetime.now()
models.db.session.add(token)
auth = token.user.auths[0]
auth = token.user.auths[models.Auth.PASSWORD]
auth.set_password(form.password.data)
models.log(models.History.PASSWORD, user=token.user)
models.db.session.add(auth)
......
......@@ -2,9 +2,13 @@ from hiboo.account import blueprint, forms
from hiboo import models, security
from wtforms import fields
from flask_babel import lazy_gettext as _
from io import BytesIO
import flask
import flask_login
import pyotp
import qrcode
import base64
@blueprint.route("/password", methods=["GET", "POST"])
......@@ -12,7 +16,7 @@ import flask_login
def password():
form = forms.PasswordForm()
if form.validate_on_submit():
auth = flask_login.current_user.auths[0]
auth = flask_login.current_user.auths[models.Auth.PASSWORD]
if auth.check_password(form.old.data):
auth.set_password(form.password.data)
models.log(models.History.PASSWORD, user=flask_login.current_user)
......@@ -25,6 +29,55 @@ def password():
return flask.render_template("account_password.html", form=form)
@blueprint.route("/totp", methods=["GET", "POST"])
@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_totp.html",
key=key, name=user.username, issuer=issuer, qr=qr
)
return flask.render_template("account_totp.html")
@blueprint.route("/totp/setup", methods=["GET", "POST"])
@security.authentication_required()
@security.confirmation_required("Setup 2FA with TOTP")
def totp_setup():
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, user=flask_login.current_user)
models.db.session.add(auth)
models.db.session.commit()
flask.flash(_("Successfully setup 2FA"), "success")
return flask.redirect(flask.url_for(".totp"))
@blueprint.route("/totp/delete", methods=["GET", "POST"])
@security.authentication_required()
@security.confirmation_required("Delete 2FA with TOTP")
def totp_delete():
user = flask_login.current_user
auth = user.auths[models.Auth.TOTP]
models.log(models.History.MFA, user=flask_login.current_user)
models.db.session.delete(auth)
models.db.session.commit()
flask.flash(_("Successfully deleted 2FA"), "success")
return flask.redirect(flask.url_for(".totp"))
@blueprint.route("/contact", methods=["GET", "POST"])
@security.authentication_required()
def contact():
......
{% extends "base.html" %}
{% block title %} {% trans %}Two-factor authentication{% 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 2FA" to get started.{% endtrans %}
</p>
</blockquote>
</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>
{% endif %}
{% endblock %}
{% block actions %}
{% if not key %}
<a href="{{ url_for(".totp_setup") }}" class="btn btn-info">{% trans %}Setup 2FA{% endtrans %}</a>
{% else %}
<a href="{{ url_for(".totp_delete") }}" class="btn btn-warning">{% trans %}Delete 2FA{% endtrans %}</a>
{% endif %}
{% endblock %}
{% extends "base.html" %}
{% block title %}{% trans %}Time-based One-Time Password (TOTP) verify{% endtrans %}{% endblock %}
{% block subtitle %}{% trans %}to access your account{% endtrans %}{% endblock %}
{% block content %}
{{ macros.form(form) }}
{% endblock %}
{% block actions %}
<a href="{{ utils.url_for(".signup") }}" class="btn btn-success">{% trans %}Sign up{% endtrans %}</a>
{% endblock %}
from passlib import context, hash
from flask import current_app as app
from sqlalchemy.ext import declarative, mutable
from sqlalchemy.orm.collections import attribute_mapped_collection
from flask_babel import lazy_gettext as _
from hiboo import actions
......@@ -10,6 +11,7 @@ import sqlalchemy
import datetime
import json
import uuid
import pyotp
def log(category, value=None, comment=None, user=None, profile=None,
......@@ -96,9 +98,9 @@ class User(db.Model):
if not user:
return False
auths = user.auths
if not auths:
if not auths[Auth.PASSWORD]:
return False
if not auths[0].check_password(password):
if not auths[Auth.PASSWORD].check_password(password):
return False
return user
......@@ -131,11 +133,18 @@ class Auth(db.Model):
"""
__tablename__ = "auth"
PASSWORD = "password"
TOTP = "totp"
def __init__(self, realm):
self.realm = realm
realm = db.Column(db.String(25), server_default=PASSWORD)
user_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid))
user = db.relationship(User,
backref=db.backref('auths', cascade='all, delete-orphan'))
# TODO: support multiple authentication realms, therefore more than
# passwords
backref=db.backref('auths',
collection_class=attribute_mapped_collection('realm'),
cascade='all, delete-orphan'))
value = db.Column(db.String)
extra = db.Column(mutable.MutableDict.as_mutable(JSONEncoded))
......@@ -145,6 +154,14 @@ class Auth(db.Model):
def check_password(self, password):
return hash.pbkdf2_sha256.verify(password, self.value)
def set_otp_key(self):
self.value = pyotp.random_base32()
def check_totp(self, totp):
Totp = pyotp.TOTP(self.value)
return Totp.verify(totp)
class Service(db.Model):
""" A service is a client application (SP or RP typically).
......@@ -308,11 +325,13 @@ class History(db.Model):
STATUS = "status"
TRANSITION = "transition"
PASSWORD = "password"
MFA = "2fa"
DESCRIPTION = {
SIGNUP: _("signed up for this account"),
CREATE: _("created the profile {this.profile.username} on {this.service.name}"),
PASSWORD: _("changed this account password"),
MFA: _("alter this account two-factor authentication settings"),
STATUS: _("set the {this.service.name} profile {this.profile.username} as {this.value}"),
TRANSITION: _("did {this.transition.label} the profile {this.profile.username} on {this.service.name}")
}
......
......@@ -12,7 +12,7 @@
</div>
{% endif %}
<div>
<i class="fas fa-{{ {"signup": "address-card", "create": "plus", "transition": "recycle", "password": "lock"}[event.category] }} bg-blue"></i>
<i class="fas fa-{{ {"signup": "address-card", "create": "plus", "transition": "recycle", "password": "lock", "2fa": "qrcode"}[event.category] }} bg-blue"></i>
<div class="timeline-item">
<span class="time"><i class="fas fa-clock"></i> {{ event.created_at.time().strftime("%H:%M") }}</span>
<h3 class="timeline-header">
......
......@@ -20,6 +20,11 @@
<i class="nav-icon fas fa-lock"></i> <p>{% trans %}Change password{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for("account.totp") }}">
<i class="nav-icon fas fa-qrcode"></i> <p>{% trans %}Two-factor authentication{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for("account.signout") }}">
<i class="nav-icon fas fa-sign-out-alt"></i> <p>{% trans %}Sign out{% endtrans %}</p>
......
......@@ -9,11 +9,11 @@ import click
@click.argument("password")
def create(username, password):
assert not models.User.query.filter_by(username=username).first()
auth = models.Auth()
auth = models.Auth(models.Auth.PASSWORD)
auth.set_password(password)
user = models.User(
username=username,
auths=[auth]
auths={auth.realm: auth}
)
models.db.session.add(auth)
models.db.session.add(user)
......
""" Add realm to Auth
Revision ID: 134571bfe268
Revises: 445033285d55
Create Date: 2021-08-15 17:22:54.349714
"""
from alembic import op
import sqlalchemy as sa
import hiboo
revision = '134571bfe268'
down_revision = '445033285d55'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('auth') as batch_op:
batch_op.add_column(sa.Column('realm', sa.String(length=25), server_default='password', nullable=True))
def downgrade():
with op.batch_alter_table('auth') as batch_op:
batch_op.drop_column('realm')
alembic==1.6.5
argon2-cffi==20.1.0
alembic==1.7.4
argon2-cffi==21.1.0
Authlib==0.15.4
axon @ git+https://forge.tedomum.net/tedomum/axon.git@5980a68da60c8de3fb7cbc8402a0f3db84bc3693
Babel==2.9.1
bcrypt==3.2.0
blinker==1.4
certifi==2021.5.30
cffi==1.14.6
charset-normalizer==2.0.3
certifi==2021.10.8
cffi==1.15.0
charset-normalizer==2.0.7
click==7.1.2
cryptography==3.4.7
decorator==5.0.9
cryptography==35.0.0
decorator==5.1.0
defusedxml==0.7.1
Deprecated==1.2.12
Deprecated==1.2.13
dnspython==2.1.0
elementpath==2.2.3
elementpath==2.3.2
email-validator==1.1.3
Flask==1.1.4
Flask-Babel==2.0.0
Flask-DebugToolbar==0.11.0
Flask-Limiter==1.4
Flask-Login==0.5.0
Flask-Migrate==3.0.1
Flask-Migrate==3.1.0
flask-redis==0.4.0
Flask-Script==2.0.6
Flask-SQLAlchemy==2.5.1
Flask-WTF==0.15.1
greenlet==1.1.0
greenlet==1.1.2
gunicorn==20.1.0
idna==3.2
importlib-resources==5.2.0
idna==3.3
importlib-resources==5.2.2
infinity==1.5
intervals==0.9.2
itsdangerous==1.1.0
Jinja2==2.11.3
jwcrypto==0.9.1
jwcrypto==1.0
limits==1.5.1
lxml==4.6.3
Mako==1.1.4
Mako==1.1.5
MarkupSafe==2.0.1
mysqlclient==2.0.3
passlib==1.7.4
Pillow==8.3.1
Pillow==8.3.2
psycopg2==2.9.1
pycparser==2.20
pyOpenSSL==20.0.1
pyOpenSSL==21.0.0
pyotp==2.6.0
pysaml2==7.0.1
python-dateutil==2.8.2
python-editor==1.0.4
pytz==2021.1
PyYAML==5.4.1
pytz==2021.3
PyYAML==6.0
qrcode==7.3.1
redis==3.5.3
requests==2.26.0
six==1.16.0
SQLAlchemy==1.4.22
SQLAlchemy==1.4.25
terminaltables==3.1.0
urllib3==1.26.6
urllib3==1.26.7
validators==0.18.2
Werkzeug==0.16.0
wrapt==1.12.1
wrapt==1.13.2
WTForms==2.3.3
WTForms-Components==0.10.5
xmlschema==1.6.4
xmlsec==1.3.11
zipp==3.5.0
xmlschema==1.8.0
xmlsec==1.3.12
zipp==3.6.0
......@@ -25,4 +25,6 @@ terminaltables
Werkzeug==0.16.0
Pillow
email_validator
pyotp
qrcode
git+https://forge.tedomum.net/tedomum/axon.git
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment