add 2fa totp
This commit is contained in:
parent
06e99be868
commit
90e4e80ede
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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':
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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'))
|
||||||
|
|
|
@ -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':
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -68,7 +69,7 @@ def client_cert_new(service_name):
|
||||||
service,
|
service,
|
||||||
form.data['publickey'],
|
form.data['publickey'],
|
||||||
valid_time=valid_time)
|
valid_time=valid_time)
|
||||||
return jsonify( {
|
return jsonify({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'data': {
|
'data': {
|
||||||
'cert': cert.pem(),
|
'cert': cert.pem(),
|
||||||
|
@ -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
|
@ -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">×</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 %}
|
||||||
|
|
|
@ -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 %}
|
20
templates/frontend/totp_new.html.j2
Normal file
20
templates/frontend/totp_new.html.j2
Normal 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 %}
|
Loading…
Reference in a new issue