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
138 changes: 105 additions & 33 deletions docs/API/openAPI-spec.html

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions docs/API/openAPI-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,29 @@
404:
description: "The administrator was not found"

/admins/unlock/{admin_id}:
put:
tags:
- "admins"
summary: "Unlocks a locked out local administrator"
description: ""
consumes:
- "application/json"
produces:
- "application/json"
parameters:
-
name: "admin_id"
in: "path"
description: "The ID of the administrator to unlock"
required: true
type: "integer"
format: "int64"
responses:
200:
description: "The administrator was unlocked"
404:
description: "The administrator was not found"

/aliases:
get:
Expand Down
5 changes: 4 additions & 1 deletion docs/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

BACKWARDS INCOMPATIBILITIES / NOTES:

* The 'Log File' config option is now baked into the application config and cannot be set in the API/UI/database. Use `python manage.py setlogfile <path>` or edit config.py to change the log file location. See [GH-128].
* The 'Log File' config option is now baked into the application config and cannot be set in the API/UI/database. Use `python manage.py setlogfile <path>` or edit config.py to change the log file location. [GH-128]
* `python manage.py createdb` has been replaced with `python manage.py upgradedb` [GH-138]
* On new installations of PostMaster, the ID of the configuration settings will change. Existing installations that will be upgraded will not be affected. [GH-142]
* The API error messages for HTTP 400 and 404 have been made friendlier. Any automation that keys in on these messages will be broken. [GH-142]

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]

Improvements:
Expand Down
9 changes: 9 additions & 0 deletions docs/Configuration/ConfigurationsPage.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
**Minimum Password Length** specifies the minimum password length that all mail users and administrators must adhere to.

**Account Lockout Threshold** specifies the number of failed login attempts a local account must make before being locked out.
To disable account lockouts, set this value to 0.

**Account Lockout Duration in Minutes** specifies the amount of time an account will be locked out for.

**Reset Account Lockout Counter in Minutes** determines how long a bad password attempt will count towards a lockout.
As an example, this value is set to 30, and an administrator entered an incorrect password twice; then the administrator returned 30 minutes later to try again,
the two failed password attempts would no longer count because the configured 30 minutes would have elapsed.

**Login Auditing** determines whether login and logout events are recorded in the log file.

**Mail Database Auditing** determines whether changes to domains, users, aliases, administrators, and configuration settings should be recorded in the log file.
Expand Down
30 changes: 30 additions & 0 deletions migrations/versions/c3803ac9b7dd_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""empty message

Revision ID: c3803ac9b7dd
Revises: e8f52e92abd0
Create Date: 2016-07-20 01:30:13.068954

"""

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

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('postmaster_admins', sa.Column('failed_attempts', sa.Integer(), nullable=True))
op.add_column('postmaster_admins', sa.Column('last_failed_date', sa.DateTime(), nullable=True))
op.add_column('postmaster_admins', sa.Column('unlock_date', sa.DateTime(), nullable=True))
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column('postmaster_admins', 'unlock_date')
op.drop_column('postmaster_admins', 'last_failed_date')
op.drop_column('postmaster_admins', 'failed_attempts')
### end Alembic commands ###
4 changes: 2 additions & 2 deletions postmaster/apiv1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ def generic_error(e):
def bad_request_error(e):
""" Error handler for 400 requests
"""
return bad_request('invalid request')
return bad_request('An invalid request was received')


@apiv1.errorhandler(404)
def not_found_error(e):
""" Error handler for 404 requests
"""
return not_found('item not found')
return not_found('The item was not found')


from . import domains, users, aliases, admins, configs, logs
13 changes: 12 additions & 1 deletion postmaster/apiv1/admins.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from flask_login import login_required, current_user
from postmaster import db, bcrypt
from postmaster.models import Admins, Configs
from postmaster.utils import json_logger
from postmaster.utils import json_logger, clear_lockout_fields_on_user
from ..decorators import json_wrap, paginate
from ..errors import ValidationError, GenericError
from . import apiv1
Expand Down Expand Up @@ -145,3 +145,14 @@ def update_admin(admin_id):
finally:
db.session.close()
return {}, 200


@apiv1.route('/admins/unlock/<int:admin_id>', methods=['PUT'])
@login_required
@json_wrap
def unlock_admin(admin_id):
""" Unlocks an admin by ID in Admins, and returns HTTP 200 on success
"""
admin = Admins.query.get_or_404(admin_id)
clear_lockout_fields_on_user(admin.username)
return {}, 200
6 changes: 4 additions & 2 deletions postmaster/apiv1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ def is_config_update_valid(setting, value, valid_value_regex):

return True
else:
if setting == 'Minimum Password Length':
raise ValidationError('An invalid minimum password length was supplied. The value must be between 1-25.')
if setting == 'Minimum Password Length' or setting == 'Account Lockout Threshold':
raise ValidationError('An invalid value was supplied. The value must be between 0-25.')
elif setting == 'Account Lockout Duration in Minutes' or setting == 'Reset Account Lockout Counter in Minutes':
raise ValidationError('An invalid value was supplied. The value must be between 1-99.')

raise ValidationError('An invalid setting value was supplied')

Expand Down
74 changes: 67 additions & 7 deletions postmaster/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Purpose: database definitions for SQLAlchemy
"""
from postmaster import db, bcrypt
from datetime import datetime, timedelta
from .errors import ValidationError
from re import search, match
from os import urandom
Expand Down Expand Up @@ -226,6 +227,9 @@ class Admins(db.Model):
password = db.Column(db.String(64))
source = db.Column(db.String(64))
active = db.Column(db.Boolean, default=True)
failed_attempts = db.Column(db.Integer)
last_failed_date = db.Column(db.DateTime)
unlock_date = db.Column(db.DateTime)

def is_active(self):
""" Returns if user is active
Expand Down Expand Up @@ -256,7 +260,9 @@ def __repr__(self):
def to_json(self):
""" Leaving password out
"""
return {'id': self.id, 'name': self.name, 'username': self.username}
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())}

def from_json(self, json):
if not json.get('username', None):
Expand All @@ -268,12 +274,10 @@ def from_json(self, json):
if self.query.filter_by(username=json['username']).first() is not None:
raise ValidationError('"{0}" already exists'.format(
json['username'].lower()))
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))
min_pwd_length = int(Configs.query.filter_by(setting='Minimum Password Length').first().value)
if len(json['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(json['password'])
self.username = json['username'].lower()
self.name = json['name']
Expand All @@ -295,6 +299,62 @@ def ldap_user_from_json(self, json):
self.active = True
return self

def is_unlocked(self):
""" Returns a boolean based on if the admin is unlocked.
"""
if not self.id:
raise ValidationError('An admin is not associated with the object')

if self.unlock_date and self.unlock_date > datetime.utcnow():
return False

return True

def clear_lockout_fields(self):
""" Clears the lockout fields (failed_attempts, last_failed_date, unlock_date) on an admin.
"""
if not self.id:
raise ValidationError('An admin is not associated with the object')

# Only clear out the lockout fields if the admin is not an LDAP user
if self.source == 'local':
self.failed_attempts = 0
self.last_failed_date = None
self.unlock_date = None

def increment_failed_login(self, account_lockout_threshold, reset_account_lockout_counter,
account_lockout_duration):
""" Increments the failed_attempts value, updates the last_failed_date value, and sets the unlock_date value
if applicable on the admin object.
"""
now = datetime.utcnow()

if not self.id:
raise ValidationError('An admin is not associated with the object')

# Only increment the failed login count if the admin is not an LDAP user
if self.source == 'local':
# If the last failed attempt was before the current time minus the minutes configured to reset the
# account lockout counter, then the failed attempts should be set to 1 again
if self.last_failed_date and self.last_failed_date < \
(now - timedelta(minutes=reset_account_lockout_counter)):
self.failed_attempts = 1
self.unlock_date = None
else:
# If the admin has never failed a login attempt, the failed_attempts column will be null
if self.failed_attempts:
self.failed_attempts += 1
else:
self.failed_attempts = 1

# Only try to lockout the user if the account lockout threshold is greater than 0, otherwise account
# lockouts are disabled
if account_lockout_threshold != 0 and self.failed_attempts >= account_lockout_threshold:
self.unlock_date = now + timedelta(minutes=account_lockout_duration)

self.last_failed_date = now



class Configs(db.Model):
""" Table to store configuration items
Expand Down
13 changes: 13 additions & 0 deletions postmaster/static/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ h2.textHeading {
bottom: 0;
margin: 0;
width: 100%;
z-index: 999;
}

#bottomOuterAlertDiv div.alert {
Expand Down Expand Up @@ -233,6 +234,14 @@ ul.pagination li.disabled a:hover {
#nameAdminHeader {
width: 28.33%;
}

#lockedAdminHeader, #actionHeader {
width: 7.5%;
}

a.adminLocked {
color: #e74c3c;
}
/* End of Only Admin Page CSS */

/* Only Configs Page CSS */
Expand Down Expand Up @@ -261,6 +270,10 @@ ul.pagination li.disabled a:hover {
width: 85%;
}

.hiddenMobile {
display: none !important;
}

h2.textHeadingManage,
h2.textHeadingManageNoFilter,
h2.textHeading {
Expand Down
31 changes: 30 additions & 1 deletion postmaster/static/js/admins.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function newAdmin(username, password, name) {


// Deletes an administrator via the API
function deleteAdmin (id) {
function deleteAdmin(id) {

$.ajax({
url: '/api/v1/admins/' + id,
Expand All @@ -42,19 +42,41 @@ function deleteAdmin (id) {
});
}

// Unlocks an administrator via the API
function unlockAdmin(id, targetLink) {

$.ajax({
url: '/api/v1/admins/unlock/' + id,
type: 'put',

success: function (response) {
addStatusMessage('success', 'The administrator was unlocked successfully');
targetLink.parent('td').html('Unlocked');
},

error: function (response) {
addStatusMessage('error', filterText(jQuery.parseJSON(response.responseText).message));
}
});

}

// Sets the event listeners for x-editable
function editableAdminEventListeners() {

var adminUsername = $('a.adminUsername');
var adminPassword = $('a.adminPassword');
var adminName = $('a.adminName');
var adminLocked = $('a.adminLocked')

adminUsername.unbind();
adminPassword.unbind();
adminName.unbind();
adminLocked.unbind();
adminUsername.tooltip();
adminPassword.tooltip();
adminName.tooltip();
adminLocked.tooltip();

adminUsername.editable({
type: 'text',
Expand Down Expand Up @@ -139,6 +161,12 @@ function editableAdminEventListeners() {
addStatusMessage('success', 'The administrator\'s name was changed successfully');
}
});

adminLocked.on('click', function(e) {
var target = $(e.target);
unlockAdmin(target.attr('data-pk'), target);
e.preventDefault();
});
}

// Sets the event listeners in the dynamic table
Expand Down Expand Up @@ -251,6 +279,7 @@ function fillInTable () {
html += '<td data-title="Username: "><a href="#" class="adminUsername" data-pk="' + item.id + '" data-url="/api/v1/admins/' + item.id + '" title="Click to change the username">' + filterText(item.username) + '</a></td>\
<td data-title="Password: "><a href="#" class="adminPassword" data-pk="' + item.id + '" data-url="/api/v1/admins/' + item.id + '" title="Click to change the password">●●●●●●●●</a></td>\
<td data-title="Name: "><a href="#" class="adminName" data-pk="' + item.id + '" data-url="/api/v1/admins/' + item.id + '" title="Click to change the name">' + filterText(item.name) + '</a></td>\
<td data-title="Locked: ">' + (item.locked ? ('<a href="#" class="adminLocked" data-pk="' + item.id + '" title="Click to unlock the administrator">Locked</a>') : 'Unlocked') + '</td>\
<td data-title="Action: "><a href="#" class="deleteAnchor" data-pk="' + item.id + '" data-toggle="modal" data-target="#deleteModal">Delete</a></td>';
tableRow.length == 0 ? html += '</tr>' : null;
tableRow.length == 0 ? insertTableRow(html) : tableRow.html(html);
Expand Down
2 changes: 2 additions & 0 deletions postmaster/templates/admins.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ <h2 class="textHeadingManage">PostMaster Administrators</h2>
<th id="usernameAdminHeader">Email:</th>
<th id="passwordAdminHeader">Password:</th>
<th id="nameAdminHeader">Name:</th>
<th id="lockedAdminHeader">Locked:</th>
<th id="actionHeader">Action:</th>
</tr>
</thead>
Expand All @@ -31,6 +32,7 @@ <h2 class="textHeadingManage">PostMaster Administrators</h2>
<td data-title="Email: "><input id="newAdminInput" class="form-control" type="text" placeholder="Enter a new admins's email address" /></td>
<td data-title="Password: "><input id="newAdminPasswordInput" class="form-control" type="password" placeholder="Enter the new admin's password" /></td>
<td data-title="Name: "><input id="newAdminNameInput" class="form-control" type="text" placeholder="Enter a new admins's name" /></td>
<td class="hiddenMobile"></td>
<td data-title="Action: "><a href="#" id="newItemAnchor">Add</a></td>
</tr>
</tbody>
Expand Down
Loading