diff --git a/app/models.py b/app/models.py index 5a49ad4..f0944fd 100644 --- a/app/models.py +++ b/app/models.py @@ -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 '' % (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 diff --git a/app/templates/login.html b/app/templates/login.html index 17a4858..cce6f9f 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -55,6 +55,9 @@ {% endif %} +
+ +
{% if ldap_enabled and basic_enabled %}
diff --git a/app/templates/user_profile.html b/app/templates/user_profile.html index ba8c05e..479b867 100644 --- a/app/templates/user_profile.html +++ b/app/templates/user_profile.html @@ -31,6 +31,8 @@ Avatar
  • Change Password
  • +
  • Authentication +
  • @@ -107,6 +109,20 @@ {% endif %}
    +
    +
    +
    + + + {% if current_user.otp_secret %} +

    + Please start FreeOTP (Android - iOS) on your smartphone and scan the above QR Code with it. +
    + Make sure only you can see this QR Code and nobodoy can capture it. + {% endif %} +
    +
    +
    @@ -117,4 +133,25 @@ {% endblock %} {% block extrascripts %} + {% endblock %} diff --git a/app/views.py b/app/views.py index afcaee7..209db3a 100644 --- a/app/views.py +++ b/app/views.py @@ -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(): diff --git a/requirements.txt b/requirements.txt index 2eb0a79..f9da6fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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