Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Upgrades will be unaffected by this, but please note that reinstalls will requir
([Ubuntu Instructions](https://github.com/PyMySQL/mysqlclient-python#install) or change the start of your database URI
with "mysql+pymysql://" instead of "mysql://" [GH-170].

Features:

* Supports Two-Factor Authentication on the backend [GH-169]


### v1.1.0 - A Hard Day's Night

BACKWARDS INCOMPATIBILITIES / NOTES:
Expand Down
24 changes: 24 additions & 0 deletions migrations/versions/e3a72926f808_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Adds table for 2 factor secret and checking if active

Revision ID: e3a72926f808
Revises: c3803ac9b7dd
Create Date: 2016-08-10 19:12:49.647496

"""

# revision identifiers, used by Alembic.
revision = 'e3a72926f808'
down_revision = 'c3803ac9b7dd'

from alembic import op
import sqlalchemy as sa


def upgrade():
op.add_column('postmaster_admins', sa.Column('otp_active', sa.Boolean(), nullable=True))
op.add_column('postmaster_admins', sa.Column('otp_secret', sa.String(length=16), nullable=True))


def downgrade():
op.drop_column('postmaster_admins', 'otp_secret')
op.drop_column('postmaster_admins', 'otp_active')
116 changes: 115 additions & 1 deletion postmaster/apiv1/admins.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@

from flask import request
from flask_login import login_required, current_user
import pyqrcode
from StringIO import StringIO
from postmaster import db
from postmaster.models import Admins, Configs
from postmaster.models import Admins
from postmaster.utils import json_logger, clear_lockout_fields_on_user
from postmaster.decorators import json_wrap, paginate
from postmaster.errors import ValidationError, GenericError
Expand Down Expand Up @@ -147,3 +149,115 @@ def unlock_admin(admin_id):
admin = Admins.query.get_or_404(admin_id)
clear_lockout_fields_on_user(admin.username)
return {}, 200


@apiv1.route('/admins/<int:admin_id>/twofactor', methods=['GET'])
@login_required
@json_wrap
def twofactor_status(admin_id):
""" Returns if 2 factor is enabled or not

This information is in the main user route,
but I added it here as a stub for the URI.
"""
admin = Admins.query.get_or_404(admin_id)
return dict(enabled=admin.otp_active)


@apiv1.route('/admins/<int:admin_id>/twofactor', methods=['PUT'])
@login_required
@json_wrap
def twofactor_disable(admin_id):
""" Disable 2 factor using API.

Enabling 2 factor from this route is not possible.
"""
admin = Admins.query.get_or_404(admin_id)
status = request.get_json(force=True).get('enabled')
if status:
if status.lower() == "false":
admin.otp_active = False
try:
db.session.add(admin)
db.session.commit()
except ValidationError as e:
raise e
except Exception as e:
db.session.rollback()
json_logger(
'error', current_user.username,
'The following error occurred in twofactor_disable: {0}'.format(str(e)))
raise GenericError('The administrator could not be updated')
return {}, 200
elif status.lower() == "true":
raise GenericError("Cannot enable 2 factor from this route - see docs")
raise GenericError("An invalid parameter was supplied")


@apiv1.route('/admins/<int:admin_id>/twofactor/qrcode', methods=['GET'])
@login_required
def qrcode(admin_id):
""" Presents the user with a QR code to scan to setup 2 factor authentication
"""
# render qrcode for FreeTOTP
admin = Admins.query.get_or_404(admin_id)
if admin.id != current_user.id:
raise GenericError('You may not view other admin\'s QR codes')
if admin.otp_active:
return ('', 204)
admin.generate_otp_secret()
try:
db.session.add(admin)
db.session.commit()
except ValidationError as e:
raise e
except Exception as e:
db.session.rollback()
json_logger(
'error', current_user.username,
'The following error occurred in qrcode: {0}'.format(str(e)))
raise GenericError('The administrator could not be updated')
url = pyqrcode.create(admin.get_totp_uri())
stream = StringIO()
url.svg(stream, scale=5)
return stream.getvalue().encode('utf-8'), 200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'}


@apiv1.route('/admins/<int:admin_id>/twofactor/verify', methods=['POST'])
@login_required
@json_wrap
def verify_qrcode(admin_id):
""" Verifies if the 2 factor token provided is correct

This will enable 2 factor for a user.
"""
admin = Admins.query.get_or_404(admin_id)
if request.get_json(force=True).get('code'):
if not admin.otp_secret:
raise GenericError("The 2 Factor Secret has not been generated yet")
if admin.verify_totp(request.get_json(force=True).get('code')):
if not admin.otp_active:
audit_message = 'The administrator "{0}" enabled 2 factor'.format(
admin.username)
admin.otp_active = True
try:
db.session.add(admin)
db.session.commit()
json_logger('audit', current_user.username, audit_message)
except ValidationError as e:
raise e
except Exception as e:
db.session.rollback()
json_logger(
'error', current_user.username,
'The following error occurred in verify_qrcode: {0}'.format(str(e)))
raise GenericError('The administrator could not be updated')
return {}, 200
else:
raise GenericError("An invalid code was supplied")
else:
raise ValidationError("The code was not supplied")
5 changes: 3 additions & 2 deletions postmaster/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"""

from flask_wtf import Form
from wtforms import StringField, PasswordField, SelectField
from wtforms.validators import DataRequired
from wtforms import StringField, PasswordField, SelectField, IntegerField
from wtforms.validators import DataRequired, Optional
from postmaster import models
from postmaster.utils import validate_wtforms_password

Expand All @@ -16,6 +16,7 @@ class LoginForm(Form):
"""
username = StringField(label='Username', validators=[DataRequired()])
password = PasswordField(label='Password', validators=[DataRequired(), validate_wtforms_password])
two_factor = IntegerField(label='Two Factor', validators=(Optional(),))
auth_source = SelectField('PostMaster User', validators=[DataRequired()])

@classmethod
Expand Down
18 changes: 17 additions & 1 deletion postmaster/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from postmaster.errors import ValidationError
from re import search, match
from os import urandom
import base64
from passlib.hash import sha512_crypt as sha512 # pylint: disable=no-name-in-module
from hashlib import sha1
import onetimepass


class VirtualDomains(db.Model):
Expand Down Expand Up @@ -226,6 +228,8 @@ class Admins(db.Model):
username = db.Column(db.String(120), unique=True)
password = db.Column(db.String(64))
source = db.Column(db.String(64))
otp_secret = db.Column(db.String(16))
otp_active = db.Column(db.Boolean, default=False)
active = db.Column(db.Boolean, default=True)
failed_attempts = db.Column(db.Integer)
last_failed_date = db.Column(db.DateTime)
Expand Down Expand Up @@ -262,7 +266,8 @@ def to_json(self):
"""
return {'id': self.id, 'name': self.name, 'username': self.username, 'failed_attempts': self.failed_attempts,
'last_failed_date': self.last_failed_date, 'unlock_date': self.unlock_date,
'locked': (self.unlock_date is not None and self.unlock_date > datetime.utcnow())}
'locked': (self.unlock_date is not None and self.unlock_date > datetime.utcnow()),
'two_factor': self.otp_active}

def from_json(self, json):
if not json.get('username', None):
Expand Down Expand Up @@ -366,6 +371,17 @@ def set_password(self, new_password):

self.password = bcrypt.generate_password_hash(new_password)

def generate_otp_secret(self, **kwargs):
# generate a random secret
self.otp_secret = base64.b32encode(urandom(10)).decode('utf-8')

def get_totp_uri(self):
return 'otpauth://totp/PostMaster:{0}?secret={1}&issuer=PostMaster' \
.format(self.username, self.otp_secret)

def verify_totp(self, token):
return onetimepass.valid_totp(token, self.otp_secret)


class Configs(db.Model):
""" Table to store configuration items
Expand Down
17 changes: 7 additions & 10 deletions postmaster/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,44 +256,41 @@ def validate_wtforms_password(form, field):
"""
username = form.username.data
password = form.password.data
two_factor = form.two_factor.data if form.two_factor.data else None

try:
if form.auth_source.data == 'PostMaster User':
admin = models.Admins.query.filter_by(username=username, source='local').first()

if admin:

if admin.is_unlocked():

if bcrypt.check_password_hash(admin.password, password):
if admin.otp_active:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can consolidate these two lines to:
if admin.otp_active and not admin.verify_totp(two_factor):

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's written the way it is so we can return messages based on if they even have otp_active or if their token was correct.

if not admin.verify_totp(two_factor):
raise WtfStopValidation('The two factor authentication code is incorrect')
form.admin = admin
return
else:
increment_failed_login(username)

else:
raise WtfStopValidation('The user is currently locked out. Please try logging in again later.')

json_logger(
'auth', username,
'The administrator "{0}" entered an incorrect username or password'.format(
username))
raise WtfStopValidation('The username or password was incorrect')
else:
ad_object = AD()

if ad_object.login(username, password):

if ad_object.check_group_membership():
friendly_username = ad_object.get_loggedin_user()
display_name = ad_object.get_loggedin_user_display_name()

if not models.Admins.query.filter_by(username=friendly_username, source='ldap').first():
add_ldap_user_to_db(friendly_username, display_name)

admin = models.Admins.query.filter_by(username=friendly_username, source='ldap').first()
if admin.otp_active:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can consolidate these two lines to:
if admin.otp_active and not admin.verify_totp(two_factor):

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's written the way it is so we can return messages based on if they even have otp_active or if their token was correct.

if not admin.verify_totp(two_factor):
raise WtfStopValidation('The two factor authentication code is incorrect')
form.admin = admin

except ADException as e:
raise WtfStopValidation(e.message)

Expand Down
4 changes: 3 additions & 1 deletion requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ Flask-SQLAlchemy
Flask-WTF
mock
mockldap
pymysql
onetimepass
passlib
pylint
pymysql
pyqrcode
pytest
pytest-cov
python-ldap
8 changes: 5 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file .\requirements.txt .\requirements.in
# pip-compile --output-file requirements.txt requirements.in
#
alembic==0.8.7 # via flask-migrate
astroid==1.4.8 # via pylint
Expand Down Expand Up @@ -31,17 +31,19 @@ MarkupSafe==0.23 # via jinja2, mako
mccabe==0.5.2 # via pylint
mock==2.0.0
mockldap==0.2.8
onetimepass==1.0.1
passlib==1.6.5
pbr==1.10.0 # via mock
py==1.4.31 # via pytest
pycparser==2.14 # via cffi
pylint==1.6.4
PyMySQL==0.7.6
pymysql==0.7.9
PyQRCode==1.2.1
pytest-cov==2.3.1
pytest==2.9.2
python-editor==1.0.1 # via alembic
python-ldap==2.4.27
six==1.10.0 # via astroid, bcrypt, mock, pylint
six==1.10.0 # via astroid, bcrypt, mock, onetimepass, pylint
SQLAlchemy==1.0.14 # via alembic, flask-sqlalchemy
Werkzeug==0.11.10 # via flask, flask-wtf
wrapt==1.10.8 # via astroid
Expand Down
Loading