diff --git a/docs/ChangeLog.md b/docs/ChangeLog.md index 7d6ae03..7115700 100644 --- a/docs/ChangeLog.md +++ b/docs/ChangeLog.md @@ -12,6 +12,7 @@ Features: * Added the ability to install PostMaster via a deb package [GH-111] * Adds the ability to lockout local accounts after x number of failed login attempts [GH-142] * Database upgrades/migrations are automatic during ugrades via the deb package and Docker [GH-138] +* Adds the ability to unlock administrators and reset administrator passwords via the CLI [GH-145] Improvements: diff --git a/docs/Configuration/CommandLineConfiguration.md b/docs/Configuration/CommandLineConfiguration.md index cb8b33b..708fbf8 100644 --- a/docs/Configuration/CommandLineConfiguration.md +++ b/docs/Configuration/CommandLineConfiguration.md @@ -31,6 +31,10 @@ Use the following commands to restore the proper permissions on the PostMaster f **generatekey** replaces the secret key in config.py which is used by Flask (the Python framework used for PostMaster) for cryptographic functions. After the initial installation, this command should not be run again as all current logins would become invalid upon the next restart of the PostMaster. +**unlockadmin username** unlocks a locked out administrator (replace username with the actual value). + +**resetadminpassword username password** resets an administrator's password to the desired value (replace user and password with the actual values) + **runserver -d -h 0.0.0.0** runs PostMaster in debug mode on port 5000. This is useful if you are having issues as it bypasses the webserver and displays failing errors in HTML. diff --git a/manage.py b/manage.py index b706e46..0cb3dfb 100644 --- a/manage.py +++ b/manage.py @@ -11,7 +11,7 @@ from re import sub from flask_script import Manager from postmaster import app, db, models -from postmaster.utils import add_default_configuration_settings +from postmaster.utils import add_default_configuration_settings, clear_lockout_fields_on_user, reset_admin_password manager = Manager(app) if os.environ.get('POSTMASTER_DEV'): @@ -98,5 +98,18 @@ def setlogfile(filepath): else: print(line.rstrip()) + +@manager.command +def unlockadmin(username): + """Unlocks a locked out administrator""" + clear_lockout_fields_on_user(username) + + +@manager.command +def resetadminpassword(username, new_password): + """Resets an administrator's password with one supplied""" + reset_admin_password(username, new_password) + + if __name__ == "__main__": manager.run() diff --git a/postmaster/apiv1/admins.py b/postmaster/apiv1/admins.py index 6966c96..2cb29f6 100644 --- a/postmaster/apiv1/admins.py +++ b/postmaster/apiv1/admins.py @@ -7,7 +7,7 @@ from flask import request from flask_login import login_required, current_user -from postmaster import db, bcrypt +from postmaster import db from postmaster.models import Admins, Configs from postmaster.utils import json_logger, clear_lockout_fields_on_user from ..decorators import json_wrap, paginate @@ -108,30 +108,21 @@ def update_admin(admin_id): auditMessage = 'The administrator "{0}" had their username changed to "{1}"'.format( admin.username, newUsername) admin.username = newUsername - db.session.add(admin) else: ValidationError('The username supplied already exists') elif 'password' in json: - minPwdLength = int(Configs.query.filter_by( - setting='Minimum Password Length').first().value) - if len(json['password']) < minPwdLength: - raise ValidationError( - 'The password must be at least {0} characters long'.format( - minPwdLength)) + admin.set_password(json['password']) auditMessage = 'The administrator "{0}" had their password changed'.format( admin.username) - admin.password = bcrypt.generate_password_hash(json['password']) - db.session.add(admin) elif 'name' in json: - auditMessage = 'The administrator "{0}" had their name changed to "{1}"'.format( - admin.username, admin.name) + auditMessage = 'The administrator "{0}" had their name changed to "{1}"'.format(admin.username, admin.name) admin.name = json['name'] - db.session.add(admin) else: raise ValidationError( 'The username, password, or name was not supplied in the request') try: + db.session.add(admin) db.session.commit() json_logger('audit', current_user.username, auditMessage) except ValidationError as e: diff --git a/postmaster/models.py b/postmaster/models.py index 81279b8..39a54bd 100644 --- a/postmaster/models.py +++ b/postmaster/models.py @@ -354,6 +354,17 @@ def increment_failed_login(self, account_lockout_threshold, reset_account_lockou self.last_failed_date = now + def set_password(self, new_password): + """ Sets the password for an admin. + """ + if not self.id: + raise ValidationError('An admin is not associated with the object') + + min_pwd_length = int(Configs.query.filter_by(setting='Minimum Password Length').first().value) + if len(new_password) < min_pwd_length: + raise ValidationError('The password must be at least {0} characters long'.format(min_pwd_length)) + + self.password = bcrypt.generate_password_hash(new_password) class Configs(db.Model): diff --git a/postmaster/utils.py b/postmaster/utils.py index eaccb58..b47ffca 100644 --- a/postmaster/utils.py +++ b/postmaster/utils.py @@ -200,6 +200,32 @@ def clear_lockout_fields_on_user(username): db.session.close() +def reset_admin_password(username, new_password): + """ Resets an admin's password with one supplied + """ + admin = models.Admins.query.filter_by(username=username).first() + + if not admin: + raise ValidationError('The admin does not exist in the database.') + + admin.set_password(new_password) + + try: + db.session.add(admin) + db.session.commit() + json_logger('audit', 'CLI', ('The administrator "{0}" had their password changed via the CLI'.format(username))) + except ValidationError as e: + raise e + except Exception as e: + db.session.rollback() + json_logger( + 'error', username, + 'The following error occurred when try to reset an admin\'s password: {0}'.format(str(e))) + ValidationError('A database error occurred. Please try again.', 'error') + finally: + db.session.close() + + def add_ldap_user_to_db(username, display_name): """ Adds an LDAP user stub in the Admins table of the database for flask_login """ diff --git a/tests/utils/test_utils_functions.py b/tests/utils/test_utils_functions.py index 2d8aa9d..e8240a3 100644 --- a/tests/utils/test_utils_functions.py +++ b/tests/utils/test_utils_functions.py @@ -4,7 +4,7 @@ from mockldap import MockLdap from mock import patch from datetime import datetime, timedelta -from postmaster import app +from postmaster import app, bcrypt from postmaster.utils import * from postmaster.apiv1.utils import * @@ -197,6 +197,14 @@ def test_clear_lockout_fields_on_user(self): assert new_test_admin.unlock_date is None assert new_test_admin.last_failed_date is None + def test_reset_admin_password(self): + test_admin = generate_test_admin() + db.session.add(test_admin) + db.session.commit() + reset_admin_password('test_admin', 'SomeNewPassword') + new_test_admin = models.Admins.query.filter_by(username='test_admin').first() + assert bcrypt.check_password_hash(new_test_admin.password, 'SomeNewPassword') is True + def test_get_wtforms_errors(self): """ Tests the get_wtforms_errors function by posting to /login with missing parameters. This also tests the new_line_to_break Jinja2 filter. The expected return value is an