diff --git a/uffd/__init__.py b/uffd/__init__.py index de5fbde633d2bd03579d677b59feef0b78b9dd5d..7280d0ba62c68d9a7ad02307f4e4e996045fae07 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -39,10 +39,10 @@ def create_app(test_config=None): db.init_app(app) # pylint: disable=C0415 - from uffd import user, selfservice, role, mail, session, csrf, ldap + from uffd import user, selfservice, role, mail, session, csrf, ldap, mfa # pylint: enable=C0415 - for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp: + for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp + mfa.bp: app.register_blueprint(i) @app.route("/") diff --git a/uffd/mfa/__init__.py b/uffd/mfa/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..656390049779db11f3fbd9a16498218b18982068 --- /dev/null +++ b/uffd/mfa/__init__.py @@ -0,0 +1,3 @@ +from .views import bp as _bp + +bp = [_bp] diff --git a/uffd/mfa/models.py b/uffd/mfa/models.py new file mode 100644 index 0000000000000000000000000000000000000000..d30338b513da2f524d3af099d50fae618ea9de2b --- /dev/null +++ b/uffd/mfa/models.py @@ -0,0 +1,93 @@ +import enum +import datetime +import secrets, time, struct, hmac, hashlib, base64, urllib.parse + +from flask import request, current_app +from sqlalchemy import Column, Integer, Enum, Boolean, String, DateTime + +from uffd.database import db +from uffd.user.models import User + +class MFAType(enum.Enum): + TOTP = 1 + +class MFAMethod(db.Model): + __tablename__ = 'mfa_method' + id = Column(Integer(), primary_key=True, autoincrement=True) + type = Column(Enum(MFAType)) + created = Column(DateTime()) + name = Column(String(128)) + dn = Column(String(128)) + + __mapper_args__ = { + 'polymorphic_on': type, + } + + def __init__(self, user, name=None): + self.user = user + self.name = name + self.created = datetime.datetime.now(); + + @property + def user(self): + return User.from_ldap_dn(self.dn) + + @user.setter + def user(self, u): + self.dn = u.dn + +def _hotp(counter, key, digits=6): + '''Generates HMAC-based one-time password according to RFC4226 + + :param counter: Positive integer smaller than 2**64 + :param key: Bytes object of arbitrary length (should be at least 160 bits) + :param digits: Length of resulting value (integer between 1 and 9, minimum + of 6 is recommended) + + :returns: String object representing human-readable HOTP value''' + msg = struct.pack('>Q', counter) + digest = hmac.new(key, msg=msg, digestmod=hashlib.sha1).digest() + offset = digest[19] & 0x0f + snum = struct.unpack('>L', digest[offset:offset+4])[0] & 0x7fffffff + return str(snum % (10**digits)).zfill(digits) + +class TOTPMethod(MFAMethod): + key = Column('totp_key', String(64)) + + __mapper_args__ = { + 'polymorphic_identity': MFAType.TOTP + } + + def __init__(self, user, name=None, key=None): + super().__init__(user, name) + if key is None: + key = base64.b32encode(secrets.token_bytes(16)).rstrip(b'=').decode() + self.key = key + + @property + def raw_key(self): + s = self.key + '='*(8 - (len(self.key) % 8)) + return base64.b32decode(s.encode()) + + @property + def key_uri(self): + issuer = urllib.parse.quote(urllib.parse.urlsplit(request.url).netloc) + accountname = urllib.parse.quote(self.user.loginname.encode()) + params = {'secret': self.key, 'issuer': issuer} + if 'MFA_ICON_URL' in current_app.config: + params['image'] = current_app.config['MFA_ICON_URL'] + return 'otpauth://totp/%s:%s?%s'%(issuer, accountname, urllib.parse.urlencode(params)) + + def verify(self, code): + '''Verify that code is valid + + Code verification must be rate-limited! + + :param code: String of digits (as entered by the user) + + :returns: True if code is valid, False otherwise''' + counter = int(time.time()/30) + if _hotp(counter-1, self.raw_key) == code or _hotp(counter, self.raw_key) == code: + return True + return False + diff --git a/uffd/mfa/templates/auth.html b/uffd/mfa/templates/auth.html new file mode 100644 index 0000000000000000000000000000000000000000..176bff26243c272d60e065ed3f1690a80d158638 --- /dev/null +++ b/uffd/mfa/templates/auth.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} + +{% block body %} +<form action="{{ url_for("mfa.auth_finish", ref=ref) }}" method="POST"> +<div class="row mt-2 justify-content-center"> + <div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;"> + <div class="text-center"> + <img src="{{ url_for("static", filename="chaosknoten.png") }}" class="col-lg-8 col-md-12" > + </div> + <div class="col-12"> + <h2 class="text-center">Two-Factor Authentication</h2> + </div> + <div class="form-group col-12"> + <label for="mfa-code">Two-factor authentication code</label> + <input type="text" class="form-control" id="mfa-code" name="code" required="required" tabindex="1"> + </div> + <div class="form-group col-12"> + <button type="submit" class="btn btn-primary btn-block" tabindex="2">Verify</button> + </div> + </div> +</div> +</form> +{% endblock %} diff --git a/uffd/mfa/templates/setup.html b/uffd/mfa/templates/setup.html new file mode 100644 index 0000000000000000000000000000000000000000..f7f47241827d83b270cefb205a1c691d648758e9 --- /dev/null +++ b/uffd/mfa/templates/setup.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} + +{% block body %} + +<div class="btn-toolbar"> + <a class="btn btn-primary mb-2 ml-auto" href="{{ url_for('mfa.setup_totp') }}">Setup TOTP</a> +</div> + +{% if methods %} +<table class="table"> + <thead> + <tr> + <th scope="col" colspan=2>Name</th> + <th scope="col">Registered On</th> + <th scope="col"></th> + </tr> + </thead> + <tbody> + {% for method in methods %} + <tr> + <td style="width: 0.5em;"><i class="fas fa-mobile-alt"></i></td> + <td>{{ method.name }}</td> + <td>{{ method.created }}</td> + <td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('mfa.delete_totp', id=method.id) }}">Delete</a></td> + </tr> + {% endfor %} + </tbody> +</table> +{% else %} +<div class="alert alert-info" role="alert"> + You have not setup any two-factor methods yet! +</div> +{% endif %} + +{% endblock %} diff --git a/uffd/mfa/templates/setup_totp.html b/uffd/mfa/templates/setup_totp.html new file mode 100644 index 0000000000000000000000000000000000000000..8272a8b5ce0b295c4c5033d4dfb4b150b29ae3dc --- /dev/null +++ b/uffd/mfa/templates/setup_totp.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} + +{% block body %} + +<div style="max-width: 75vh;" class="mx-auto"> + {{ method.key_uri|qrcode_svg(width='100%', height='100%') }} +</div> + +<form action="{{ url_for('mfa.setup_totp') }}" method="POST" class="form"> +<div class="form-group"> + <label for="code">Code</label> + <input name="code" type="text" class="form-control" required="required"> +</div> +<div class="form-group"> + <label for="name">Authenticator Name</label> + <input name="name" type="text" class="form-control" required="required"> +</div> +<div class="form-group"> + <button type="submit" class="btn btn-primary btn-block">Verify</button> +</div> +</form> + +{% endblock %} diff --git a/uffd/mfa/views.py b/uffd/mfa/views.py new file mode 100644 index 0000000000000000000000000000000000000000..be8febd3423686c49393396a1183a8c7ef808d9a --- /dev/null +++ b/uffd/mfa/views.py @@ -0,0 +1,64 @@ +from flask import Blueprint, render_template, session, request, redirect, url_for, flash + +from uffd.database import db +from uffd.mfa.models import TOTPMethod +from uffd.session.views import get_current_user, login_required + +bp = Blueprint('mfa', __name__, template_folder='templates', url_prefix='/mfa/') + + +@bp.route('/', methods=['GET']) +@login_required() +def setup(): + user = get_current_user() + methods = TOTPMethod.query.filter_by(dn=user.dn).all() + return render_template('setup.html', methods=methods) + +@bp.route('/setup/totp', methods=['GET']) +@login_required() +def setup_totp(): + user = get_current_user() + method = TOTPMethod(user) + session['mfa_totp_key'] = method.key + return render_template('setup_totp.html', method=method) + +@bp.route('/setup/totp', methods=['POST']) +@login_required() +def setup_totp_finish(): + user = get_current_user() + method = TOTPMethod(user, name=request.form['name'], key=session['mfa_totp_key']) + del session['mfa_totp_key'] + if method.verify(request.form['code']): + db.session.add(method) + db.session.commit() + return redirect(url_for('mfa.setup')) + flash('Code is invalid') + return redirect(url_for('mfa.setup_totp')) + +@bp.route('/setup/totp/<int:id>/delete') +@login_required() +def delete_totp(id): + user = get_current_user() + method = TOTPMethod.query.filter_by(dn=user.dn, id=id).first_or_404() + db.session.delete(method) + db.session.commit() + return redirect(url_for('mfa.setup')) + +@bp.route('/auth', methods=['GET']) +@login_required() +def auth(): + user = get_current_user() + methods = TOTPMethod.query.filter_by(dn=user.dn).all() + return render_template('auth.html', ref=request.values.get('ref'), methods=methods) + +@bp.route('/auth', methods=['POST']) +@login_required() +def auth_finish(): + user = get_current_user() + methods = TOTPMethod.query.filter_by(dn=user.dn).all() + for method in methods: + if method.verify(request.form['code']): + session['mfa_verifed'] = True + return redirect(request.values.get('ref', url_for('index'))) + flash('Two-factor authentication failed') + return redirect(url_for('mfa.auth', ref=request.values.get('ref')))