active directory authentication with salt
2013-12-06
I typically manage two different authentication realms:
- Interal, usually Active Directory
- External, LDAP (OpenLDAP or now, OpenDJ)
So at minimum, I have two completely separate authentication mechanisms for our FreeBSD/Linux system based upon their function
Setting this all up in Salt was pretty easy so we’ll start with the pillar basics
Pillar Data
I have a few pillar values, ad-auth, ext-auth and is-public.
AD Authentication
Lets take a look at ad-auth:
ad-auth:
{% if grains.host.startswith('cifs-') or grains.host.startswith('print-') "%}
winbind: True
ldap: False
join_pass: "password"
{% else %}
winbind: False
ldap: True
ldap_server: "ad.domain.com"
ldap_base_dn: "dc=domain,dc=com"
ldap_user_dn: "ou=accounts,dc=doman,dc=com"
ldap_group_dn: "ou=accounts,dc=domain,dc=com"
ldap_bind_dn: "cn=binduser,ou=services,ou=accounts,dc=domain,dc=com"
ldap_bind_pw: "bindpassword"
{% endif %}
Its fairly simple, we use Winbind for file servers and our CUPS print server. All other internal systems use ldap bindings for AD authentication.
I found LDAP is a lot more reliable than Winbind to be honest, and, its faster
I’ve also had a lot of problems between Samba versions, and our production environment is too critical to have an authentication issue.
However, to properly use ACL’s with Samba, you need to use winbind so that is why there is the two boolean values so I can flip between LDAP and Winbind.
External LDAP
Here is the pillar data for our other LDAP server:
ext-auth:
client:
ldap_server: ext-auth.example.com
{% if grains.host.startswith('ftp-') %}
ldap_base_dn: "ou=ftp,dc=ldap,dc=example,dc=com"
ldap_user_dn: "ou=ftp,dc=ldap,dc=example,dc=com"
ldap_group_dn: "ou=ftp,dc=ldap,dc=example,dc=com"
{% else %}
ldap_base_dn:"dc=ldap,dc=example,dc=com"
ldap_user_dn: "ou=users,dc=ldap,dc=example,dc=com"
ldap_group_dn: "ou=groups,dc=ldap,dc=example,dc=com"
{% endif %}
ldap_bind_dn: "cn=binduser,ou=bind,dc=ldap,dc=example,dc=com"
ldap_bind_pw: "bindpassword"
server:
sync_role: slave
States
Kerberos State
First is the Kerberos state, which creates a ticket for our AD realms.
After the system’s krb5.conf file has been created, we need to create a ticket before we can use Samba to join the domain.
/etc/krb5.conf:
file.managed:
- source: salt://kerberos/krb5.conf.jinja
- template: jinja
kerberos-kinit:
cmd:
- cwd: /root
- names:
- echo {{ pillar['ad-auth']['join_pass'] }} | kinit --password-file=STDIN joiner
- run
- require:
- service: ntpd
- file: /etc/krb5.conf
- watch:
- file: /etc/krb5.conf
Here is our krb5.conf.jinja template:
[libdefaults]
default_realm = {{ grains.realm }}
dns_lookup_realm = false
dns_lookup_kdc = false
ticket_lifetime = 24h
forwardable = yes
default_tgs_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96 RC4-HMAC DES-CBC-CRC DES-CBC-MD5
default_tkt_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96 RC4-HMAC DES-CBC-CRC DES-CBC-MD5
preferred_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96 RC4-HMAC DES-CBC-CRC DES-CBC-MD5
[realms]
CORP.DOMAIN.COM = {
kdc = dc-1.corp.domain.com
kdc = dc-2.corp.domain.com
}
DOMAIN.LOCAL = {
kdc = dc-1.domain.local
}
[domain_relay]
.corp.domain.com = CORP.DOMAIN.COM
.domain.local = DOMAIN.LOCAL
[appdefaults]
pam = {
debug = false
ticket_lifetime = 36000
renew_lifetime = 36000
forwardable = true
krb4_convert = false
}
PAM and nsswitch
The PAM stack and nsswitch.conf files are the only files that overlap between Winbind and LDAP authentication
First, PAM:
/etc/pam.d/system:
file.managed:
- source: salt://pam/{{ grains.os_family }}/system.jinja
- template: jinja
- user: root
- mode: 644
/etc/pam.d/login:
file.managed:
- source: salt://pam/{{ grains.os_family }}/login.jinja
- template: jinja
- user: root
- mode: 644
- require:
- pkg: pam_mkhomedir
/etc/pam.d/sshd:
file.managed:
- source: salt://pam/{{ grains.os_family }}/sshd.jinja
- template: jinja
- user: root
- mode: 644
The actual pam files are split up between the OS type, as FreeBSD and Linux use slightly different implementations of PAM, and this way, I fight a little less with freebsd-update when it wants to update the systems pam.d/ files
I won’t show them all, just one, pam/sshd
# auth
auth sufficient pam_opie.so no_warn no_fake_prompts
auth requisite pam_opieaccess.so no_warn allow_local
{% if pillar['is_public'] or pillar['ad-auth']['ldap'] -%}
auth sufficient /usr/local/lib/pam_ldap.so no_warn try_first_pass
{% else -%}
auth sufficient pam_krb5.so no_warn try_first_pass
{%- endif %}
auth required pam_unix.so no_warn try_first_pass
# account
account required pam_nologin.so
account required pam_login_access.so
{% if pillar['is_public'] or pillar['ad-auth']['ldap'] -%}
account sufficient /usr/local/lib/pam_ldap.so no_warn ignore_authinfo_unavail ignore_unknown_user
{% else -%}
account sufficient /usr/local/lib/pam_winbind.so
{%- endif %}
account required pam_unix.so
# session
{% if pillar['ad-auth']['winbind'] -%}
session sufficient /usr/local/lib/pam_winbind.so
{%- endif %}
session required /usr/local/lib/pam_mkhomedir.so umask=0700
session required pam_permit.so
# password
{% if pillar['ad-auth']['winbind'] -%}
password sufficient /usr/local/lib/pam_winbind.so try_first_pass
{%- endif %}
password required pam_unix.so no_warn try_first_pass
Here is the nsswitch state and template, which is very similar to the PAM state:
/etc/nsswitch.conf:
file.managed:
- source: salt://nsswitch/{{ grains.os_family }}/nsswitch.conf.jinja
- template: jinja
- require:
{% if pillar['is_public'] %}
- pkg: openldap-client
- pkg: nss_ldap
- pkg: pam_ldap
- file: nss_ldap.conf
{% elif pillar['ad-auth']['ldap'] %}
- pkg: openldap-client
- pkg: nss_ldap
- pkg: pam_ldap
- file: nss_ldap.conf
{% else %}
- pkg: samba
{% endif %}
and the template:
{% if pillar['is_public'] -%}
group: files ldap [UNAVAIL=return]
{% elif pillar['ad-auth']['ldap'] -%}
group: files ldap [UNAVAIL=return]
{% elif pillar['ad-auth']['winbind'] -%}
group: files winbind
{%- endif %}
group_compat: files
hosts: files dns
networks: files
{% if pillar['is_public'] -%}
passwd: files ldap [UNAVAIL=return]
{% elif pillar['ad-auth']['ldap'] -%}
passwd: files ldap [UNAVAIL=return]
{% elif pillar['ad-auth']['winbind'] -%}
passwd: files winbind
{%- endif %}
passwd_compat: files
shells: files
services: compat
services_compat: files
protocols: files
rpc: files
Samba State
The Samba state creates a smb.conf file, and joins the system to the domain once, which is always the trickey part
There are a few things I do with Samba that need explanation.
Since I support BSD and Linux, I created a simple pillar called “etc_prefix”, which based on the os Grain, will set the value to “/etc/” (for Linux) and “/usr/local/etc/” for FreeBSD. This pillar value comes in handy for nearly every 3rd party application that gets installed.
I use another pillar value called “smb_hosts_allowed” which is a dictionary of networks that we allow access to our cifs shares.
Finally, each time salt is ran, I re-build a smb.conf.local file, which is based on the contents in $etc_prefix/smb.conf.d/
I keep the actual shares as separate files in smb.conf.d/
To me, this is preferred. Each system has the exact same smb.conf, which is under strict management, but the indivudual server shares can be controlled either locally, or by another salt state that determines the systems shares and role.
include:
- pam
- nsswitch
samba:
pkg:
- installed
- name: {{ pillar['samba'] }}
service:
- running
- enable: True
- watch:
- file: smb.conf
{% if grains['os'] == 'FreeBSD' %}
/etc/rc.conf.d/winbindd:
file.managed:
- source: salt://samba/winbindd
/etc/rc.conf.d/nmbd:
file.managed:
- source: salt://samba/nmbd
{% endif %}
smb.conf:
file.managed:
- name: {{ pillar['etc_prefix'] }}/smb.conf
{% if pillar['is_public'] %}
- source: salt://samba/smb.conf.standalone.jinja
{% else %}
- source: salt://samba/smb.conf.jinja
{% endif %}
- template: jinja
- context:
workgroup: {{ grains.realm.split('.')[0] }}
realm: {{ grains.realm }}
etc_prefix: {{ pillar['etc_prefix'] }}
smb_hosts_allowed: {{ pillar['smb_hosts_allowed'] }}
- require:
- pkg: samba
- service: ntpd
smb.conf.d:
file.directory:
- name: {{ pillar['etc_prefix'] }}/smb.conf.d
- user: root
create-smb-conf-local:
cmd:
- run
- names:
- cat {{ pillar.etc_prefix }}/smb.conf.d/* > {{ pillar.etc_prefix }}/smb.conf.local
net-ads-join:
cmd:
- run
- names:
- net ads join osVer={{ grains.kernelrelease }} osName={{ grains.kernel }} createcomputer=Servers/UNIX -U joiner@{{ grains.realm }}%{{ pillar['ad-auth']['joiner_pass'] }}
- echo 'is_joined' > /tmp/.ad
- unless: cat /tmp/.ad
- require:
- file: smb.conf
- pkg: samba
[global]
workgroup = {{ workgroup }}
server string = Samba Server
security = ads
{% if 'smb_hosts_allowed' in pillar -%}
hosts allow ={{ pillar.smb_hosts_allowed|join(' ') }}
{%- endif %}
log file = /var/log/samba/log.%m
max log size = 50
realm = {{ grains.realm }}
local master = no
domain master = no
wins server = dc-1.{{ grains.domain }}
dns proxy = no
winbind enum users = yes
winbind enum groups = yes
winbind nss info = rfc2307
winbind use default domain = yes
winbind refresh tickets = true
winbind nested groups = yes
winbind max domain connections = 100
client ntlmv2 auth = yes
client use spnego = yes
template shell = /bin/zsh
template homedir = /home/%D/%U
idmap config *:backend = tdb
idmap config *:range = 10001-30000
idmap config DISCDRIVE: range = 20001-30000
idmap config DISCDRIVE: backend = rid
idmap config DISCDRIVE : base_rid = 0
idmap config BAYPHOTO: range = 10000-20000
idmap config BAYPHOTO: backend = rid
idmap config BAYPHOTO : base_rid = 0
admin users="@DISCDRIVE\Domain Admins"
write list ="@DISCDRIVE\Domain Users"
include={{ etc_prefix }}/smb.conf.local
LDAP Client State
The only difference at this point between Samba + Winbind and LDAP, is managing the ldap.conf. Everything else was already handled above (nsswitch.conf and pam.d/ files) and will use ldap/pam_ldap.so instead of winbind/pam_winbind.so
pam_ldap:
pkg.installed
nss_ldap:
pkg.installed
ldap.conf:
file:
- name: {{ pillar['etc_prefix'] }}/openldap/ldap.conf
- managed
{% if pillar['is_public'] %}
- source: salt://ldap/ext-auth.ldap.conf.jinja
{% elif pillar['ad-auth']['ldap'] %}
- source: salt://ldap/ad.ldap.conf.jinja
{% endif %}
- template: jinja
- context:
{% if pillar['is_public'] %}
ldap_server: {{ pillar['ext-auth']['ldap_server'] }}
ldap_bind_dn: {{ pillar['ext-auth']['ldap_bind_dn'] }}
ldap_base_dn: {{ pillar['ext-auth']['ldap_base_dn'] }}
ldap_bind_pw: {{ pillar['ext-auth']['ldap_bind_pw'] }}
ldap_group_dn: {{ pillar['ext-auth']['ldap_group_dn'] }}
ldap_user_dn: {{ pillar['ext-auth']['ldap_user_dn'] }}
{% elif pillar['ad-auth']['ldap'] %}
ldap_server: {{ pillar['ad-auth']['ldap_server'] }}
ldap_bind_dn: {{ pillar['ad-auth']['ldap_bind_dn'] }}
ldap_base_dn: {{ pillar['ad-auth']['ldap_base_dn'] }}
ldap_bind_pw: {{ pillar['ad-auth']['ldap_bind_pw']}}
ldap_group_dn: {{ pillar['ad-auth']['ldap_group_dn'] }}
ldap_user_dn: {{ pillar['ad-auth']['ldap_user_dn'] }}
{% endif %}
ldap.conf:
file:
- name: {{ pillar['etc_prefix'] }}/ldap.conf
- managed
{% if pillar['is_public'] %}
- source: salt://ldap/ext-auth.ldap.conf.jinja
{% elif pillar['ad-auth']['ldap'] %}
- source: salt://ldap/ad.ldap.conf.jinja
{% endif %}
- template: jinja
- context:
{% if pillar['is_public'] %}
ldap_server: {{ pillar['ext-auth']['ldap_server'] }}
ldap_bind_dn: {{ pillar['ext-auth']['ldap_bind_dn'] }}
ldap_base_dn: {{ pillar['ext-auth']['ldap_base_dn'] }}
ldap_bind_pw: {{ pillar['ext-auth']['ldap_bind_pw'] }}
ldap_group_dn: {{ pillar['ext-auth']['ldap_group_dn'] }}
ldap_user_dn: {{ pillar['ext-auth']['ldap_user_dn'] }}
{% elif pillar['ad-auth']['ldap'] %}
ldap_server: {{ pillar['ad-auth']['ldap_server'] }}
ldap_bind_dn: {{ pillar['ad-auth']['ldap_bind_dn'] }}
ldap_base_dn: {{ pillar['ad-auth']['ldap_base_dn'] }}
ldap_bind_pw: {{ pillar['ad-auth']['ldap_bind_pw'] }}
ldap_group_dn: {{ pillar['ad-auth']['ldap_group_dn'] }}
ldap_user_dn: {{ pillar['ad-auth']['ldap_user_dn'] }}
{% endif %}
nss_ldap.conf:
file:
- name: {{ pillar['etc_prefix'] }}/nss_ldap.conf
- managed
{% if pillar['is_public'] %}
- source: salt://ldap/ext-auth.ldap.conf.jinja
{% elif pillar['ad-auth']['ldap'] %}
- source: salt://ldap/ad.ldap.conf.jinja
{% endif %}
- template: jinja
- context:
{% if pillar['is_public'] %}
ldap_server: {{"{{ pillar['ext-auth']['ldap_server'] }}
ldap_bind_dn: {{"{{ pillar['ext-auth']['ldap_bind_dn'] }}
ldap_base_dn: {{"{{ pillar['ext-auth']['ldap_base_dn'] }}
ldap_bind_pw: {{"{{ pillar['ext-auth']['ldap_bind_pw'] }}
ldap_group_dn: {{"{{ pillar['ext-auth']['ldap_group_dn'] }}
ldap_user_dn: {{"{{ pillar['ext-auth']['ldap_user_dn'] }}
{% elif pillar['ad-auth']['ldap'] %}
ldap_server: {{"{{ pillar['ad-auth']['ldap_server'] }}
ldap_bind_dn: {{"{{ pillar['ad-auth']['ldap_bind_dn'] }}
ldap_base_dn: {{"{{ pillar['ad-auth']['ldap_base_dn'] }}
ldap_bind_pw: {{"{{ pillar['ad-auth']['ldap_bind_pw'] }}
ldap_group_dn: {{"{{ pillar['ad-auth']['ldap_group_dn'] }}
ldap_user_dn: {{"{{ pillar['ad-auth']['ldap_user_dn'] }}
{% endif %}
/etc/ssl/private/gd_bundle.crt:
file:
- managed
- source: salt://ldap/gd_bundle.crt
ldap.conf.jinja template finally looks like this:
URI ldaps://{{ ldap_server }}
tls_cacertdir /etc/ssl/private
tls_cacert /etc/ssl/private/gd_bundle.crt
base {{ ldap_base_dn }}
binddn {{ ldap_bind_dn }}
bindpw {{ ldap_bind_pw }}
timelimit 3
bind_timelimit 3
bind_policy soft
pam_check_host_attr no
nss_paged_results yes
pagesize 1000
nss_base_passwd {{ ldap_user_dn }}?sub
nss_base_shadow {{ ldap_user_dn }}?sub
nss_base_group {{ ldap_group_dn }}?sub
# How passwords are stored in OpenLDAP
{% if pillar['is_public'] %}
pam_password exop
{% elif pillar['ad-auth']['ldap'] %}
pam_password ad
{% endif %}
#
# Logging config. Note that debug 255 is commented out initially, this will create *A LOT* of log messages when enabled!!
#logdir /var/log/nss_ldap
#debug 4
# Enable SSL communications.
ssl on
# OpenLDAP SSL options
# Require and verify server certificate (yes/no)
# Default is to use libldap's default behavior, which can be configured in
# /etc/openldap/ldap.conf using the TLS_REQCERT setting. The default for
# OpenLDAP 2.0 and earlier is "no", for 2.1 and later is "yes".
tls_checkpeer no
# For Gentoo's distribution of nss_ldap, as of 250-r1, we use these values
# (The hardwired constants in the code are changed to them as well):
nss_reconnect_tries 4 # number of times to double the sleep time
nss_reconnect_sleeptime 1 # initial sleep value
nss_reconnect_maxsleeptime 16 # max sleep value to cap at
nss_reconnect_maxconntries 2 # how many tries before sleeping
#
{% if pillar['ad-auth']['ldap'] %}
# AD Mappings (RFC 2307)
nss_map_objectclass posixAccount user
nss_map_objectclass shadowAccount user
nss_map_attribute uid sAMAccountName
nss_map_attribute homeDirectory unixHomeDirectory
nss_map_attribute shadowLastChange pwdLastSet
nss_map_objectclass posixGroup group
nss_map_attribute uniqueMember member
pam_login_attribute sAMAccountName
pam_filter objectclass=User
{% endif %}