Add OTP authentication feature

This commit is contained in:
Khanh Ngo 2016-06-16 15:33:05 +07:00
parent af7402096e
commit f4e2c3b3df
5 changed files with 106 additions and 3 deletions

View file

@ -1,10 +1,12 @@
import os
import os
import ldap
import time
import base64
import bcrypt
import urlparse
import itertools
import traceback
import onetimepass
from datetime import datetime
from distutils.version import StrictVersion
@ -51,9 +53,10 @@ class User(db.Model):
lastname = db.Column(db.String(64))
email = db.Column(db.String(128))
avatar = db.Column(db.String(128))
otp_secret = db.Column(db.String(16))
role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
def __init__(self, id=None, username=None, password=None, plain_text_password=None, firstname=None, lastname=None, role_id=None, email=None, avatar=None, reload_info=True):
def __init__(self, id=None, username=None, password=None, plain_text_password=None, firstname=None, lastname=None, role_id=None, email=None, avatar=None, otp_secret=None, reload_info=True):
self.id = id
self.username = username
self.password = password
@ -63,6 +66,7 @@ class User(db.Model):
self.role_id = role_id
self.email = email
self.avatar = avatar
self.otp_secret = otp_secret
if reload_info:
user_info = self.get_user_info_by_id() if id else self.get_user_info_by_username()
@ -74,6 +78,7 @@ class User(db.Model):
self.lastname = user_info.lastname
self.email = user_info.email
self.role_id = user_info.role_id
self.otp_secret = user_info.otp_secret
def is_authenticated(self):
return True
@ -93,6 +98,12 @@ class User(db.Model):
def __repr__(self):
return '<User %r>' % (self.username)
def get_totp_uri(self):
return 'otpauth://totp/PowerDNS-Admin:%s?secret=%s&issuer=PowerDNS-Admin' % (self.username, self.otp_secret)
def verify_totp(self, token):
return onetimepass.valid_totp(token, self.otp_secret)
def get_hashed_password(self, plain_text_password=None):
# Hash a password for the first time
# (Using bcrypt, the salt is saved into the hash itself)
@ -260,7 +271,7 @@ class User(db.Model):
except Exception, e:
raise
def update_profile(self):
def update_profile(self, enable_otp=None):
"""
Update user profile
"""
@ -277,6 +288,16 @@ class User(db.Model):
if self.avatar:
user.avatar = self.avatar
if enable_otp == True:
# generate the opt secret key
user.otp_secret = base64.b32encode(os.urandom(10)).decode('utf-8')
elif enable_otp == False:
# set otp_secret="" means we want disable the otp authenticaion.
user.otp_secret = ""
else:
# do nothing.
pass
try:
db.session.commit()
return True

View file

@ -55,6 +55,9 @@
{% endif %}
<span class="glyphicon glyphicon-lock form-control-feedback"></span>
</div>
<div class="form-group">
<input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken">
</div>
{% if ldap_enabled and basic_enabled %}
<div class="form-group">

View file

@ -31,6 +31,8 @@
Avatar</a></li>
<li><a href="#password_tab" data-toggle="tab">Change
Password</a></li>
<li><a href="#authentication_tab" data-toggle="tab">Authentication
</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="personal_tab">
@ -107,6 +109,20 @@
</form>
{% endif %}
</div>
<div class="tab-pane" id="authentication_tab">
<form action="{{ user_profile }}" method="post">
<div class="form-group">
<input type="checkbox" id="{{ current_user.username }}" class="otp_toggle" {% if current_user.otp_secret %}checked{% endif %}>
<label for="otp_toggle">Enable Two Factor Authentication</label>
{% if current_user.otp_secret %}
<p><img id="qrcode" src="{{ url_for('qrcode') }}"></p>
Please start FreeOTP (<a target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp&hl=en">Android</a> - <a target="_blank" href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>) on your smartphone and scan the above QR Code with it.
<br/>
<font color="red"><strong><i>Make sure only you can see this QR Code and nobodoy can capture it.</i></strong></font>
{% endif %}
</div>
</form>
</div>
</div>
</div>
</div>
@ -117,4 +133,25 @@
{% endblock %}
{% block extrascripts %}
<!-- TODO: add password and password confirmation comparisson check -->
<script>
// initialize pretty checkboxes
$('.otp_toggle').iCheck({
checkboxClass : 'icheckbox_square-blue',
increaseArea : '20%'
});
// handle checkbox toggling
$('.otp_toggle').on('ifToggled', function(event) {
var enable_otp = $(this).prop('checked');
var username = $(this).prop('id');
postdata = {
'action' : 'enable_otp',
'data' : {
'username' : username,
'enable_otp' : enable_otp
}
};
applyChanges(postdata, '/user/profile');
});
</script>
{% endblock %}

View file

@ -2,6 +2,7 @@ import os
import json
import jinja2
import traceback
import pyqrcode
from functools import wraps
from flask.ext.login import login_user, logout_user, current_user, login_required
@ -11,6 +12,8 @@ from werkzeug import secure_filename
from lib import utils
from app import app, login_manager
from .models import User, Role, Domain, DomainUser, Record, Server, History, Anonymous, Setting
from io import BytesIO
from distutils.util import strtobool
jinja2.filters.FILTERS['display_record_name'] = utils.display_record_name
@ -114,6 +117,7 @@ def login():
# process login
username = request.form['username']
password = request.form['password']
otp_token = request.form['otptoken'] if 'otptoken' in request.form else None
auth_method = request.form['auth_method'] if 'auth_method' in request.form else 'LOCAL'
# addition fields for registration case
@ -138,6 +142,15 @@ def login():
error = e.message['desc'] if 'desc' in e.message else e
return render_template('login.html', error=error, ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
# check if user enabled OPT authentication
if user.otp_secret:
if otp_token:
good_token = user.verify_totp(otp_token)
if not good_token:
return render_template('login.html', error='Invalid credentials', ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
else:
return render_template('login.html', error='Token required', ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
login_user(user, remember = remember_me)
return redirect(request.args.get('next') or url_for('index'))
else:
@ -542,6 +555,16 @@ def user_profile():
email = request.form['email'] if 'email' in request.form else ''
new_password = request.form['password'] if 'password' in request.form else ''
# json data
if request.data:
jdata = json.loads(request.data)
data = jdata['data']
if jdata['action'] == 'enable_otp':
enable_otp = data['enable_otp']
user = User(username=current_user.username)
user.update_profile(enable_otp=enable_otp)
return make_response(jsonify( { 'status': 'ok', 'msg': 'Change OTP Authentication successfully. Status: %s' % enable_otp } ), 200)
# get new avatar
save_file_name = None
if 'file' in request.files:
@ -567,6 +590,23 @@ def user_avatar(filename):
return send_from_directory(os.path.join(app.config['UPLOAD_DIR'], 'avatar'), filename)
@app.route('/qrcode')
@login_required
def qrcode():
if not current_user:
return redirect(url_for('index'))
# render qrcode for FreeTOTP
url = pyqrcode.create(current_user.get_totp_uri())
stream = BytesIO()
url.svg(stream, scale=3)
return stream.getvalue(), 200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'}
@app.route('/', methods=['GET', 'POST'])
@login_required
def index():

View file

@ -9,3 +9,5 @@ python-ldap==2.4.21
Flask-SQLAlchemy==2.1
SQLAlchemy==1.0.9
sqlalchemy-migrate==0.10.0
onetimepass==1.0.1
PyQRCode==1.2