add 2fa totp

This commit is contained in:
TuxCoder 2020-05-10 14:34:28 +02:00
parent 06e99be868
commit 90e4e80ede
11 changed files with 233 additions and 14 deletions

View file

@ -19,8 +19,43 @@ function ab2str(buf) {
} }
function randBase32() {
// src: https://en.wikipedia.org/wiki/Base32 RFC4648
const alphabet = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y','Z','2', '3', '4', '5', '6', '7'];
var result = '';
var buf = new Uint8Array(1);
for ( var i = 0; i < 16; i++ ) {
window.crypto.getRandomValues(buf);
var rand_val = buf[0] & 31;
result += alphabet[rand_val];
}
return result;
}
window.totp = { window.totp = {
init: function() { init_list: function(){
},
init_new: function() {
//create new TOTP secret, create qrcode and ask for token.
var form = $('form');
var secret = randBase32();
var input_secret = form.find('#secret')
if(input_secret.val() == '') {
input_secret.val(secret);
}
form.find('#name').on('change',window.totp.generate_qrcode);
window.totp.generate_qrcode();
},
generate_qrcode: function(){
var form = $('form');
var secret = form.find('#secret').val();
var name = form.find('#name').val();
var issuer = 'Lenticular%20Cloud';
var svg_container = $('#svg-container')
var svg = new QRCode(`otpauth://totp/${issuer}:${name}?secret=${secret}&issuer=${issuer}`).svg();
svg_container.html(svg);
} }
} }

View file

@ -89,7 +89,7 @@ def init_oidc_provider(app):
def get_claims_for(self, user_id, requested_claims): def get_claims_for(self, user_id, requested_claims):
user = self[user_id] user = self[user_id]
print(f'user {user.username}') print(f'user {user.username} {requested_claims}')
claims = {} claims = {}
for claim in requested_claims: for claim in requested_claims:
if claim == 'name': if claim == 'name':

View file

@ -61,8 +61,10 @@ class TotpAuthProvider(AuthProvider):
data = form.data['totp'] data = form.data['totp']
if data is not None: if data is not None:
print(f'data totp: {data}') print(f'data totp: {data}')
if len(user.totps) == 0: # migration, TODO remove
return True
for totp in user.totps: for totp in user.totps:
if pyotp.TOTP(totp).verify(data): if totp.verify(data):
return True return True
return False return False

View file

@ -3,11 +3,10 @@ from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextField, \ from wtforms import StringField, SubmitField, TextField, \
TextAreaField, PasswordField, IntegerField, FloatField, \ TextAreaField, PasswordField, IntegerField, FloatField, \
DateTimeField, DateField, FormField, BooleanField, \ DateTimeField, DateField, FormField, BooleanField, \
SelectField, Form as NoCsrfForm SelectField, Form as NoCsrfForm, HiddenField
from wtforms.widgets.html5 import NumberInput, DateInput from wtforms.widgets.html5 import NumberInput, DateInput
from wtforms.validators import DataRequired, NumberRange, Optional, NoneOf, Length from wtforms.validators import DataRequired, NumberRange, \
from datetime import datetime Optional, NoneOf, Length
class ClientCertForm(FlaskForm): class ClientCertForm(FlaskForm):
@ -22,3 +21,14 @@ class ClientCertForm(FlaskForm):
NumberRange(min=1, max=365*2) NumberRange(min=1, max=365*2)
]) ])
submit = SubmitField(gettext('Submit')) submit = SubmitField(gettext('Submit'))
class TOTPForm(FlaskForm):
secret = HiddenField(gettext('totp-Secret'))
token = TextField(gettext('totp-verify token'))
name = TextField(gettext('name'))
submit = SubmitField(gettext('Activate'))
class TOTPDeleteForm(FlaskForm):
submit = SubmitField(gettext('Delete'))

View file

@ -7,6 +7,10 @@ from flask_login import UserMixin
from ldap3.core.exceptions import LDAPSessionTerminatedByServerError from ldap3.core.exceptions import LDAPSessionTerminatedByServerError
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from collections.abc import MutableSequence
from datetime import datetime
import pyotp
import json
ldap_conn = None # type: Connection ldap_conn = None # type: Connection
base_dn = '' base_dn = ''
@ -160,12 +164,79 @@ class Certificate(object):
def __str__(self): def __str__(self):
return f'Certificate(cn={self._cn}, ca_name={self._ca_name}, not_valid_before={self.not_valid_before}, not_valid_after={self.not_valid_after})' return f'Certificate(cn={self._cn}, ca_name={self._ca_name}, not_valid_before={self.not_valid_before}, not_valid_after={self.not_valid_after})'
class Totp(object):
def __init__(self, name, secret, created_at=datetime.now()):
self._secret = secret
self._name = name
self._created_at = created_at
@property
def name(self):
return self._name
@property
def created_at(self):
return self._created_at
def verify(self, token: str):
totp = pyotp.TOTP(self._secret)
return totp.verify(token)
def to_dict(self):
return {
'secret': self._secret,
'name': self._name,
'created_at': int(self._created_at.timestamp())}
@staticmethod
def from_dict(data):
return Totp(
name=data['name'],
secret=data['secret'],
created_at=datetime.fromtimestamp(data['created_at']))
class TotpList(MutableSequence):
def __init__(self, ldap_attr):
super().__init__()
self._ldap_attr = ldap_attr
def __getitem__(self, ii):
return Totp.from_dict(json.loads(self._ldap_attr[ii]))
def __setitem__(self, ii, val: Totp):
self._ldap_attr[ii] = json.dumps(val.to_dict()).encode()
def __len__(self):
return len(self._ldap_attr)
def __delitem__(self, ii):
del self._ldap_attr[ii]
def delete(self, totp_name):
for i in range(len(self)):
if self[i].name == totp_name:
self._ldap_attr.delete(self._ldap_attr[i])
def insert(self, ii, val):
self.append(val)
def append(self, val):
self._ldap_attr.add(json.dumps(val.to_dict()).encode())
class User(EntryBase): class User(EntryBase):
dn = "uid={uid},{base_dn}" dn = "uid={uid},{base_dn}"
base_dn = "ou=users,{_base_dn}" base_dn = "ou=users,{_base_dn}"
object_classes = ["top", "inetOrgPerson", "LenticularUser"] object_classes = ["top", "inetOrgPerson", "LenticularUser"]
def __init__(self, ldap_object=None, **kwargs):
super().__init__(ldap_object, **kwargs)
self._totp_list = TotpList(ldap_object.totpSecret)
@property @property
def is_authenticated(self): def is_authenticated(self):
return True # TODO return True # TODO
@ -173,6 +244,10 @@ class User(EntryBase):
def get(self, key): def get(self, key):
print(f'getitem: {key}') print(f'getitem: {key}')
def make_writeable(self):
self._ldap_object = self._ldap_object.entry_writable()
self._totp_list = TotpList(self._ldap_object.totpSecret)
@property @property
def entry_dn(self): def entry_dn(self):
return self._ldap_object.entry_dn return self._ldap_object.entry_dn
@ -219,7 +294,7 @@ class User(EntryBase):
@property @property
def totps(self): def totps(self):
return ['JBSWY3DPEHPK3PXP'] return self._totp_list
class _query(EntryBase._query): class _query(EntryBase._query):
def by_username(self, username) -> 'User': def by_username(self, username) -> 'User':

View file

@ -19,11 +19,12 @@ from flask_login import login_required, login_user, logout_user, current_user
from werkzeug.utils import redirect from werkzeug.utils import redirect
import logging import logging
from datetime import timedelta from datetime import timedelta
import pyotp
from ..model import User, SecurityUser from ..model import User, SecurityUser, Totp
from ..form.login import LoginForm from ..form.login import LoginForm
from ..form.frontend import ClientCertForm from ..form.frontend import ClientCertForm, TOTPForm, TOTPDeleteForm
from ..auth_providers import AUTH_PROVIDER_LIST from ..auth_providers import AUTH_PROVIDER_LIST
@ -88,6 +89,38 @@ def client_cert_new(service_name):
@frontend_views.route('/totp') @frontend_views.route('/totp')
@login_required @login_required
def totp(): def totp():
return render_template('frontend/totp.html.j2') delete_form = TOTPDeleteForm()
return render_template('frontend/totp.html.j2', delete_form=delete_form)
@frontend_views.route('/totp/new', methods=['GET','POST'])
@login_required
def totp_new():
form = TOTPForm()
if form.validate_on_submit():
totp = Totp(name=form.data['name'], secret=form.data['secret'])
if totp.verify(form.data['token']):
current_user.make_writeable()
current_user.totps.append(totp)
current_user._ldap_object.entry_commit_changes()
return jsonify({
'status': 'ok'})
else:
return jsonify({
'status': 'error',
'errors': [
'TOTP Token invalid'
]})
return render_template('frontend/totp_new.html.j2', form=form)
@frontend_views.route('/totp/<totp_name>/delete', methods=['GET','POST'])
@login_required
def totp_delete(totp_name):
current_user.make_writeable()
current_user.totps.delete(totp_name)
current_user._ldap_object.entry_commit_changes()
return jsonify({
'status': 'ok'})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -43,6 +43,14 @@
<div style="height: 40px"></div> <div style="height: 40px"></div>
<div class="container-fluid"> <div class="container-fluid">
<div class="row">
{% for message in get_flashed_messages() %}
<div class="alert alert-warning">
<button type="button" class="close" data-dismiss="alert">&times;</button>
{{ message }}
</div>
{% endfor %}
</div>
<div class="row"> <div class="row">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<nav class="col-md-2 d-none d-md-block bg-light sidebar fixed-top"> <nav class="col-md-2 d-none d-md-block bg-light sidebar fixed-top">
@ -51,6 +59,7 @@
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.index') }}">{{ gettext('Account') }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.index') }}">{{ gettext('Account') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.logout') }}">{{ gettext('Logout') }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('auth.logout') }}">{{ gettext('Logout') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.totp') }}">{{ gettext('2FA - TOTP') }}</a></li>
</div> </div>
</nav> </nav>
{% endif %} {% endif %}

View file

@ -0,0 +1,35 @@
{% extends 'frontend/base.html.j2' %}
{% block title %}{{ gettext('2FA - TOTP') }}{% endblock %}
{% block content %}
<table class="table">
<thead>
<tr>
<th>name</th>
<th>created_at</th>
<th>action<th>
</tr>
</thead>
<tbody>
{% for totp in current_user.totps %}
<tr>
<td>{{ totp.name }}</td>
<td>{{ totp.created_at }}</td>
<td>{{ render_form(delete_form, action_url=url_for('frontend.totp_delete', totp_name=totp.name)) }}</td>
{% endfor %}
</table>
<a class="btn btn-default" href="{{ url_for('frontend.totp_new') }}">
New TOTP
</a>
{% endblock %}
{% block script_js %}
totp.init_list();
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends 'frontend/base.html.j2' %}
{% block title %}{{ gettext('2FA - TOTP - New') }}{% endblock %}
{% block content %}
{{ render_form(form) }}
<div id="svg-container">
</div>
{% endblock %}
{% block script_js %}
totp.init_new();
{% endblock %}