Comments on Active Directory IdP and YubiKey OTP integration that supports MS-CHAP v2
David Herselman
dhe at syrex.co
Mon Feb 22 22:45:01 CET 2021
Hi,
I was hoping the following may provide a more complete start to finish integration guide for others that may have similar requirements. I would very much like to invite dialogue regarding any recommendations anyone may have.
Our requirements were:
* RADIUS servers domain joined to handle PAP and MS-CHAP v2 authentication using information only from Active Directory
* We do not want to store credentials in AD with reversible encryption
* AD Group membership validation based on source
* Yubico OTP Multi-Factor Authentication (MFA)
System are domain joined as member servers using Samba. Samba winbind subsequently presents users and groups as native system accounts, which allows references to just the group name and unlike LDAP does not make the link sensitive to the placement of the security group within the AD folders or organisational units (distinguished name or DN). Password Authentication Protocol (PAP) transmits unencrypted passwords (i.e. plain-text) over the network so connectivity should be encrypted using either IPSec or TLS (RadSec or EAP-TTLS). MS-CHAP v2 provides mutual authentication and uses a hash of the password. This means that the request only includes a hash representing the password, so there is nothing to cut the password and OTP out of. One can however transmit the OTP as the username and then lookup the associated owner's AD account name for FreeRADIUS to retrieve the hash used during authentication via Samba winbind.
Yubico devices generate a unique 32 character modhex passcode each time you press the button on a YubiKey 5 or scan it using NFC. This passcode comes after a 12 character public id, which is unique to each YubiKey. We therefore have two method of transmitting the OTP during login. The first is the classic RSA token method (although reversed) where we use our username, type our password and then press the YubiKey button, herewith an example:
Username: davidh
Password: secretcccccct00001krbhnvrjrdlujuujdcjvltdcrdkhhtit
This method requires PAP, as we require the client to transmit the entered credentials, so that we can cut the information in to a password and Yubico OTP.
The alternative is to make use of the YubiKey's public ID to lookup the AD username. This method works for PAP and allows MS-CHAP v2 authentication, herewith an example:
Username: cccccct00001krbhnvrjrdlujuujdcjvltdcrdkhhtit
Password: secret
In the second case we type out password out and then press the YubiKey when prompted for the username. Devices we were authenticating to are generally switches and routers where we can transmit attributes as part of the access accept message to grant eg access permissions. Some platforms however (eg Check Point Management and VyOS) require user accounts to be defined, in those cases you are stuck with PAP being the only possible integration.
PS: All is not lost, if you implement a jump host that you enforce MFA access to, which in turn simply uses single factor.
Additional detail on the Yubico OTP is available directly from Yubico:
https://developers.yubico.com/OTP/OTPs_Explained.html
The following notes pertain to a Debian buster (10) install, they would require path changes but should in essence work on any Linux distribution.
Join system to Active Directory:
apt-get install libnss-winbind samba winbind;
pico /etc/krb5.conf
[libdefaults]
default_realm = AD.ACME.COM
dns_lookup_realm = false
dns_lookup_kdc = true
pico /etc/samba/smb.conf
[---------------------------------------- /etc/samba/smb.conf ----------------------------------------]
[global]
server role = member server
workgroup = ACME
realm = ad.acme.com
netbios name = FreeRADIUS1
server string = FreeRADIUS and Yubico OTP
bind interfaces only = yes
interfaces = 127.0.0.1/8 192.168.1.48/24
ntlm auth = mschapv2-and-ntlmv2-only
guest account = nobody
idmap cache time = 300
kerberos method = system keytab
idmap config * : backend = tdb
idmap config * : range = 100001-120000
idmap config Domain-01 : backend = rid
idmap config Domain-01 : range = 1000-100000
template homedir = /home/users/%U
template shell = /sbin/nologin
log level = 2
log file = /var/log/samba/%m.log
enable core files = no
max log size = 50
dont descend = /dev, /mirror, /proc
time server = no
wins support = no
map acl inherit = yes
store dos attributes = yes
printing = cups
cups options = raw
winbind use default domain = yes
winbind enum users = yes
winbind enum groups = yes
winbind expand groups = 2
[nobody]
path = /dev/null
comment = Access denied - Guest
guest ok = no
printable = no
browseable = no
[---------------------------------------- /etc/samba/smb.conf ----------------------------------------]
net ads join -U adm-domain-davidh;
systemctl restart smbd;
pico /etc/nsswitch.conf;
# Append 'winbind' to 'passwd' and 'group'
Check winbind and ntlm_auth authentication are working:
wbinfo -t
ntlm_auth --allow-mschapv2 --request-nt-key --domain=ACME --username=davidh
pico /etc/group /etc/gshadow
# Add 'freerad' to 'winbindd_priv:'
Finally check that you are able to retrieve users and groups as if they were local users:
getent passwd davidh
getent group client_cpe_admin
Install FreeRADIUS with the YubiKey module:
apt-get install freeradius-yubikey
If necessary set shortname on defined clients to apply different AD group membership or altering what reply attributes are sent back
/etc/freeradius/3.0/clients.conf
# Hash out existing active lines, then append:
client fw1.acme.com {
ipaddr = 192.168.10.1
secret = ****************
require_message_authenticator = yes
nastype = other
shortname = checkpoint
}
client switch6.acme.com {
ipaddr = 192.168.20.7
secret = ****************
require_message_authenticator = yes
nastype = cisco
shortname = client_cpe
}
Define custom attributes:
/etc/freeradius/3.0/dictionary.custom
ATTRIBUTE sAMAccountName 3000 string
ATTRIBUTE Original-User-Name 3001 string
Sample Livingston style users file, in this case for use with MikroTik RouterOS, make as many as you need with whatever reply attributes you require:
/etc/freeradius/3.0/users
DEFAULT FreeRADIUS-Client-Shortname == "client_cpe", Group == "client_cpe_view"
Mikrotik-Group = "view"
DEFAULT FreeRADIUS-Client-Shortname == "client_cpe", Group == "client_cpe_admin"
Mikrotik-Group = "full"
DEFAULT FreeRADIUS-Client-Shortname == "client_cpe", Auth-Type := Reject
Reply-Message = "Access Denied - Not a member of any client_cpe security groups"
DEFAULT Auth-Type := Reject
Reply-Message = "Access Denied"
Edit MS-CHAP v2 module to use winbind and alter the username:
/etc/freeradius/3.0/mods-available/mschap
winbind_username = "%{%{sAMAccountName}:-%{mschap:User-Name}}"
winbind_domain = "%{mschap:NT-Domain}"
Add 'allow-mschapv2' and again change the username expansion variable:
/etc/freeradius/3.0/mods-available/ntlm_auth
program = "/usr/bin/ntlm_auth --allow-mschapv2 --request-nt-key --domain=DOMAIN-01 --username=%{%{sAMAccountName}:-%{mschap:User-Name}} --password=%{User-Password}"
Register for a Yubico API integration account and then enable and configure the module:
cd /etc/freeradius/3.0/mods-enabled;
ln -s ../mods-available/yubikey yubikey;
/etc/freeradius/3.0/mods-enabled/yubikey
validate = yes
client_id = *****
api_key = '****************************'
The following changes are all in
/etc/freeradius/3.0/sites-available/default
Hash out 'pap', we'll force NTLMv2 by obtaining a hash of the password hash from AD with each authentication request and perform necessary operations on the plain text password we receive as part of the request
After 'preprocess' in 'authorize {'
if (&User-Name =~ /^([cbdefghijklnrtuv]{44})$/) {
update request {Yubikey-OTP = "%{1}"}
} elsif (&User-Password =~ /^(.+?)([cbdefghijklnrtuv]{44})$/) {
update request {User-Password := "%{1}", Yubikey-OTP = "%{2}"}
}
if (&Yubikey-OTP =~ /^([cbdefghijklnrtuv]{12})/) {
switch "%{1}" {
case cccccct00001 {update request {sAMAccountName = "davidh"}}
case cccccct00002 {update request {sAMAccountName = "philipo"}}
}
if (!&sAMAccountName || (&User-Name != &Yubikey-OTP && &User-Name != &sAMAccountName)) {
update reply {Reply-Message := "Unregistered or user mismatch"}
reject
}
}
Before and after 'files' in 'authorize {'
update request {FreeRADIUS-Client-Shortname = "%{Client-Shortname}"}
if (&sAMAccountName && &User-Name != &sAMAccountName) {
update request {
Original-User-Name := "%{User-Name}",
User-Name := "%{sAMAccountName}"}}
files
if (&Original-User-Name) {update request {User-Name := "%{Original-User-Name}"}}
End of 'authorize {'
if (&control:Auth-Type == "mschap") {
if (&Yubikey-OTP) {
update control {Auth-Type := "YubiCHAP"}
} else {update control {Auth-Type := "MS-CHAP"}}
if (!control:Auth-Type && User-Password) {
if (&Yubikey-OTP) {
update control {Auth-Type := "YubiNTLM"}
} else {update control {Auth-Type := "ntlm_auth"}}
}
Exclusively have the following in 'authenticate {'
Auth-Type MS-CHAP {
mschap
}
Auth-Type YubiCHAP {
mschap
yubikey
}
Auth-Type ntlm_auth {
ntlm_auth
}
Auth-Type YubiNTLM {
ntlm_auth
yubikey
}
Beginning of 'post-auth {'
if (&sAMAccountName && &User-Name != &sAMAccountName) {update request {User-Name := "%{sAMAccountName}"}}
Beginning of 'Post-Auth-Type REJECT {'
if (&sAMAccountName && &User-Name == &Yubikey-OTP) {update request {User-Name := "%{sAMAccountName}"}}
Herewith all active lines, as a comparative to where you've ended up:
[---------------------------------------- grep -v '^\s*#\|^\s*$' /etc/freeradius/3.0/sites-enabled/default ----------------------------------------]
server default {
listen {
type = auth
ipaddr = *
port = 0
limit {
max_connections = 16
lifetime = 0
idle_timeout = 30
}
}
listen {
ipaddr = *
port = 0
type = acct
limit {
}
}
listen {
type = auth
ipv6addr = :: # any. ::1 == localhost
port = 0
limit {
max_connections = 16
lifetime = 0
idle_timeout = 30
}
}
listen {
ipv6addr = ::
port = 0
type = acct
limit {
}
}
authorize {
filter_username
preprocess
if (&User-Name =~ /^([cbdefghijklnrtuv]{44})$/) {
update request {Yubikey-OTP = "%{1}"}
} elsif (&User-Password =~ /^(.+?)([cbdefghijklnrtuv]{44})$/) {
update request {User-Password := "%{1}", Yubikey-OTP = "%{2}"}
}
if (&Yubikey-OTP =~ /^([cbdefghijklnrtuv]{12})/) {
switch "%{1}" {
case cccccct0001 {update request {sAMAccountName = "davidh"}}
case cccccct0002 {update request {sAMAccountName = "philipo"}}
}
if (!&sAMAccountName || (&User-Name != &Yubikey-OTP && &User-Name != &sAMAccountName)) {
update reply {Reply-Message := "Unregistered or user missmatch"}
reject
}
}
chap
mschap
digest
suffix
eap {
ok = return
}
update request {FreeRADIUS-Client-Shortname = "%{Client-Shortname}"}
if (&sAMAccountName && &User-Name != &sAMAccountName) {
update request {
Original-User-Name := "%{User-Name}",
User-Name := "%{sAMAccountName}"}}
files
if (&Original-User-Name) {update request {User-Name := "%{Original-User-Name}"}}
-sql
-ldap
expiration
logintime
if (&control:Auth-Type == "mschap") {
if (&Yubikey-OTP) {
update control {Auth-Type := "YubiCHAP"}
} else {update control {Auth-Type := "MS-CHAP"}}
}
if (!control:Auth-Type && User-Password) {
if (&Yubikey-OTP) {
update control {Auth-Type := "YubiNTLM"}
} else {update control {Auth-Type := "ntlm_auth"}}
}
}
authenticate {
Auth-Type MS-CHAP {
mschap
}
Auth-Type YubiCHAP {
mschap
yubikey
}
Auth-Type ntlm_auth {
ntlm_auth
}
Auth-Type YubiNTLM {
ntlm_auth
yubikey
}
}
preacct {
preprocess
acct_unique
suffix
files
}
accounting {
detail
unix
-sql
exec
attr_filter.accounting_response
}
session {
}
post-auth {
if (&sAMAccountName && &User-Name != &sAMAccountName) {update request {User-Name := "%{sAMAccountName}"}}
update {
&reply: += &session-state:
}
-sql
exec
remove_reply_message_if_eap
Post-Auth-Type REJECT {
if (&sAMAccountName && &User-Name == &Yubikey-OTP) {update request {User-Name := "%{sAMAccountName}"}}
-sql
attr_filter.access_reject
eap
remove_reply_message_if_eap
}
Post-Auth-Type Challenge {
}
}
pre-proxy {
}
post-proxy {
eap
}
}
[---------------------------------------- grep -v '^\s*#\|^\s*$' /etc/freeradius/3.0/sites-enabled/default ----------------------------------------]
NB: Once everyone has a YubiKey hash out the single factor authenticate options (Auth-Type MS-CHAP and Auth-Type ntlm_auth) methods or update unlang to exempt 2FA from jump hosts or NMS.
Unvalidated idea for users file:
DEFAULT Yubikey-OTP !* "", Packet-Src-IP-Address !~ /192\.168\.14\./, Auth-Type := Reject
Reply-Message = "Access Denied - 2FA required"
Regards
David Herselman
More information about the Freeradius-Users
mailing list