This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Access Control

Overview

Network Access Control (NAC) is usually done with the 802.1X Network standard and a RADIUS server. At a base level, users identify themselves and are granted or denied access on an individual basis.

Role Based Access Control (RBAC) uses the same setup, but takes it a step further. It examines user attributes, such as job title, physical location, time of day, etc, to decide if they can have access, and even what network to put them on.

Windows and Mac PCs support this over physical ethernet with little or no configuration. But a more frequent use is with Enterprise WiFi, where you supply both a login name or email address and a password.

Implementing with Open Source Tools

In our example we’ll use Linux with FreeRADIUS and OpenLDAP. Importantly, we’ll use the MSCHAPv2 password hash so that the user’s plaintext passwords don’t need to be stored and it directly integrates with Windows/Mac/Phones.

Install and configure as described in the following in order.

1 - FreeRADIUS

RADIUS is an Authentication and Authorization protocol and FreeRADIUS is the most widely deployed server. Others are quite good, but FreeRADIUS is, well, free.

Let’s install on Debian and test the EAP-PEAP protocols with MSCHAPv2 hashed passwords so we can integrate easily with Win/Mac. We’ll also include LDAP extensions for later.

Install

# Start with a clean slate
apt remove --purge freeradius* 
rm -rf /etc/freeradius/

apt install freeradius freeradius-ldap

Edit the Users File

The users file is simply a config file that FreeRADIUS processes when it starts up. You can add users directly to it and they’ll be processed accordingly.

vim /etc/freeradius/3.0/users

# Uncomment the line below
bob     Cleartext-Password := "hello"

Start The Service and Test Locally

systemctl stop freeradius.service

/etc/init.d/freeradius debug

# In another terminal on the server, test that you can authenticate
radtest -t mschap bob hello localhost 0 testing123

You’re looking for the message Received Access-Accept. Take a look at the debug in the server console if you need to dig into errors.

Hashed Password Testing

We prefer not store plain-text passwords, so let’s use the smbcrypt utility to generate a hash and store that instead. The RADIUS server will simply use that directly rather than hashing the plain-text password it has stored now.

smbencrypt hello

# a quick way to extract just the nt hash
smbencrypt hello 2> /dev/null  | tail -1 | awk '{print $2}'

# Change the bob entry
vim /etc/freeradius/3.0/users

bob     NT-Password := "066DDFD4EF0E9CD7C256FE77191EF43C"

# Test the same as above
radtest -t mschap bob hello localhost 0 testing123

Client Testing

The above testing worked because the localhost is allowed to connect by default. To use this with an actual access point and client (authenticator and supplicant), we’ll need to create a ‘site’ by creating a file with the AP details and the server process that will talk to them.

Add a RADIUS Client

You need access points that understand 802.1x. In our example we have some Unifi Access Points on a 10. network that can reach the RADIUS server.

Create a new ‘site’ named ‘wifi’ that describes our network.

vim /etc/freeradius/3.0/sites-available/wifi
#
# /etc/freeradius/3.0/sites-available/wifi
#

client UniFi-APs {
    shortname = wifi
    virtual_server = wifi
    secret = someBigLongRandomString
    # allow client connections from the 10.* range
    ipaddr  = 10.0.0.0/8
}
server wifi {
    authorize {
        # cleans up attributes, required
        preprocess
        # we use eap authentication, required
        eap 
    }
    authenticate {
        # mschap authentication
        Auth-Type MS-CHAP {
            mschap
        }
        # eap, this is required
        eap
    }
}

Enable and Test

ln -s /etc/freeradius/3.0/sites-available/wifi /etc/freeradius/3.0/sites-enabled/wifi

systemctl stop freeradius.service

/etc/init.d/freeradius debug

# If you have a workstation in the 10. space you can test this before jumping to a WiFi supplicant
radtest -t mschap bob hello someServer 0 someBigLongRandomString

Support Realms

Sometimes, users will enter their email address as their login - i.e. [email protected]. This is pretty normal these days, but you’ll need to tell RADIUS about it.

vi /etc/freeradius/3.0/proxy.conf

# at the bottom, add

realm example.org {
}

Next Steps

Configure your access point and you should be good - as long as you’re Bob and don’t mind editing text files. A better approach is a user database, like OpenLDAP

OpenLDAP

2 - OpenLDAP

LDAP is preferred when dealing with Identity Management and while there are many alternatives to OpenLDAP, it is widely used and performs well.

Install

# Start With a Clean Slate
apt remove --yes --purge slapd ldap-utils
rm -rf /var/backups/slapd*

# For a non-interactive install, supply a root password and domain to debconf before starting ap-get
WORD=somePass
DOMAIN=example.org

echo "slapd slapd/root_password password $PASSWORD" | debconf-set-selections &&\
echo "slapd slapd/root_password_again password $PASSWORD" | debconf-set-selections && \
echo "slapd slapd/domain string $DOMAIN" | debconf-set-selections && \
echo "slapd shared/organization string $DOMAIN" | debconf-set-selections && \
DEBIAN_FRONTEND=noninteractive apt-get install -y slapd ldap-utils

Configure

Create Sample LDAP Data

Create a text file that describes how we want to organize people, and a user ‘Bob. Notice that we add use the output of smbencrypt hello to the userPassword field, but prepend it with ‘{nthash}’. That’s a standard flag so FreeRADIUS and LDAP know it’s already hashed. It also prevents users from (easily) logging into OpenLDAP as it can’t use that hash itself.

vi people.ldif
dn: ou=people,dc=example,dc=org
objectClass: organizationalUnit
ou: people 
vi bob.ldif
dn: cn=bob,ou=people,dc=example,dc=org
objectClass: person
cn: bob
sn: roberts
description: staff
userPassword: {nthash}066DDFD4EF0E9CD7C256FE77191EF43C

Now we’ll add the people organizational unit and the user bob.

ldapadd -x -W -D cn=admin,dc=example,dc=org -f people.ldif
ldapadd -x -W -D cn=admin,dc=example,dc=org -f bob.ldif

ldapsearch -x -LLL -b dc=example,dc=org

When you view your additions the userPassword field may be different than expected. That’s because it’s base64 encoded. You can decode it with echo (contents) | base64 --decode if you need to check.

Note: If you get the LDAP error ldap_bind: Invalid credentials (49) refer below to reset the root password.

Configure FreeRADIUS to search LDAP

We added the FreeRADIUS LDAP extension when we installed FreeRADIUS so all we need to do is make a couple edits.

vim /etc/freeradius/3.0/mods-available/ldap

# At the top, add the admin account credentials from LDAP and org with your own details

  identity = 'cn=admin,dc=example,dc=org'
  password = somePass
  base_dn = 'ou=people,dc=example,dc=org'

# Further down in the 'update' section, comment out the control, request and reply attributes, and add your own reply that returns the LDAP
# attribute 'description' as the RADIUS attribute 'User-Category'

  #control:             += 'radiusControlAttribute'
  #request:             += 'radiusRequestAttribute'
  #reply:               += 'radiusReplyAttribute'
  reply:User-Category   := 'description'

# In the 'user' section change the filter from `cn=` to `uid=` if it's not already

    filter = "(uid=%{%{Stripped-User-Name}:-%{User-Name}})"

Enable LDAP and Test

# Enable to mod similar to before
ln -s /etc/freeradius/3.0/mods-available/ldap /etc/freeradius/3.0/mods-enabled/ldap

# Restart in debug and test the login
/etc/init.d/freeradius debug
radtest -t mschap bob hello localhost 0 testing123

Once again, you should see the Received Access-Accept message.

Next Steps

At this point, you have a working FreeRADIUS with an OpenLDAP back-end. All you need to do is import your users and you’re ready for production…well, you should secure it first.

Encrypt FreeRADIUS and OpenLDAP With Let’s Encrypt

3 - Encrypt

Clients expect to see a certificate when connecting. The default Debian Snakeoil certificate causes some clients to show a red warning message (as it should).

Back in the bad old days, you had to purchase a ‘Unified Communication’ cert at a premium. But now you can use the free Let’s Encrypt project. It includes the EKU (Extended Key Usage) attribute “Server Authentication” which is required.

It can secure the inter-server communication channel as well.

Get the Certificate

There are a couple ways to prove yourself to Let’s Encrypt, but the simplest is let them connect to you at the DNS name you are requesting.

Configure a Reverse Proxy

Create the public DNS CNAME “wifi.example.org” for your hopefully-already-existing reverse proxy server. Forward port 80 to your RADIUS server for later use.

# HAProxy Example
frontend http
    bind *:80
    mode http

    acl wifi hdr(host) wifi.example.org
    use_backend wifi if wifi

backend wifi
    mode http
    server wifi radius.private.lan:80


# Caddy Example
http://wifi.example.org {
        import logging
        reverse_proxy * radius.private.lan
}

Install Certbot

The EFF makes a handy utility called certbot that will do the hard work for us. Though one hesitates to allow the internet in, certbot only listens for a few seconds every two months.

apt install certbot

certbot certonly --standalone -d wifi.example.org

ls /etc/letsencrypt/live/wifi.example.org/

Set Permissions

The cert files start as root-only but any changes are preserved.

# Create a certs group (if it doesn't already exist) and add the processes to it.
sudo addgroup certs
sudo chgrp -R certs /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chmod 750 /etc/letsencrypt/live /etc/letsencrypt/archive

Configure FreeRADIUS

Add Group to FreeRADIUS

Grant the freerad process access to the certs

sudo adduser freerad certs

Change the EAP config

vi /etc/freeradius/3.0/mods-available/eap
...
...
        tls-config tls-common {
                private_key_file = /etc/letsencrypt/live/wifi.example.org/privkey.pem
                #private_key_file = /etc/ssl/private/ssl-cert-snakeoil.key
                ...
                ...
                certificate_file = /etc/letsencrypt/live/wifi.example.org/fullchain.pem
                #certificate_file = /etc/ssl/certs/ssl-cert-snakeoil.pem
systemctl restart freeradius

Test with a client

The best way to test is to grab an IOS device or Apple Laptop. These will display the Cert when you connect.

Add a RADIUS Trigger

FreeRADIUS won’t know when a cert gets updated, so you’ll need to trigger a restart (a reload isn’t enough) with a certbot hook.

sudo touch /etc/letsencrypt/renewal-hooks/post/radius
sudo chmod +x /etc/letsencrypt/renewal-hooks/post/radius
sudo vi /etc/letsencrypt/renewal-hooks/post/radius
#!/bin/sh
logger certbot renewel triggered restart of freerad
systemctl restart freeradius.service

Configure LDAP

In this example everything is running on one host. But if the data was traversing the network you’d encrypt it. The modern way is to enable and enforce TLS using the certificate we just got. In fact, RFC4513 says servers should disallow the use of passwords when TLS is not in use, so let’s get on this.

Configure SLAPD

First, let’s grant OpenLDAP access to the key.

sudo adduser openldap certs

Then, tell slapd about the cert by creating a configuration file in LDIF format and loading it.

vi cert.ldif
dn: cn=config
changetype: modify
replace: olcTLSCertificateKeyFile
olcTLSCertificateKeyFile: /etc/letsencrypt/live/wifi.example.org/privkey.pem
-
replace: olcTLSCertificateFile
olcTLSCertificateFile: /etc/letsencrypt/live/wifi.example.org/cert.pem
ldapmodify -Y EXTERNAL -H ldapi:/// -f cert.ldif

Test TLS is working with the ZZ option. (A single Z says means continue if negotiation fails, ZZ means stop if failed). Also use LDAPTLS_REQCERT=never. This allows us to verify that a cert is in place (ZZ) but don’t bother testing the validity (never).

# Use 'LDAPTLS_REQCERT=never' to trust any cert we get, but don't check the chain root yet
LDAPTLS_REQCERT=never ldapsearch -x -LLL -W -ZZ -D cn=admin,dc=example,dc=org -b dc=example,dc=org

There’s also the gold standard, openssl.

openssl s_client -starttls ldap -connect localhost:389

# Or a one-liner to decode as well
echo | openssl s_client -starttls ldap -connect localhost:389 | openssl x509 -text

Add a Hook

Just like before, we need to tel slapd about the new cert.

sudo touch /etc/letsencrypt/renewal-hooks/post/ldap
sudo chmod +x /etc/letsencrypt/renewal-hooks/post/ldap
sudo vi /etc/letsencrypt/renewal-hooks/post/ldap
#!/bin/bash
logger certbot renewel triggered restart of openldap
systemctl restart slapd.service

Join Them Together

RADIUS TLS

We can now tell RADIUS to use TLS with LDAP. Use the ‘require_cert’ attribute to ignore the trust chain until later.

vi /etc/freeradius/3.0/mods-available/ldap
        tls {
                ...
                ...
               start_tls = yes
               ...
               ...
               require_cert    = 'never'
systemctl restart freeradius
# You can start in dubug mode like before to troubleshoot if needed

Next Steps

You may have noticed that are using certs now, but the processes themselves don’t care if they are valid. Just that we have one. We also haven’t done much with LDAP security. Let’s do that next.

secure

4 - Secure

Let’s take a few steps to block casual access, stop using the admin account, and validate the certificates we’re using.

LDAP

Require Passwords

By default, the LDAP server allows anonymous connections to read all the user entry data. Let’s prevent that by disabling anonymous binds.

Before we make any changes, this anonymous bind and search will return all the entries.

ldapsearch -x -LLL -b dc=example,dc=org

Let’s create a LDIF config file to disable that, load it and test again

vi disable_anon_bind.ldif
dn: cn=config
changetype: modify
add: olcDisallows
olcDisallows: bind_anon
# Modify the config
ldapmodify -H ldapi:/// -Y EXTERNAL -f disable_anon_bind.ldif

# An anonymous bind will now fail
ldapsearch -x -LLL -b dc=example,dc=org

# An authenticated search will succeed
ldapsearch -x -LLL -W -D cn=admin,dc=example,dc=org -b dc=example,dc=org

Require Encryption

OpenLDAP accepts TLS connections, but it doesn’t require it. Now that we’re requiring passwords we should also require encryption. NOTE make sure systems talking to your LDAP server can use TLS and ignore/trust the Let’s Encrypt trust chain.

vi require-tls.ldif
dn: olcDatabase={1}mdb,cn=config
changetype: modify
add: olcSecurity
olcSecurity: tls=1
ldapmodify -H ldapi:// -Y EXTERNAL -f require-tls.ldif

See if it requires encryption.

# Test without -ZZ for no TLS
ldapsearch -x -LLL -W  -D cn=admin,dc=example,dc=org -b dc=example,dc=org -h wifi.example.org
Enter LDAP Password:
ldap_bind: Confidentiality required (13)
        additional info: TLS confidentiality required

Create Service Accounts

Services should have their own accounts, so let’s create one for FreeRADIUS and a theoretical Identity Management Service. These accounts bind directly so we’ll set their passwords traditionally.

vi service_accts.ldif

dn: cn=FreeRADIUS,dc=example,dc=org
objectClass: person
cn: FreeRADIUS
sn: Server
description: service

dn: cn=IDM,dc=example,dc=org
objectClass: person
cn: IDM
sn: Server
description: service

ldapadd -x -W -D cn=admin,dc=example,dc=org -f service_accts.ldif

ldappasswd -x -W -D cn=admin,dc=example,dc=org -S cn=FreeRADIUS,dc=example,dc=org
ldappasswd -x -W -D cn=admin,dc=example,dc=org -S cn=IDM,dc=example,dc=org 

Add ACLs

The password attribute is protected by default. The FreeRADIUS account must read and the IDM account write it, so lets replace the ACL on that attribute.

vi FreeRADIUS-IDM-password.ldif
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by dn="cn=FreeRADIUS,dc=example,dc=org" read by dn="cn=IDM,dc=example,dc=org" write by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to * by * read

The IDM account also gets the ability to manage the whole folder, so as to add and delete people.

vi IDM-Access.ldif
dn: olcDatabase={1}mdb,cn=config
changetype: modify
add: olcAccess
olcAccess: {2}to dn.exact="ou=people,dc=example,dc=org" attrs=children by dn="cn=IDM,dc=example,dc=org" manage
olcAccess: {3}to dn.children="ou=people,dc=example,dc=org" by dn="cn=IDM,dc=example,dc=org" manage
ldapmodify -Y EXTERNAL -H ldapi:/// -f FreeRADIUS-IDM-password.ldif
ldapmodify -Y EXTERNAL -H ldapi:/// -f idm-access.ldif

The FreeRADIUS user should now be able to query a user and see their password value

ldapsearch -x -LLL -W -D cn=FreeRADIUS,dc=example,dc=org -b dc=example,dc=org

...
userPassword:: .....

If you mess something up and need to reset ACLs, or understand more about the rules, see the miscellany.

RADIUS

Use New Account

Now that you’ve created an account for FreeRADIUS, you should go back and update it’s connection and test that it still works.

vi /etc/freeradius/3.0/mods-available/ldap

        identity = 'cn=FreeRADIUS,dc=example,dc=org'
        password = somePasswordYouSet

Validate Certs

The server processes themselves are not validating the certs and that’s bad form. We’ve been skipping validation because in the past it was a hurdle. But Debian 12 and later include the ISRG Root X1 that Let’s Encrypt uses. Let’s test that now.

# And test with the CA option. 
# You need to make sure the host name matches 
echo 127.0.1.1  wifi.example.org >> /etc/hosts

ldapsearch -x -LLL -W -ZZ -D cn=admin,dc=example,dc=org -b dc=example,dc=org -h wifi.example.org

If this works as expected, you can attempt setting validate in the RADIUS settings.

vi /etc/freeradius/3.0/mods-available/ldap

                start_tls       = yes
                ca_file         = /etc/ssl/certs/ca-certificates.crt
                require_cert    = 'demand'

If these don’t work, you can compile a trust chain as described in the miscellany.

Troubleshooting

LDAP Error Messages

main: TLS init def ctx failed: -1

slapd can’t read the cert. Check the groups and unix permissions.

ldap_start_tls: Connect error (-11)

Check that the file you are specifying in ldapsearch, such LDAPTLS_CACERT=/root/r3-x1.pem, is actually available. Make sure you are using the -h and that the host you are connecting to matches the CN in the certificate. If it’s different, you’ll need to use a hosts file entry to trick the client

Nothing useful in logs

You can increase the log level from ‘stats’ to ‘args` with an ldapmodify. Just make sure to put it back when you’re done.

vi loglevel.ldif
dn: cn=config
changetype:modify
replace: olcLoglevel
#olcLoglevel: stats
olcLoglevel: args
ldapmodify -Y EXTERNAL -H ldapi:/// -b cn=config -D cn=config -s base -LLL -W -f loglevel.ldif

Next Steps

You’ve done a lot of work to get to this point where you can finally apply some Role Based Access Controls with Dynamic VLAN assignment.

Dynamic VLAN Assignment

5 - VLAN Assign

IEEE 802.1X VLAN Assignment or Role Based Access Control is what it’s often called. It’s the ability to put people on different VLANs based on any imaginable criteria; time of day, type of computer, location connecting from, etc.

But it’s most often based on a role.

LDAP Data

When we created users we added a description attribute, and then passed it to RADIUS as the standard User-Category attribute. Let’s use that to assign a VLAN.

Add VLAN Logic

This is a simple example that uses the User-Category attribute.

vim /etc/freeradius/3.0/sites-available/wifi
# After the `authenticate` section add a `post-auth` block
   ...
        ...
        authenticate {
                ...
                ...
        }
        post-auth {
                update reply {
                        &Tunnel-Type = 13,
                        &Tunnel-Medium-Type = 6
                }
                if (reply:User-Category  == "staff")     {
                        update reply { &Tunnel-Private-Group-Id = "1020" }
                }
                elsif (reply:User-Category == "student") {
                        update reply { &Tunnel-Private-Group-Id = "1021" }
                }
                else                                     {
                        update reply { &Tunnel-Private-Group-Id = "1022" }
                }

        }

Make sure to edit the eap file as noted here. It’s a common gotcha.

vim /etc/freeradius/3.0/mods-available/eap
use_tunneled_reply = yes

Configure APs

You must configure your AP as per your vendor’s docs. For Unifi, it means you create a RADIUS profile that with those check-boxes enabled, then create a WiFi network that uses that profile.

https://help.ui.com/hc/en-us/articles/360015268353#6

6 - Reporting

You are often asked when the last time or location a user logged in. You can get that from the RADIUS logfile, or from implementing something like the ElasticStack.

Local Log

You can create a quick script to check for user IDs and MAC addresses in the local log files.

vi search-netid 
#!/bin/bash

[ $1 ] || { echo "enter search user ID"; exit 1;}

ID=$1

echo "Recent Logins: "
grep ") Login OK"  /var/log/freeradius/radius.log /var/log/freeradius/radius.log.1 |  grep $ID | sed 's/.*cli \(.*\)).*/\1/' | sort | uniq | tr '-' ':'| tr '[:upper:]' '[:lower:]'
echo

echo "Older Logins: "
zgrep ") Login OK"  /var/log/freeradius/radius.log*.gz |  grep $ID | sed 's/.*cli \(.*\)).*/\1/' | sort | uniq | tr '-' ':'| tr '[:upper:]' '[:lower:]'

Elastic Integration

Update as needed for modern versions of Elastic.

wget https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-7.6.2-amd64.deb
sudo apt install ./filebeat-7.6.2-amd64.deb 

sudo vi /etc/filebeat/filebeat.yml
filebeat.inputs:
  - type: log
    paths:
      - /var/log/freeradius/radius.log
    include_lines: ['\) Login OK','incorrect']
    tags: ["radius"]
processors:
  - drop_event:
      when:
        contains:
          message: "previously"
  - if:
      contains:
        message: "Login OK"
    then: 
      - dissect:
          tokenizer: "%{key1} [%{source.user.id}/%{key3}cli %{source.mac})"
          target_prefix: ""
      - drop_fields:
          fields: ["key1","key3"]
      - script:
          lang: javascript
          source: >
            function process(event) {
                var mac = event.Get("source.mac");
                if(mac != null) {
                        mac = mac.toLowerCase();
                         mac = mac.replace(/-/g,":");
                         event.Put("source.mac", mac);
                }
              }
    else:
      - dissect:
          tokenizer: "%{key1} [%{source.user.id}/<via %{key3}"
          target_prefix: ""
      - drop_fields: 
          fields: ["key1","key3"]
output.elasticsearch:
  hosts: ["http://some.server:9200"]        
  allow_older_versions: true
  setup.ilm.enabled: false


#output.file:
  #path: "/tmp/filebeat"
  #filename: filebeat
  #rotate_every_kb: 10000
  #number_of_files: 7
  #permissions: 0600

7 - Miscellany

RADIUS

User File

You could possibly skip LDAP and use only a user file.

#! /bin/sh
cd /etc/raddb
if [ ! -e .last-reload ] || [ "`find users -nt .last-reload`" ]; then
    if radiusd -C &gt; .last-reload 2&gt;&amp;1; then
        kill -1 `cat /var/run/radiusd.pid`
    else
        mail -s "radius reload failed!" root &lt; .last-reload
    fi
fi
touch .last-reload

And it may be of some value to include multiple files with the $INCLUDE directive.

Testing VLAN Assignment

The ‘WiFi’ client group we created in the RADIUS setup stipulated they’d all be on the 10 network. To test from localhost, you’ll need to explicitly add that. A quick way is to edit the default site file.

A Static Example

Find the post-auth section, Add the block after the update block in the existing post-auth block. Here’s a static sample.

vi /etc/freeradius/3.0/sites-available/default
...
...
post-auth {
        ...
        ...
        update {
                &reply: += &session-state:
        }

        update reply {
                &Tunnel-Type = 13,
                &Tunnel-Medium-Type = 6,
                &Tunnel-Private-Group-Id = "1020"
        }

A Dynamic Example

You can also test this with a users file, so as to testing getting the result from a user database.

# Remove the 'update reply' block and any static assignments added above
vi /etc/freeradius/3.0/sites-available/default
# The attributes will be assigned directly from the userdb to the reply
vim  /etc/freeradius/3.0/users

bob     NT-Password := "066DDFD4EF0E9CD7C256FE77191EF43C"
        Tunnel-Type = "VLAN",
        Tunnel-Medium-Type = "IEEE-802",
        Tunnel-Private-Group-Id = "1020"

radtest should show you something like

radtest -t mschap bob hello localhost 0 testing123

...
...
Received Access-Accept Id 255 from 127.0.0.1:1812 to 127.0.0.1:37509 length 102
        MS-CHAP-MPPE-Keys = 0x0000000000000000ac0782e2de2337dee40e54ee732c1af5
        MS-MPPE-Encryption-Policy = Encryption-Allowed
        MS-MPPE-Encryption-Types = RC4-40or128-bit-Allowed
        Tunnel-Type:0 = VLAN
        Tunnel-Medium-Type:0 = IEEE-802
        Tunnel-Private-Group-Id:0 = "1020"

Note that with RADIUS attribute definitions you can use the numerical representation, 6, or the actual string “IEEE-802”. It’s rumored that the string works best with the user database, but it’s worth testing yourself.

Certificate Life

The Let’s Encrypt certs are short-lived. Apple will prompt you again every time it renews, which is currently 2 months. It will show as valid, but they just want to notify you it’s changed. This can be annoying, but since the industry is moving towards shorter validity periods with a goal of 47 days by 2029, we can only hope Apple changes how they handle it.

LDAP

Why Use LDAP

for large numbers or real-time changes. One can use a relational database like MySQL, but most of the tools in the Identity Management space work better with LDAP. In the past, one would pass-thru the authentication directly to LDAP. But this doesn’t work with MSCHAPv2. Instead, we will treat OpenLDAP as a database as recommended in the FreeRADIUS docs: ‘We generally recommend that LDAP should be used as a database…"

How LDAP Passwords Work

There is an attribute for LDAP Passwords; userPassword. But this isn’t suitable for our use as it’s designed to be plain text string as per RFC4519. The admin password is salted SHA.

You may notice when using radtest that Bob’s password is returned as what looks like a hash. But it’s not. The userPassword attribute is a base64 encoded plain text string as per RFC4519. You can decode it with a echo Ym9ic1Bhc3M= | base64 --decode. This is one of the reasons we used a hash.

Using a Password Hash

It’s counter intuitive, but many documents and even the LDAP RFC4519 prefer passwords stored in plain-text as this ensures compatibility with all protocols. In the above test, the password is encrypted by the client (-t mschap) before it’s sent. The client tells the server what algorithm it used and the server hashes it’s own copy on the fly to compare. If another client used a different protocol, the server could still handle it.

But we’re only interested in one password protocol, MSCHAPv2. That allows us to store just the hash on the server and makes security guys breathe easier. Do this with the smbcrypt utility that’s installed with FreeRADIUS.

Password Value Header

You’ll notice that we’re storing the NT Hash with a header in the userPassword attribute. OpenLDAP doesn’t understand nthash as a scheme and so it blocks direct logins with it, but it’s happy to pass the data to RADIUS as a base64 encoded text string.

# Let's take a look at the person object to see we have to work with
ldapsearch -x -s base -b cn=Subschema objectClasses -LLL -o ldif-wrap=no |  sed -nr '/person/ p' | sed -r 's/[$()]+/\n /g'

# There are several variants, but lets look at the first one, the basic 'person' object. You'll see something like

        objectClasses:
          2.5.6.6 NAME 'person' DESC 'RFC2256: a person' SUP top STRUCTURAL MUST
            sn
            cn
          MAY
            userPassword
            telephoneNumber
            seeAlso
            description

# Let's take a look at the attributes to make sure we can just put text in them.
ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' attributeTypes | grep description

        attributeTypes: ( 2.5.4.13 NAME 'description' DESC 'RFC4519: descriptive information' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )

ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' ldapSyntaxes | grep  1.3.6.1.4.1.1466.115.121.1.15

        ldapSyntaxes: ( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )

# So what's a directory string? One or more aribtray characters. That will work fine for an aribitrary description like 'staff'

ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' attributeTypes | grep seeAlso

        attributeTypes: ( 2.5.4.34 NAME 'seeAlso' DESC 'RFC4519: DN of related object' SUP distinguishedName )

# That won't work as it must refer to another ldap object. How about telephoneNumber

ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' attributeTypes | grep telephoneNumber

        attributeTypes: ( 2.5.4.20 NAME 'telephoneNumber' DESC 'RFC2256: Telephone Number' EQUALITY telephoneNumberMatch SUBSTR telephoneNumberSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.50{32} )

ldapsearch -x -o ldif-wrap=no -LLL -b cn=Subschema -s base '(objectClass=subschema)' ldapSyntaxes | grep 1.3.6.1.4.1.1466.115.121.1.50

        ldapSyntaxes: ( 1.3.6.1.4.1.1466.115.121.1.50 DESC 'Telephone Number' )

# According to the web, that syntax is 'a string of [printable characters]' and that will work for a date if we needed to mis-use it.

Resetting OpenLDAP ACLs

If you have misconfigured your ACLs but can’t determine how, you may benefit from a reset.

# Default ACLs
vi acls-orig.ldif
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to * by * read

ldapmodify -Y EXTERNAL -H ldapi:/// -f idm-replace.ldif

Understanding LDAP ACLs

Take a look at the base, or default ACLs.

# su to root so we can use the EXTERNAL auth for a change.
su -
ldapsearch -Q -LLL -Y EXTERNAL -H ldapi:/// -b cn=config '(olcDatabase={1}mdb)' olcAccess

dn: olcDatabase={1}mdb,cn=config
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to * by * read

These rules are somewhat cryptic at first but you can puzzle them out once you see that it’s a set of three-word rules. The first line is the DN of rule object itself (which you can ignore) and the successive lines refer to a thing, and then who can do what with it.

In the first ACL, it’s referring to the attribute userPassword and says by self write and then by anon auth and then by * none. The middle one is a puzzler at first, but means anon can go back and try to authenticate, to be reconsidered later.

Importantly, these rules are short-circuit style. The by * none at the end of the first ACL will catch everyone, so even though the last ACL says by or everything by everyone read, the userPassword in the first ACL hit first and overrides for that attribute.

Changing ACLs

In our case, we need the FreeRADIUS user as a ‘read’ and the IDM user as a ‘write’. This is a modify-replace ldap operation as we need to replace rule 0.

vi FreeRADIUS-IDM-password.ldif

dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by dn="cn=FreeRADIUS,dc=example,dc=org" read by dn="cn=IDM,dc=example,dc=org" write by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to * by * read

ldapmodify -Y EXTERNAL -H ldapi:/// -f FreeRADIUS-IDM-password.ldif

We also need the IDM account to manage the user container. You must grant it specifically to the container itself (so it can add entries) and to the children (so it can delete and modify existing entries). This is a modify-add ldap operation and as we are adding these at rule 2, it will push anything after down automatically.

vi IDM-Access.ldif

dn: olcDatabase={1}mdb,cn=config
changetype: modify
add: olcAccess
olcAccess: {2}to dn.exact="ou=people,dc=example,dc=org" attrs=children by dn="cn=IDM,dc=example,dc=org" manage
olcAccess: {3}to dn.children="ou=people,dc=example,dc=org" by dn="cn=IDM,dc=example,dc=org" manage

ldapmodify -Y EXTERNAL -H ldapi:/// -f idm-access.ldif

That will give us the new ACL list of:

ldapsearch -Q -LLL -Y EXTERNAL -H ldapi:/// -b cn=config '(olcDatabase={1}mdb)' olcAccess

dn: olcDatabase={1}mdb,cn=config
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by dn="cn=FreeRADIUS,dc=example,dc=org" read by dn="cn=IDM,dc=example,dc=org" write by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to dn.exact="ou=people,dc=example,dc=org" attrs=children by dn="cn=IDM,dc=example,dc=org" manage
olcAccess: {3}to dn.children="ou=people,dc=example,dc=org" by dn="cn=IDM,dc=example,dc=org" manage
olcAccess: {4}to * by * read

Root Password Reset

ldap_bind: Invalid credentials (49)

I see this sometimes and it appears to be a bug with the debconf that pops up everyone so often. You must manually set the password.

# Get the hash for the password you want to use
slappasswd 

# This command connects to an interactive ldap session
ldapmodify -Y EXTERNAL -H ldapi://

# Type in the next the lines to set the password
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcRootPW
olcRootPW: {SSHA}4uDGf5aZylGWXoTde6Mvsp5CIph+XFNo

# Hit enter one more time and you should see the message
modifying entry "olcDatabase={1}mdb,cn=config"

# Hit Ctrl-C to end the interactive session, then restart slapd
Ctrl-C
systemctl restart slapd

Anon vs Unauth

LDAP distinguishes between anonymous and unauthenticated. The latter being when a user identifies themselves but doesn’t supply a password. This is disabled by default.

# Unauthenticated binds (user ID but no password) are disabled by default already
ldapsearch -x -LLL -w "" -D cn=admin,dc=example,dc=org -b dc=example,dc=org

System Chain Compilation

If your system doesn’t have the Let’s Encrypt CA, you must compile the CA certs into a chain-file for yourself and any other clients who may not have a modern trust store.

# Who signed our cert?
openssl x509 -in /etc/letsencrypt/live/wifi.example.org/cert.pem -noout -subject -issuer

        subject=CN = wifi.example.org
        issuer=C = US, O = Let's Encrypt, CN = R3
# Where is their cert?
openssl x509 -in /etc/letsencrypt/live/wifi.example.org/cert.pem -text | grep "CA Issuers"

        CA Issuers - URI:http://r3.i.lencr.org/
# Let's get that cert and see if it was in turned signed by someone else and where to get that cert
wget http://r3.i.lencr.org/ -O r3.der
openssl x509 -inform DER -in r3.der -noout -subject -issuer

        subject=C = US, O = Let's Encrypt, CN = R3
        issuer=C = US, O = Internet Security Research Group, CN = ISRG Root X1
openssl x509 -inform DER -in r3.der -text | grep "CA Issuers"

        CA Issuers - URI:http://x1.i.lencr.org/

wget http://x1.i.lencr.org/ -O x1.der

# OK, if this is the 'root' signer, it will have itself as it's issuer
openssl x509 -inform DER -in x1.der -noout -subject -issuer

        subject=C = US, O = Internet Security Research Group, CN = ISRG Root X1
        issuer=C = US, O = Internet Security Research Group, CN = ISRG Root X1

# Success!

# Now lets put both of those in a .pem file for the apps to use
openssl x509 -inform DER -in x1.der > x1-r3.pem
openssl x509 -inform DER -in r3.der >> x1-r3.pem


# And test with the CA option. 
# You need to make sure the host name matches 
echo 127.0.1.1  wifi.example.org >> /etc/hosts

LDAPTLS_CACERT=./x1-r3.pem ldapsearch -x -LLL -W -ZZ -D cn=admin,dc=example,dc=org -b dc=example,dc=org -h wifi.example.org

# If you get unexpected results, you can use the -d -1 options for testing
LDAPTLS_CACERT=./x1-r3.pem ldapsearch -d -1 -x -LLL -W -ZZ -D cn=admin,dc=example,dc=org -b dc=example,dc=org -h wifi.example.org

# And let's add it to our general conf. Check /etc/ldap/ldap.conf for the right crt
su -c "cat x1-r3.pem >> /etc/ssl/certs/ca-certificates.crt"

# This will now work as expected
ldapsearch -x -LLL -W -ZZ -D cn=admin,dc=example,dc=org -b dc=example,dc=org -h wifi.example.org

Resources

Checking the schema LDAP object plain text show Resetting the ldap admin password http://deployingradius.com/documents/configuration/pap.html https://xenomorph.net/linux/ubuntu/misc/radius-unifi/ https://clintonmetu.com/2018/05/setting-up-freeradius-openldap-on-a-raspberry-pi-for-network-device-authentication/ https://wiki.debian.org/LDAP/OpenLDAPSetup

8 - NPS

As a commercial alternative to FreeRADIUS, Microsoft does a decent RADIUS implementation as part of their Network Policy Server. It scales well and you can install and configure it with a few simple commands or use the GUI. It’s much simpler than FreeRADIUS.

I’ve scaled to to thousands of requests per second and it’s proven to work well with Aruba APs. But I’ve also had issues with Unifi Access Points. So it’s not always the best answer. Though that wa circa 2018 and it’s probably better now.

Here, we’ll configure it with the minimum needed to process RADIUS requests from the VPN server.

Installation

If you haven’t already, log into your Windows server, add the NPS role and register it with the domain1, The Windows firewall will auto-config2.

In an administrative powershell window:

Install-WindowsFeature NPAS -IncludeManagementTools
netsh nps add registeredserver YOURDOMAIN YOURNPSSERVER

Configuration

NPS configuration has three parts; Who are my clients? When are they allowed to connect? and Should we give the end-user the thumbs up? This feels a little strange at times, but you get used to it.

Add A Client

The first step is to add the vpn server as a client3. The RADIUS server won’t even acknowledge your connection attempt unless it knows about you. In an admin powershell window:

# Add the client. Use the actual IP address to avoid Event ID 13 Errors
netsh nps add client name=<client name> address=<ip address> sharedsecret=<password>

Add A Connection Policy

The next component is a Connection Policy4. In practice, this is seldom used as anything other than a catch-all-allow-connect as the default policy shows. Though when in a federation, it determines how to route your connection. I.e. should your request be processed here, or should I proxy your request to an external server based on your ID.

Here, we add a very simple policy that looks at the IP and by default accepts the connection.

# Add a Connection Policy
#  The default connection policy is sufficient but let's add an explicit one.
#  Processing order doesn't work as documented. You must clear existing ones or
#  explicitly specify processing order.

netsh nps reset crp
netsh nps add crp name = "VPN Connection" conditionid = "0x100c" conditiondata = "SomeIpAddress"

Add A Network Policy

The last step is to add a Network Policy5. This determines if you are both Authentic and Allowed to connect. That’s an important point: the authorization decision is made at the RADIUS server, not the VPN server. You could in theory return a set of attributes to the VPN server. But in practice that’s not the approach that’s used6.

In this example we assume you have a group in AD to control access and looked up it’s SID.

# Add a Network Policy
netsh nps reset np
netsh nps add np
name = "Allow VPN Access"
conditionid = "0x1fb5" conditiondata = "S-1-5-21-1566005089-1651370962-3825839635-513" `
profileid = "0x100f" profiledata = "True"
profileid = "0x100a" profiledata = "1A000000000000000000000000000000"
profileid = "0x1009" profiledata = "0x5"

The Condition ID above isn’t documented but adding the condition from the GUI and issuing netsh nps show np reveals it. To see what attributes these profileid’s are setting, do a show np to see the desc.

Enterprise Authentication 802.1x

The traditional way of providing a name and password is with 802.1x. It’s done by chaining together a few protocols to get “PEAP-MS-CHAPv2”. This allows you transmit a name and password from the client to the RADIUS server without the AP in middle seeing it.

One of the things you need is a certificate with an extension that specifically allows for server authentication.

Let’s Encrypt works fine.

The Hard Way

If you want to go a different route, MS documentation identify that as Server Authentication purpose in Extended Key Usage (EKU) extensions. (The object identifier for Server Authentication is 1.3.6.1.5.5.7.3.1.) And the server certificate is configured with a required algorithm min value of RSA 2048. The Subject Alternative Name (SubjectAltName) extension, if used, must contain the DNS name of the server.

You can also do it the old way with MS’s cert req.

# Don't add the SAN as the provider will do tha in a web form usually
certreq -new radius.inf radius.csr 

certreq –accept radius.cer 
;----------------- request.inf ----------------- 
[Version] 
Signature="$Windows NT$" 

[NewRequest] 
;Change to your,country code, company name and common name 
Subject = "CN=radius.marietta.edu,OU=Information Technology,O=Marietta College,L=Marietta,S=Ohio,C=US,[email protected]" 
KeySpec = 1 
KeyLength = 2048 
Exportable = TRUE 
MachineKeySet = TRUE 
SMIME = False 
PrivateKeyArchive = FALSE 
UserProtected = FALSE 
UseExistingKeySet = FALSE 
ProviderName = "Microsoft RSA SChannel Cryptographic Provider" 
ProviderType = 12 
RequestType = PKCS10 
KeyUsage = 0xa0 

[EnhancedKeyUsageExtension] 
OID=1.3.6.1.5.5.7.3.1 ; this is for Server Authentication / Token Signing 
;-----------------------------------------------  

Operation

Performance Tuning

If you expect a lot of traffic you should increase the number of concurrent connection to AD so you don’t bottle-neck7. This is critical on a large install when you have more than a hundred authentications per second. (say, 10,000+ devices). This is unlikely for a VPN but wireless transaction can easily hit the limit.

# Edit the registry to increase the number of concurrent connections to AD
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters 5

Issues

When using with Unifi, if the communication between the authenticator (the AP) and the RADIUS server has any issues, the AP will give up after 3 tries and won’t try again. Packet captures make it seem like the they disagree on how to implement the protocol in some cases. This happened to about 1% of our APs a day.

In these cases - a reboot of the AP was the quick fix.

No such problems with FreeRADIUS.

References

You may notice we’re referring to older docs here. The most current don’t have the details from the older docs when it comes to the netsh commands.