published on

active directory authentication with salt

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 %}