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 ldap
import time import time
import base64
import bcrypt import bcrypt
import urlparse import urlparse
import itertools import itertools
import traceback import traceback
import onetimepass
from datetime import datetime from datetime import datetime
from distutils.version import StrictVersion from distutils.version import StrictVersion
@ -51,9 +53,10 @@ class User(db.Model):
lastname = db.Column(db.String(64)) lastname = db.Column(db.String(64))
email = db.Column(db.String(128)) email = db.Column(db.String(128))
avatar = 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')) 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.id = id
self.username = username self.username = username
self.password = password self.password = password
@ -63,6 +66,7 @@ class User(db.Model):
self.role_id = role_id self.role_id = role_id
self.email = email self.email = email
self.avatar = avatar self.avatar = avatar
self.otp_secret = otp_secret
if reload_info: if reload_info:
user_info = self.get_user_info_by_id() if id else self.get_user_info_by_username() 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.lastname = user_info.lastname
self.email = user_info.email self.email = user_info.email
self.role_id = user_info.role_id self.role_id = user_info.role_id
self.otp_secret = user_info.otp_secret
def is_authenticated(self): def is_authenticated(self):
return True return True
@ -93,6 +98,12 @@ class User(db.Model):
def __repr__(self): def __repr__(self):
return '<User %r>' % (self.username) 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): def get_hashed_password(self, plain_text_password=None):
# Hash a password for the first time # Hash a password for the first time
# (Using bcrypt, the salt is saved into the hash itself) # (Using bcrypt, the salt is saved into the hash itself)
@ -260,7 +271,7 @@ class User(db.Model):
except Exception, e: except Exception, e:
raise raise
def update_profile(self): def update_profile(self, enable_otp=None):
""" """
Update user profile Update user profile
""" """
@ -277,6 +288,16 @@ class User(db.Model):
if self.avatar: if self.avatar:
user.avatar = 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: try:
db.session.commit() db.session.commit()
return True return True

View file

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

View file

@ -31,6 +31,8 @@
Avatar</a></li> Avatar</a></li>
<li><a href="#password_tab" data-toggle="tab">Change <li><a href="#password_tab" data-toggle="tab">Change
Password</a></li> Password</a></li>
<li><a href="#authentication_tab" data-toggle="tab">Authentication
</a></li>
</ul> </ul>
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane active" id="personal_tab"> <div class="tab-pane active" id="personal_tab">
@ -107,6 +109,20 @@
</form> </form>
{% endif %} {% endif %}
</div> </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> </div>
</div> </div>
@ -117,4 +133,25 @@
{% endblock %} {% endblock %}
{% block extrascripts %} {% block extrascripts %}
<!-- TODO: add password and password confirmation comparisson check --> <!-- 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 %} {% endblock %}

View file

@ -2,6 +2,7 @@ import os
import json import json
import jinja2 import jinja2
import traceback import traceback
import pyqrcode
from functools import wraps from functools import wraps
from flask.ext.login import login_user, logout_user, current_user, login_required 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 lib import utils
from app import app, login_manager from app import app, login_manager
from .models import User, Role, Domain, DomainUser, Record, Server, History, Anonymous, Setting from .models import User, Role, Domain, DomainUser, Record, Server, History, Anonymous, Setting
from io import BytesIO
from distutils.util import strtobool from distutils.util import strtobool
jinja2.filters.FILTERS['display_record_name'] = utils.display_record_name jinja2.filters.FILTERS['display_record_name'] = utils.display_record_name
@ -114,6 +117,7 @@ def login():
# process login # process login
username = request.form['username'] username = request.form['username']
password = request.form['password'] 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' auth_method = request.form['auth_method'] if 'auth_method' in request.form else 'LOCAL'
# addition fields for registration case # addition fields for registration case
@ -138,6 +142,15 @@ def login():
error = e.message['desc'] if 'desc' in e.message else e 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) 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) login_user(user, remember = remember_me)
return redirect(request.args.get('next') or url_for('index')) return redirect(request.args.get('next') or url_for('index'))
else: else:
@ -542,6 +555,16 @@ def user_profile():
email = request.form['email'] if 'email' in request.form else '' email = request.form['email'] if 'email' in request.form else ''
new_password = request.form['password'] if 'password' 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 # get new avatar
save_file_name = None save_file_name = None
if 'file' in request.files: 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) 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']) @app.route('/', methods=['GET', 'POST'])
@login_required @login_required
def index(): def index():

View file

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