Kerberos single sign onansible_ssh_user: <not specified>
ansible_ssh_pass: <not specified>
Kerberos with a specific user (UPN)
ansible_ssh_user: user@realm
ansible_ssh_pass:
Kerberos with a specific username passwordansible_ssh_user: user@realm
ansible_ssh_pass: password
I think we should support all 3 cases, and this suggestion seems reasonable.
Unfortunately this PR went a bit quiet, and nothing happened in the last few weeks. In particular no suggestion was made as to how to implement this.
#8914 was then submitted and suggested to have Ansible itself call kinit to ensure the provided credentials could be used in the Kerberos authentication.
One thing I really like about it is that it also adds an "ansible_authorization_type" variable, which explicitly specifies the required authentication mechanism, which I think is clearer than simply rely on the username to decide whether to use Kerberos. As an added bonus it would allow 2 other ways to indicate a user on the default domain:
Kerberos with a specific user on the default domain
ansible_ssh_user: user
ansible_ssh_pass:
ansible_authorization_type: kerberos
Kerberos with a specific username/password on the default domainansible_ssh_user: user
ansible_ssh_pass: password
ansible_authorization_type: kerberos
Regarding the call to kinit, I took a different approach and updated pywinrm to support username and password (which are currently ignored if you're using Kerberos with it).
My ansible repository is https://github.com/nicodeslandes/ansible, and the changes to pywinrm are available there: https://github.com/nicodeslandes/pywinrm/commits/alt_credentials
These changes unfortunately also required some changes to the underlying pykerberos library, so that it would accept a password. That change is here: https://github.com/nicodeslandes/pykerberos/commits/kerberos_passwords_spike.
With these changes, there's no need to call kinit. pywinrm/pykerberos take care of requesting a TGT for the username/password provided.
This was necessary for me for 2 reasons:
I also added an option to allow the delegation of the user's credentials on the remote server. Useful if for instance you need to access a network share from the Windows server you're accessing.
Now both the proposed solution in #8914 and the one I implemented in pywinrm/pykerberos have a big security problem in my scenario (using Tower):
If user A provides a password for a Kerberos principal, the TGT for this principal gets added to the credential cache of whichever user executes ansible ('awx' in that case).
That means that user B may then start a deployment for the same kerberos principal without having to provide a password, and still have access to a TGT for it from the credential cache.
I haven't found a solution to this issue yet, but we would need a sort of transient credential cache that would only be used for a specific Ansible playbook run.
I should also mention a comment from AdmiralNemo in the GitHub discussion for #8914, that is very relevant to this discussion:
Also, pywinrm may end up having native support for requesting the tgt given a principal and password[1], so having Ansible call kinit directly would not be necessary in that case.
What do people think?
Does this seem like a good way to add support for domain authentication in Ansible?
Does anyone have any idea how to tackle the security issue with cached credentials?
Thanks,
Nico
--
You received this message because you are subscribed to the Google Groups "Ansible Development" group.
To unsubscribe from this group and stop receiving emails from it, send an email to ansible-deve...@googlegroups.com.
For more options, visit https://groups.google.com/d/optout.
Hi Michael,
Yeah, sorry for the long post :). I was trying to summarize what’s been
discussed already in the 2 tickets.
Just to be clear I wasn't suggesting you guys merge anything just yet, if only because of the security issues I highlighted. I just wanted to discuss the options that we have.
Glad to hear domain authentication is on your radar already.
--
--
Hi, Chris.
$ cat tickets_plugin.py
# This plugin for ansible is intended to manage acquiring and destroying
# kerberos or windows domain credentials.
# Use at your own risk, not yet subjected to any meaningful testing.
import os
import json
import subprocess
import csv
import datetime
from datetime import datetime, timedelta
from subprocess import Popen, PIPE
from ansible import errors
class CallbackModule(object):
"""
This callback module is intended to manage acquiring and disposing
of kerberos / windows domain credentials.
"""
def __init__(self):
print "Windows/Kerberos Domain-joining plugin is active."
# TODO check kerberos user package has been installed here
# and disable the plugin and warn use if so.
def check_if_ticket_is_still_usable(self, tgt_info):
start = tgt_info['startDate'] + ' ' + tgt_info['startTime']
expires = tgt_info['expiresDate'] + ' ' + tgt_info['expiresTime']
ticket_valid_from = datetime.strptime(start, '%d/%m/%y %H:%M:%S')
ticket_valid_to = datetime.strptime(expires, '%d/%m/%y %H:%M:%S')
now = datetime.now()
ticket_aged_after= now - timedelta(days=1)
remaining_ticket_life = ticket_valid_to - now
if now > ticket_valid_to:
print "Ticket Expired"
if now < ticket_valid_to and now > ticket_valid_from:
print "Ticket valid for %s days, %s hours, %s minutes" % (days_hours_minutes(remaining_ticket_life))
if now > ticket_aged_after:
print "Ticket is aged"
def check_if_ticket_is_for_this_domain(self, tgt_info, userAtDomain):
principle = tgt_info['servicePrincipal']
parts = userAtDomain.split('@', 2)
domain = parts[1].upper()
if ( principle.startswith('krbtgt') and principle.endswith(domain) ):
return True
return False
def find_or_acquire_ticket(self, userAtDomain, password):
"""
# look for a ticket for configured ansible_ssh_user / ansible_ssh_password
# todo open subprocesses (klist, kinit, kdestroy) the same way.
"""
process = subprocess.Popen(['klist'], stdout=subprocess.PIPE)
stdout, stderr = process.communicate()
stdout_lines = stdout.decode('ascii').splitlines()
if not stdout_lines:
print "No credentials cache found. Attempting to get a ticket to cache... "
self.cache_new_ticket(userAtDomain, password)
return
else:
ticket_info_line = []
ticket_info_line.append(all_lines[4]) # all the interesting info is on line 4
reader = csv.DictReader(ticket_info_line,
delimiter=' ', skipinitialspace=True,
fieldnames=['startDate', 'startTime',
'expiresDate', 'expiresTime', 'servicePrincipal'])
if reader:
tgt_info = reader.next
if(check_if_ticket_is_for_this_domain(tgt_info, userAtDomain)):
if(check_if_ticket_is_still_usable(tgt_info)):
print "Ticket-granting ticket is still ok to use. Continuing..."
else:
print "Ticket-granting ticket is no longer usable, attempting to get a new one..."
self.cache_new_ticket(userAtDomain, password)
else:
print "Found a Ticket-granting ticket but not for %s. Continuing..." % (userAtDomain)
def run_kerberos_command(self, command_and_args, password):
try:
cmd = subprocess.Popen(command_and_args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
if password:
cmd.stdin.write('%s\n' % password)
cmd.wait()
except OSError:
raise errors.AnsibleError("%s is not installed in %s. Please check you have installed krb-user and can run kinit from the command line" % (command_and_args[0], command_and_args[1]))
except IOError:
raise errors.AnsibleError("could not authenticate as %s. Please check winrm group vars are correctly configured." % (userAtDomain))
def cache_new_ticket(self, userAtDomain, password):
command_and_args = ['kinit', '%s' % (userAtDomain) ]
self.run_kerberos_command(command_and_args, password)
def destroy_all_tickets(self):
command_and_args = ['kdestroy']
self.run_kerberos_command(command_and_args, None)
def set_ticket_cache(self):
ticket_cache = '/tmp/krb5cc_ansible-%s' % (os.getpid())
print "Caching credentials to %s" % (ticket_cache)
os.environ['KRB5CCNAME'] = ticket_cache
# the callbacks we use (on_setup and on_stats) are defined below:
def playbook_on_setup(self):
print "On setup. Checking to see if this run needs to join any windows/kerberos domains..."
self.set_ticket_cache()
hosts = self.playbook.inventory.get_hosts()
for host in hosts:
print "Considering host: %s" % (host.name)
host_vars = self.playbook.inventory.get_variables(host.name)
for var in host_vars:
# TODO settle how to determine if a host needs to connect via kerberos.
# perhaps having an ansible_ssh_user containing and '@' or a '\' would be enough
# I don't know, do local user names often get created containing @ or \ ?
if ( var == 'ansible_authorization_type' and host_vars[var] == 'kerberos'):
print "Host %s is configured for windows/kerberos domain connection. Looking for ticket for %s " % (host.name, host_vars['ansible_ssh_user'])
self.find_or_acquire_ticket(host_vars['ansible_ssh_user'], host_vars['ansible_ssh_pass'])
def playbook_on_stats(self, stats):
print "On stats. Play tasks completed."
print "Removing all tickets from credential cache: %s" % (os.environ['KRB5CCNAME'])
# destroy tickets if configured to do so
#TODO make this optional based on ansible.cfg
self.destroy_all_tickets()
#end of plugin
# This plugin for ansible is intended to manage acquiring and destroying
# kerberos or windows domain credentials.
import os
import json
import subprocess
import csv
import datetime
from datetime import datetime, timedelta
from subprocess import Popen, PIPE
from ansible import errors
class CallbackModule(object):
"""
This callback module is intended to manage acquiring and disposing
of kerberos / windows domain credentials.
A new credential cache is created for each run and the contents
of the cache are destroyed when the run completes.
"""
def __init__(self):
print "Windows/Kerberos Domain-joining plugin is active."
# TODO check kerberos user package has been installed here
# and disable the plugin and warn use if so.
def days_hours_minutes(self, timedelta):
return timedelta.days, timedelta.seconds//3600, (timedelta.seconds//60)%60
def check_if_ticket_is_still_usable(self, tgt_info):
start = tgt_info['startDate'] + ' ' + tgt_info['startTime']
expires = tgt_info['expiresDate'] + ' ' + tgt_info['expiresTime']
ticket_valid_from = datetime.strptime(start, '%m/%d/%y %H:%M:%S')
ticket_valid_to = datetime.strptime(expires, '%m/%d/%y %H:%M:%S')
now = datetime.now()
ticket_aged_after= ticket_valid_to - timedelta(hours=2)
remaining_ticket_life = ticket_valid_to - now
if now > ticket_valid_to:
print "Ticket Expired"
return False
if now < ticket_valid_to and now > ticket_valid_from:
print "Ticket valid for %s days, %s hours, %s minutes" % (self.days_hours_minutes(remaining_ticket_life))
if now > ticket_aged_after:
print "Ticket is aged"
return False
return True
def check_if_ticket_is_for_this_domain(self, tgt_info, userAtDomain):
principle = tgt_info['servicePrincipal']
parts = userAtDomain.split('@', 2)
domain = parts[1].upper()
if ( principle.startswith('krbtgt') and principle.endswith(domain) ):
return True
return False
def find_or_acquire_ticket(self, userAtDomain, password):
"""
# look for a ticket for configured ansible_ssh_user / ansible_ssh_password
# todo open subprocesses (klist, kinit, kdestroy) the same way.
"""
process = subprocess.Popen(['klist'], stdout=subprocess.PIPE)
stdout, stderr = process.communicate()
stdout_lines = stdout.decode('ascii').splitlines()
if not stdout_lines:
print "No credentials cache found. Attempting to get a ticket to cache... "
self.cache_new_ticket(userAtDomain, password)
return
else:
ticket_info_line = []
ticket_info_line.append(stdout_lines[4]) # all the interesting info is on line 4
reader = csv.DictReader(ticket_info_line,
delimiter=' ', skipinitialspace=True,
fieldnames=['startDate', 'startTime',
'expiresDate', 'expiresTime', 'servicePrincipal'])
if reader:
tgt_info = reader.next()
if(self.check_if_ticket_is_for_this_domain(tgt_info, userAtDomain)):
if(self.check_if_ticket_is_still_usable(tgt_info)):
print "Ticket-granting ticket is still ok to use. Continuing..."
else:
print "Ticket-granting ticket is no longer usable, attempting to get a new one..."
self.cache_new_ticket(userAtDomain, password)
else:
print "Found a Ticket-granting ticket but not for %s. Continuing..." % (userAtDomain)
def run_kerberos_command(self, command_and_args, password):
try:
cmd = subprocess.Popen(command_and_args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
if password:
cmd.stdin.write('%s\n' % password)
cmd.wait()
except OSError:
raise errors.AnsibleError("%s is not installed in %s. Please check you have installed krb-user and can run kinit from the command line" % (command_and_args[0], command_and_args[1]))
except IOError:
raise errors.AnsibleError("could not authenticate as %s. Please check winrm group vars are correctly configured." % (userAtDomain))
def cache_new_ticket(self, userAtDomain, password):
command_and_args = ['kinit', '%s' % (userAtDomain) ]
self.run_kerberos_command(command_and_args, password)
def destroy_all_tickets(self, credential_cache):
command_and_args = ['kdestroy']
self.run_kerberos_command(command_and_args, None)
def set_ticket_cache(self):
ticket_cache = '/tmp/krb5cc_ansible-%s' % (os.getpid())
print "Caching credentials to %s" % (ticket_cache)
os.environ['KRB5CCNAME'] = ticket_cache
# the callbacks we use (on_setup and on_stats) are defined below:
def playbook_on_setup(self):
print "On setup. Checking to see if this run needs to join any windows/kerberos domains..."
self.set_ticket_cache()
hosts = self.playbook.inventory.get_hosts()
for host in hosts:
print "Considering host: %s" % (host.name)
host_vars = self.playbook.inventory.get_variables(host.name)
for var in host_vars:
# TODO settle how to determine if a host needs to connect via kerberos.
if ( var == 'ansible_authorization_type' and host_vars[var] == 'kerberos'):
print "Host %s is configured for windows/kerberos domain connection. Looking for ticket for %s " % (host.name, host_vars['ansible_ssh_user'])
self.find_or_acquire_ticket(host_vars['ansible_ssh_user'], host_vars['ansible_ssh_pass'])
def playbook_on_stats(self, stats):
print "On stats. Play tasks completed."
credential_cache = os.environ['KRB5CCNAME']
print "Removing all tickets from credential cache: %s" % (credential_cache)
# destroy tickets if configured to do so
#TODO make this optional based on ansible.cfg
self.destroy_all_tickets(credential_cache)
#end of plugin
[windows-servers:vars]
use_ticket_plugin=true
--
pip install requests python-ntlm kerberos
I set up the build described above using NTLM authentication (DOMAIN\user).
I run “whoami” register the variable and then run debug on the variable to confirm I am logged in as a domain user using NTLM credentials. When I try to run a long running process such as “setup.com /PrepareSchema” (this is the unattended setup command for installing Microsoft exchange) the playbook errors out with:
failed to exec cmd D:\setup.com /PrepareSchema
The last line in the callback is:
ReadTimeout: HTTPSConnectionPool (host='x.x.x.x', port =5986): Read timed out. (read timeout=10)
Shorter running processes will complete just fine.
Using a standard Ansible build and running the same command on the host can be completed using psexec and local credentials, but this complicates the playbook and I would prefer to use the correct credentials for the job.
Has anyone seen this or made it work in their environments?