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
1 change: 1 addition & 0 deletions config.default.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class TestConfiguration(BaseConfiguration):
WTF_CSRF_ENABLED = False
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
DEBUG = True
LOG_LOCATION = ''


class DevConfiguration(BaseConfiguration):
Expand Down
3 changes: 3 additions & 0 deletions docs/Configuration/ConfigurationsPage.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ such as "LDAPS://dc1.postmaster.local:636". Although not recommended, you can al
This setting's value must be the same as the "Group name (pre-Windows 2000)" value of the desired group as shown in the screenshot below.

[![PostMaster Active Directory Group](../imgs/AD_Group.png)](../imgs/AD_Group.png)

**LDAP Authentication Method** specifies which authentication mechanism to use when authenticating via LDAP to Active Directory.
NTLM is the default option and is more secure, as the password is never sent to the Domain Controller.
229 changes: 229 additions & 0 deletions postmaster/ad.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""
Author: StackFocus
File: ad.py
Purpose: Active Directory class
"""
import ldap3
import re
from postmaster import models
from postmaster.logger import json_logger


class ADException(Exception):
""" A custom exception for the PostMasterLDAP class
"""
pass


class AD(object):
""" A class that handles all the Active Directory tasks for the Flask app
"""
ldap_connection = None
ldap_server = None
ldap_admin_group = None
domain = None

def __init__(self):
""" The constructor that initializes the ldap_connection object
"""
ldap_enabled = models.Configs().query.filter_by(setting='Enable LDAP Authentication').first()
if ldap_enabled is not None and ldap_enabled.value == 'True':
ldap_server = models.Configs().query.filter_by(setting='AD Server LDAP String').first()
domain = models.Configs().query.filter_by(setting='AD Domain').first()
ldap_admin_group = models.Configs().query.filter_by(setting='AD PostMaster Group').first()
ldap_auth_method = models.Configs().query.filter_by(setting='LDAP Authentication Method').first()

if ldap_server is not None and re.search(r'LDAP[S]?:\/\/(.*?)\:\d+', ldap_server.value, re.IGNORECASE):
self.ldap_server = ldap_server.value

if domain is not None and ldap_admin_group is not None:
self.domain = domain.value
self.ldap_admin_group = ldap_admin_group.value

ad_server = ldap3.Server(
ldap_server.value, allowed_referral_hosts=[('*', False)], connect_timeout=3)
# Use NTLMv2 authentication so that credentials aren't set in the clear if LDAPS is not used
self.ldap_connection = ldap3.Connection(
ad_server, auto_referrals=False, authentication=ldap_auth_method.value.upper())

try:
self.ldap_connection.open()
except ldap3.core.exceptions.LDAPSocketOpenError:
json_logger(
'error', 'NA',
'The LDAP server "{0}" could not be contacted'.format(self.ldap_server))
raise ADException('The LDAP server could not be contacted')
else:
json_logger('error', 'NA', 'The LDAP Admin Group is not configured properly')
raise ADException('LDAP authentication is not properly configured')
else:
json_logger('error', 'NA',
'The LDAP server string is not configured or isn\'t properly formatted')
raise ADException('The LDAP server could not be contacted')
else:
json_logger('error', 'NA', 'An LDAP authentication attempt was made but it is currently disabled')
raise ADException('LDAP authentication is not enabled')

def __del__(self):
""" The destructor that disconnects from LDAP
"""
self.ldap_connection.unbind()

@property
def base_dn(self):
""" returns the base distinguished name (e.g. DC=postmaster,DC=local)
"""
return 'DC=' + (self.domain.replace('.', ',DC='))

def parse_username_input(self, username):
""" Takes a username and properly formats it for authentication with Active Directory
"""
extract_username_regex = re.compile(r'(?P<username>.+)(?:@.*)')
# Determine if a UPN or domain was provided
if '\\' in username:
return username
elif re.match(extract_username_regex, username):
# Parse the username from the UPN
username_search = re.search(extract_username_regex, username)
return '{0}\\{1}'.format(self.domain, username_search.group('username'))
elif self.ldap_connection.authentication != 'NTLM' and re.search('CN=', username, re.IGNORECASE):
# If the authentication method is not NTLM, then distinguished names are valid usernames
return username
else:
return '{0}\\{1}'.format(self.domain, username)

def login(self, username, password):
""" Uses the supplied username and password to bind to LDAP and returns a boolean
"""
bind_username = self.parse_username_input(username)

self.ldap_connection.user = str(bind_username)
self.ldap_connection.password = str(password)
if self.ldap_connection.bind():
return True

json_logger(
'auth', bind_username,
'The administrator "{0}" entered an incorrect username or password via LDAP'.format(bind_username))
raise ADException('The username or password was incorrect')

def search(self, search_filter, attributes=None, search_scope=ldap3.SUBTREE):
""" Returns LDAP objects based on the search_filter and the desired attributes
"""
# Check if the ldap_connection is in a logged in state
if self.ldap_connection.bound:
if self.ldap_connection.search(
self.base_dn, search_filter, search_scope=search_scope, attributes=attributes):
# Check if anything was returned
if len(self.ldap_connection.response):
return self.ldap_connection.response

json_logger(
'error', self.get_loggedin_user(),
'The LDAP object could not be found using the search filter "{0}"'.format(search_filter))
raise ADException('There was an error searching the LDAP server. Please try again.')
else:
raise ADException('You must be logged into LDAP to search')

def get_ldap_object(self, sam_account_name, attributes=None):
""" Returns an LDAP object based on the sAMAccountName and the desired attributes
"""
search_filter = '(sAMAccountName={0})'.format(sam_account_name)
ldap_object = self.search(search_filter, attributes)[0]
# Check if a user was returned
if 'dn' in ldap_object:
if attributes and 'attributes' in ldap_object:
for attribute in attributes:
if attribute not in ldap_object['attributes']:
json_logger(
'error', self.get_loggedin_user(),
('The object with account name "{0}" was found in LDAP, but the attribute "{1}" was'
' not'.format(sam_account_name, attribute)))
raise ADException('There was an error searching the LDAP server. Please try again.')
return ldap_object

json_logger(
'error', self.get_loggedin_user(),
'The object with account name "{0}" could not be found in LDAP'.format(sam_account_name))
raise ADException('There was an error searching the LDAP server. Please try again.')

def get_loggedin_user(self):
""" Returns the logged in username without the domain
"""
# Check if the ldap_connection is in a logged in state
if self.ldap_connection.bound:
# If a distinguished name was used to login, get the sAMAccountName
if re.search('CN=', self.ldap_connection.user, re.IGNORECASE):
search_filter = '(&(objectClass=user)(distinguishedName={0}))'.format(self.ldap_connection.user)
user = self.search(search_filter, ['sAMAccountName'])
return user[0]['attributes']['sAMAccountName']
# The username is stored as DOMAIN\username, so this gets the sAMAccountName
return re.sub(r'(^.*(?<=\\))', '', self.ldap_connection.user)

return None

def get_loggedin_user_display_name(self):
""" Returns the display name or the object name if the display name is not available of the logged on user
"""
user = self.get_ldap_object(self.get_loggedin_user(), ['displayName', 'name'])
# If the displayName is defined, return that, otherwise, return the name which is always defined
if user['attributes']['displayName']:
return user['attributes']['displayName']
else:
return user['attributes']['name']

def get_distinguished_name(self, sam_account_name):
""" Gets the distinguishedName of an LDAP object based on the sAMAccountName
"""
ldap_object = self.get_ldap_object(sam_account_name)
return ldap_object['dn']

def check_nested_group_membership(self, group_sam_account_name, user_sam_account_name):
""" Checks the nested group membership of a user by supplying the sAMAccountName, and verifies if the user is a
part of that supplied group. A list with the groups the user is a member of will be returned
"""
group_dn = self.get_distinguished_name(group_sam_account_name)
user_dn = self.get_distinguished_name(user_sam_account_name)
search_filter = '(memberof:1.2.840.113556.1.4.1941:={0})'.format(group_dn)
# By setting the base to be the user, and the dn searching for as the group, search will return a boolean
# based on if the user is a member or not
return self.ldap_connection.search(user_dn, search_filter)

def get_primary_group_dn_of_user(self, sam_account_name):
""" Returns the distinguished name of the primary group of the user
"""
user = self.get_ldap_object(sam_account_name, ['primaryGroupID'])
primary_group_id = str(user['attributes']['primaryGroupID'])

domain_search = self.search('(objectClass=domainDNS)', ['objectSid'], ldap3.BASE)
if 'dn' in domain_search[0]:
if domain_search[0] and 'attributes' in domain_search[0] and 'objectSid' in domain_search[0]['attributes']:
domain_sid = domain_search[0]['attributes']['objectSid']
group_search_filter = '(&(objectClass=group)(objectSid={0}-{1}))'.format(domain_sid, primary_group_id)
group_search = self.search(group_search_filter)
if 'dn' in group_search[0]:
return group_search[0]['dn']

json_logger('error', self.get_loggedin_user(), 'The objectSid of the domain could not be found')
raise ADException('There was an error searching the LDAP server. Please try again.')

def check_group_membership(self):
""" Checks the group membership of the logged on user. This will return True if the user is a member of
the Administrator group set in the database
"""
username = self.get_loggedin_user()
if self.check_nested_group_membership(self.ldap_admin_group, username):
return True

admin_group_dn = self.get_distinguished_name(self.ldap_admin_group)
# If the user was not a member of the group, check to see if the admin group is the primary group
# of the user which is not included in memberOf (this is typically Domain Users)
primary_group_dn = self.get_primary_group_dn_of_user(username)
if admin_group_dn == primary_group_dn:
return True

json_logger(
'auth', username,
('The LDAP user "{0}" authenticated but the login failed because they weren\'t '
'a PostMaster administrator').format(username))
raise ADException('The user account is not authorized to login to PostMaster')
3 changes: 2 additions & 1 deletion postmaster/apiv1/admins.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from StringIO import StringIO
from postmaster import db
from postmaster.models import Admins
from postmaster.utils import json_logger, clear_lockout_fields_on_user
from postmaster.logger import json_logger
from postmaster.utils import clear_lockout_fields_on_user
from postmaster.decorators import json_wrap, paginate
from postmaster.errors import ValidationError, GenericError
from postmaster.apiv1 import apiv1
Expand Down
2 changes: 1 addition & 1 deletion postmaster/apiv1/aliases.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
from postmaster.models import VirtualAliases
from postmaster.utils import json_logger
from postmaster.logger import json_logger
from postmaster.decorators import json_wrap, paginate
from postmaster.errors import ValidationError, GenericError
from postmaster.apiv1 import apiv1
Expand Down
2 changes: 1 addition & 1 deletion postmaster/apiv1/configs.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
from postmaster.models import Configs
from postmaster.utils import json_logger
from postmaster.logger import json_logger
from postmaster.decorators import json_wrap, paginate
from postmaster.errors import ValidationError, GenericError
from postmaster.apiv1 import apiv1
Expand Down
2 changes: 1 addition & 1 deletion postmaster/apiv1/domains.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
from postmaster.models import VirtualDomains
from postmaster.utils import json_logger
from postmaster.logger import json_logger
from postmaster.decorators import json_wrap, paginate
from postmaster.errors import ValidationError, GenericError
from postmaster.apiv1 import apiv1
Expand Down
2 changes: 1 addition & 1 deletion postmaster/apiv1/users.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
from postmaster.models import VirtualUsers, VirtualAliases, Configs
from postmaster.utils import json_logger
from postmaster.logger import json_logger
from postmaster.decorators import json_wrap, paginate
from postmaster.errors import ValidationError, GenericError
from postmaster.apiv1 import apiv1
Expand Down
2 changes: 2 additions & 0 deletions postmaster/apiv1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ def is_config_update_valid(setting, value, valid_value_regex):
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.')
elif setting == 'LDAP Authentication Method':
raise ValidationError('An invalid value was supplied. The value must be either "NTLM" or "SIMPLE"')

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

Expand Down
50 changes: 50 additions & 0 deletions postmaster/logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
Author: StackFocus
File: utils.py
Purpose: logging function
"""
import json
from datetime import datetime
from os import path
from postmaster import app, models
from postmaster.errors import ValidationError


def maildb_auditing_enabled():
""" Returns a bool based on if mail db auditing is enabled
"""
auditing_setting = models.Configs.query.filter_by(
setting='Mail Database Auditing').first().value
return auditing_setting == 'True'


def login_auditing_enabled():
""" Returns a bool based on if mail db auditing is enabled
"""
auditing_setting = models.Configs.query.filter_by(
setting='Login Auditing').first().value
return auditing_setting == 'True'


def json_logger(category, admin, message):
""" Takes a category (typically error or audit), a log message and the responsible
user. It then appends it with an ISO 8601 UTC timestamp to a JSON formatted log file
"""
log_path = app.config.get('LOG_LOCATION')
if log_path and ((category == 'error') or
(category == 'audit' and maildb_auditing_enabled()) or
(category == 'auth' and login_auditing_enabled())):
try:
with open(log_path, mode='a+') as log_file:
log_file.write("{}\n".format(json.dumps(
{
'category': category,
'message': message,
'admin': admin,
'timestamp': datetime.utcnow().isoformat() + 'Z'
},
sort_keys=True)))
log_file.close()
except IOError:
raise ValidationError('The log could not be written to "{0}". '
'Verify that the path exists and is writeable.'.format(path.abspath(log_path)))
Loading