Skip to content

NTLM Hash Attribute in LDAP Outpost? #8768

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
jcrapuchettes opened this issue Mar 1, 2024 · 22 comments
Closed

NTLM Hash Attribute in LDAP Outpost? #8768

jcrapuchettes opened this issue Mar 1, 2024 · 22 comments
Labels
question Further information is requested

Comments

@jcrapuchettes
Copy link

jcrapuchettes commented Mar 1, 2024

Describe your question
I’m going down the path of figuring out WPA Enterprise WiFi Security. As I have been reviewing posts online and github issues, I’ve been trying to figure out why I wouldn’t be able to use the following setup: Authentik -> LDAP Outpost -> FreeRADIUS -> UniFi. After setting it all up, the FreeRADIUS server reported "mschap: FAILED: No NT-Password." I noticed that the LDAP settings in FreeRADIUS included control:NT-Password := 'ipaNTHash'. I tried adding an attribute to my user in Authentik called ipaNTHash and set its value to the NTLM hash of my password. I then tested my setup with eapol_test and got a SUCCESS! I've also tested connecting through two different computers and it worked!

My question: Is there a way for me to add a calculation of the NTLM Hash to user attributes when the password is changed? Could I make use of a property mapping?

Relevant info

  • UniFi Network Application 8.0.24
  • FreeRADIUS 3

Version and Deployment (please complete the following information):

  • authentik version: 2023.10.7
  • Deployment: Helm through TrueCharts
  • LDAP Outpost on Docker Swarm

Additional context
WPA Enterprise through PEAP-MSCHAPv2 is pretty standard and having to setup FreeRADIUS and FreeIPA seems like a lot given the competition offers their own RADIUS server that supports PEAP-MSCHAPv2. I know that it is possible to setup FreeRADIUS to communicate to Authentik via OAuth2, but that required EAP-TTLS/PAP. I'm not opposed to that with the exception that we have a number of iOS users and from my research, it will be a pain to set them up.

@jcrapuchettes jcrapuchettes added the question Further information is requested label Mar 1, 2024
@jcrapuchettes
Copy link
Author

I would be happy to contribute code or code up my own solution. I just need some direction.

@rissson
Copy link
Member

rissson commented Mar 1, 2024

Is there a way for me to add a calculation of the NTLM Hash to user attributes when the password is changed?

Yeah you can do so in the password change flow.

Other question though: why not use the Radius provider directly instead of using the LDAP one?

@jcrapuchettes
Copy link
Author

jcrapuchettes commented Mar 1, 2024

Can you help me unpack how I might do that in the password change flow? I didn't see any way to inject arbitrary code.

The RADIUS provider doesn't support EAP w/ PEAP-MSCHAPv2 as far as I know..

@rissson
Copy link
Member

rissson commented Mar 1, 2024

In the flow default-password-change (to adapt if you have a custom one), you'd go to "Stage Bindings", expand the default-password-change-write binding, click "Create and bind Policy", select "Expression Policy", give it a meaningful name, and then have an expression that looks like:

user.attributes["ipaNTHash"] = ntlm_hash(context['flow_plan'].context["prompt_data"]["password"])

I might be wrong about the code itself, I'm not 100% fluent in authentik policy yet, but it should be this. Also, you'll need to get an ntlm_hash function somehow.

@jcrapuchettes
Copy link
Author

@rissson your help got me to the point that my testing with eapol_test works! The final policy code looks like this:

from Crypto.Hash import MD4
request.user.attributes["ipaNTHash"] = MD4.new(context["prompt_data"]["password"].encode('utf-16-le')).digest().hex()
return True

@rissson
Copy link
Member

rissson commented Mar 11, 2024

Perfect! Would you be willing to contribute to https://docs.goauthentik.io/integrations/?

@ZUCCzwp
Copy link

ZUCCzwp commented Apr 25, 2024

how to set the policy in version 2024.2 ? i get the error
image

@jcrapuchettes
Copy link
Author

@ZUCCzwp you will have to translate that error for us. I haven't upgraded to 2024.2 yet.

@CppBunny
Copy link

@jcrapuchettes Could you share the freeradius config you used to get it to work with authentik ldap?

@jcrapuchettes
Copy link
Author

jcrapuchettes commented May 2, 2024

@jcrapuchettes Could you share the freeradius config you used to get it to work with authentik ldap?

I configured FreeRADIUS via the pfSense GUI, but attached are the eap and ldap config files.
eap.txt
ldap.txt

@jcrapuchettes
Copy link
Author

After upgrading to 2024.4.2, I found (like @ZUCCzwp did) that the Crypto package no longer is included in the shipped docker container. I will post when I find a solution.

@jcrapuchettes
Copy link
Author

Here is the solution: https://gist.github.com/jcrapuchettes/830c99982e391c858f5b7eb066c02749

@MarcoTribuz
Copy link

MarcoTribuz commented Jun 14, 2024

@jcrapuchettes Thank you for your work, you save me a lot of time, i'm wondering if you can share with me the authentik configuration in order to setting up in the right way my server :). And if it is possible, can you share your virtual server configuration? for example i see in your eap config file, that you have two virtual server, inner-tunnel-peap and inner-tunnel-ttls. in any case thank you

@showier-drastic
Copy link

Hi,

Is it possible to update ipaNTHash when user's password is changed by an administrator via admin panel?

@PopcornPanda
Copy link

PopcornPanda commented Sep 5, 2024

@jcrapuchettes I noticed that this policy didn't work if You're reseting password

I came up with version that works eather during password change from user level and recovery flow:

import hashlib

def generate_ntlm_hash(password):
    """Generate an NTLM hash from the given password."""
    password_unicode = password.encode('utf-16le')
    return hashlib.new('md4', password_unicode).hexdigest()

def store_ntlm_hash(user, password):
    """Store the NTLM hash in the user's attributes."""
    if not user.attributes:
        user.attributes = {}
    user.attributes["ipaNTHash"] = generate_ntlm_hash(password)
    return user.save()

# Determine if the user is authenticated or pending
user = request.user if request.user and not request.user.is_anonymous else context.get("pending_user")

if user:
    # Extract the password from the context
    password = context.get("prompt_data", {}).get("password") or context.get("password")
    
    if password:
        return store_ntlm_hash(user, password)
    else:
        ak_message("There is no new password value in context")
        return False
else:
    ak_message("There is no user context")
    return False

@PopcornPanda
Copy link

@showier-drastic
Admin's set password calls an endpoint that directly sets a new password. It's bypass every policy, so unfortunately not, it's not possible.

The best workaround so far is to generate recovery links or send recovery emails to the user.

@Arzaroth
Copy link

Arzaroth commented Sep 30, 2024

I didn't want to ask every user to change their passwords in order to populate new hash fields, so I tried to incorporate this into a authentication flow.
I've duplicated the default one and swapped the password stage by a prompt stage.
The prompt stage should have a password field, and a custom validation policy.
Then, I've created the policy as follows :

import os
import base64
import hashlib
import datetime

from authentik.stages.password.stage import authenticate

def extract_ldap_hash(ldap_hash, salt_size=4):
    if not ldap_hash:
        return None, os.urandom(salt_size)
    try:
        ssha_password = base64.b64decode(ldap_hash).decode('utf-8')
    except Exception:
        return None, os.urandom(salt_size)
    if not ssha_password.startswith('{SSHA}'):
        return None, os.urandom(salt_size)
    cleaned_ssha_password = ssha_password[6:]
    ssha_dec = base64.b64decode(cleaned_ssha_password)
    payload = ssha_dec[:-salt_size]
    salt = ssha_dec[-salt_size:]

    return payload, salt

def generate_ldap_hash(user, password):
    ldap_hash = user.attributes.get("userPassword")
    payload, salt = extract_ldap_hash(ldap_hash)
    check_hash = hashlib.sha1(password.encode('utf-8') + salt).digest()
    return (b'{SSHA}' + base64.b64encode(check_hash + salt)).decode('utf-8')

def generate_ntlm_hash(user, password):
    password_unicode = password.encode('utf-16le')
    return hashlib.new('md4', password_unicode).hexdigest()

def store_user_hash(user, password):
    now = datetime.datetime.now().timestamp()
    if not user.attributes:
        user.attributes = {}
    previous_attributes = {**user.attributes}
    user.attributes["userPassword"] = generate_ldap_hash(user, password)
    user.attributes["ipaNTHash"] = generate_ntlm_hash(user, password)
    user.attributes["sambaNTPassword"] = user.attributes["ipaNTHash"].upper()
    return user.save()

user = request.user if request.user and not request.user.is_anonymous else context.get("pending_user")

if user:
    # Extract the password from the context
    password = context.get("prompt_data", {}).get("password") or context.get("password")
    
    if password:
        if authenticate(
            request=request.http_request,
            backends=[
                'authentik.core.auth.InbuiltBackend',
                'authentik.core.auth.TokenBackend',
                'authentik.sources.ldap.auth.LDAPBackend',
            ],
            username=user.username, password=password
        ):
            store_user_hash(user, password)
            return True
        else:
            ak_message("Invalid password")
            return False
    else:
        ak_message("There is no new password value in context")
        return False
else:
    ak_message("There is no user context")
    return False

As you can see in this, I use this policy to populate two fields, for LDAP and RADIUS logins (although I haven't tested it yet).
I don't think doing this is a particularly good idea, as it relies on authentik internals and could break with an update. The flow works nonetheless.

EDIT: I've tweaked the code a bit, since I realized I could directly use the authenticate function found in the password stage. It does not change my evaluation of the situation though. If authentik could allow the user to specify a validation policy during a password stage that would prevent this kind of workarounds.
That being said, it doesn't even work for my use case, since the authentik LDAP outpost does not support SAMBA LDAP schema.

@Junto026
Copy link

Junto026 commented Jan 27, 2025

I have built upon the fantastic information in this post and would like to share for others. I successfully configured Unifi to accept incoming connection requests from users (VPN or Wifi WPA2 Enterprise) when that request is using MSCHAPv2 for strong authentication. Those authentication requests are sent to FreeRADIUS (which I have running in docker), which then queries Authentik's LDAP outpost.

I used Authentik's guide for installing the LDAP outpost. I am not using the TOTP 2FA for this. Only the ldapservice account created during Authentik's guide connects to the LDAP outpost to search users, check their group assignments, and check their hashed passwords against what the user provided via MSCHAPv2.

First, each user must have the ipaNTHash attribute stored in their user attributes. I slightly modified @Arzaroth 's expression policy so it can be used for (i) enrollment flows when a user is being created for the first time, as well as (ii) when the user changes their password via the webGUI and (iii) when the user changes their password via a recovery flow. This expression policy must be saved at Authentik's Administration --> Customization --> Policies as an expression policy. Then, you must bind it to the FLOW during the user write stage during enrollment flow, user settings flow, and password recovery flow. This expression policy can be used for all 3. Basically it is defining generate_ntlm_hash and store_ntlm_hash functions. Then it checks to see if the user attempting to be edited already exists. If it does, it stores the hash in that user's attributes. If it doesn't, it writes the hash to the prompt_data context, which will then write it to the new user being created during the normal user write operation.

import hashlib

def generate_ntlm_hash(password):
    """Generate an NTLM hash from the given password."""
    password_unicode = password.encode('utf-16le')
    return hashlib.new('md4', password_unicode).hexdigest()

def store_ntlm_hash(user, password):
    """Store the NTLM hash in the user's attributes."""
    if not user.attributes:
        user.attributes = {}
    user.attributes["ipaNTHash"] = generate_ntlm_hash(password)
    return user.save()

# Determine if the user is authenticated or pending
user = request.user if request.user and not request.user.is_anonymous else context.get("pending_user")

if user:
    # Extract the password from the context
    password = context.get("prompt_data", {}).get("password") or context.get("password")
    
    if password:
        store_ntlm_hash(user, password)
        return True
    else:
        ak_message("There is no new password value in context")
        return False
else:
    request.context["prompt_data"]["attributes.ipaNTHash"] = generate_ntlm_hash(request.context["prompt_data"]["password"])
    return True

Once that is done, test and confirm the 'ipaNTHash' attribute is written to the user whenever they go through any of those flows. Look through Authentik's event logs for debugging.

Next, create an Authentik Group you will use to determine which users should pass this authentication (otherwise, ALL users will be able to access your WPA2 Enterprise Wifi and/or VPN). I created a group called VPN Users and added the users I wanted to be able to access VPN. You can choose a different group name, but make sure you change the group name in future steps. You can also create multiple groups, and multiple configurations in FreeRADIUS, but I didn't document that here. Doing so essentially requires multiple files in /etc/freeradius/sites-enabled/ with slightly altered configurations.

Next, you must install FreeRADIUS. I have not documented that here.

If you install it in docker like I did, it is tricky. You must run the freeRADIUS container WITHOUT bind-mounting /etc/freeradius configuration directory. After it's running, you then copy the whole /etc/freeradius/ configuration directory to another folder, which IS bind mounted. From inside the freeRADIUS container, use cp -Rp /etc/freeradius /newdirectory so that the initial permissions are retained. FreeRADIUS accesses those files as a special user (not root), and it complains if the files within that directory aren't properly locked down to that special user. You can then stop the container, delete it, and map your /newdirectory to the /etc/freeradius directory inside the container and it will run, and you can have persistence as you change the files in that directory.

That said, here is my compose.yaml file for freeRADIUS:

networks:
    private:
        external: true
services:
    freeradius:
        command: ["radiusd", "-X"] # Start freeradius in debug mode to standard output
#       command: ["radiusd", "-f"] # Start freeradius normally
        container_name: freeradius
        environment:
            - PUID=1026
            - PGID=101
            - TZ=America/New_York
        image: freeradius/freeradius-server:latest
        networks:
            private:
                ipv4_address: 192.168.32.21 # FreeRADIUS IP address inside private docker network
        ports:
            - 1812:1812/udp # RADIUS Authentication
            - 1813:1813/udp # JRADIUS Accounting
#        restart: always
        volumes:
            - /dockerdir/freeradius/etc/freeradius:/etc/freeradius
            - /dockerdir/freeradius/var/log/freeradius:/var/log/freeradius
            - /certsdir/:/etc/mycerts:ro

Next, you'll delete all files in FreeRADIUS located at /etc/freeradius/sites-enabled. I created a new file /etc/freeradius/sitesenabled/default with these contents. It checks to confirm the user requesting access belongs to the VPN Users group, and also confirms the password they entered matches the 'ipaNTHash' attribute stored in Authentik.

server default {

	listen {
		type = auth
		ipaddr = *
		port = 1812
	}

    authorize {
        # Extract the User-Name
        preprocess
        suffix
        ntdomain

        eap {
            ok = return
        }

        # Authenticate the user against LDAP
	ldap

	# Check if the user belongs to the 'VPN Users' group
	if ((Ldap-Group == "VPN Users")) {
		ok
	} else {
		reject
	}	

	# The PAP module processes the NT-Password value to ensure it's in the correct format for MSCHAP
	pap
	
	# Handle MSCHAP requests
	mschap
	}

    authenticate {
        # Use EAP for MSCHAPv2
        eap

        # Use MS-CHAP authentication
        mschap
	}

}

Next, you need to configure the ldap, eap and mschap modules in freeRADIUS. To start, I deleted all three from /etc/freeradius/mods-enabled/.

Here is my /etc/freeradius/mod-enabled/eap file (with thanks to @jcrapuchettes ). Note you will need to provide certificates in the area I marked as 'REPLACE-THIS'

eap {
	default_eap_type = peap
	timer_expire     = 60
	ignore_unknown_eap_types = no
	cisco_accounting_username_bug = no
	max_sessions = 4096

	tls-config tls-common {
#		# private_key_password = whatever
#		private_key_file = ${certdir}/server.key
#		certificate_file = ${certdir}/server.pem
#		ca_path = ${confdir}/certs
#		ca_file = ${ca_path}/ca.pem
#		dh_file = ${certdir}/dh
		private_key_file = /mycerts/privkey.pem # CHANGE-THIS
		certificate_file = /mycerts/cert.pem # CHANGE-THIS
		ca_file = /mycerts/fullchain.pem # CHANGE-THIS
		random_file = /dev/urandom
		fragment_size = 1024
		include_length = yes
		check_crl = no
		cipher_list = "DEFAULT"
		cipher_server_preference = no
#		disable_tlsv1_2 = no
		ecdh_curve = "prime256v1"
		tls_min_version = "1.2"
		cache {
			enable = yes
			lifetime = 24
			max_entries = 255
			#name = "EAP module"
			#persist_dir = "/tlscache"
		}
		verify {
	#		skip_if_ocsp_ok = no
	#		tmpdir = /tmp/radiusd
	#		client = "/path/to/openssl verify -CApath ${..ca_path} %{TLS-Client-Cert-Filename}"
		}
		ocsp {
			enable = no
			override_cert_url = no
			url = "http://127.0.0.1/ocsp/"
			# use_nonce = yes
			# timeout = 0
			# softfail = no
		}
	}
	tls {
		tls = tls-common
	#	virtual_server = check-eap-tls
	}
	ttls {
		tls = tls-common
		default_eap_type = mschapv2
		copy_request_to_tunnel = no
		include_length = yes
	#	require_client_cert = yes
		virtual_server = "inner-tunnel-ttls"
		#use_tunneled_reply is deprecated, new method happens in virtual-server
	}	### end ttls
	peap {
		tls = tls-common
		default_eap_type = mschapv2
		copy_request_to_tunnel = no
	#	proxy_tunneled_request_as_eap = yes
	#	require_client_cert = yes
### MS SoH Server is disabled ###

		virtual_server = "inner-tunnel-peap"
		#use_tunneled_reply is deprecated, new method happens in virtual-server
	}
	mschapv2 {
#		send_error = no
#		identity = "FreeRADIUS"
	}
#	fast {
#		tls = tls-common
#		pac_lifetime = 604800
#		authority_identity = "1234"
#		pac_opaque_key = "0123456789abcdef0123456789ABCDEF"
#		virtual_server = inner-tunnel
#	}
}

Here is my /etc/freeradius/mods-enabled/ldap file (with thanks to @jcrapuchettes). There are several things you'll need to modify based on your Authentik server configuration. Note, certificates are again needed in FreeRADIUS.

ldap {

	server = '<AUTHENTIK-SERVER>' # CHANGE-THIS
	port = '636'
	identity = 'cn=ldapservice,ou=users,dc=ldap,dc=authentik,dc=io' # CHANGE-THIS, but This works if you used Authentik's LDAP configuration guide
	password = '<LDAPSERVICE-PASSWORD)' # CHANGE-THIS
	base_dn = 'dc=ldap,dc=authentik,dc=io' # CHANGE-THIS

	user {
		base_dn = 'ou=users,dc=ldap,dc=authentik,dc=io' # CHANGE-THIS
		# ak-active=TRUE filters out disabled users
		filter = "(&(objectclass=user)(cn=%{%{Stripped-User-Name}:-%{User-Name}})(ak-active=TRUE))"
		### access_attr = "dialupAccess" ###
	}
	group {
		base_dn = 'ou=groups,dc=ldap,dc=authentik,dc=io' # CHANGE-THIS
		filter = '(objectClass=posixGroup)'
		name_attribute = cn
		membership_filter = "(|(&(objectClass=GroupOfNames)(member=%{control:Ldap-UserDn}))(&(objectClass=GroupOfUniqueNames)(uniquemember=%{control:Ldap-UserDn})))"
		membership_attribute = memberOf
		compare_check_items = yes
		do_xlat = yes
		access_attr_used_for_allow = yes
	}
	profile {
		filter = "(objectclass=user)"
	}

	tls {
		start_tls = no
		ca_file = /mycerts/fullchain.pem # CHANGE-THIS
		certificate_file = /mycerts/cert.pem # CHANGE-THIS
		private_key_file = /mycerts/privkey.pem # CHANGE-THIS
		random_file = /dev/urandom
		require_cert = "allow"
	}


#	valuepair_attribute = 'radiusAttribute'
	update {
		control:Auth-Type		:= 'radiusAuthType'
		control:Simultaneous-Use	:= 'radiusSimultaneousUse'
		control:Called-Station-Id	:= 'radiusCalledStationId'
		control:Calling-Station-Id	:= 'radiusCallingStationId'
		control:LM-Password		:= 'lmPassword'
		#control:NT-Password		:= 'ntPassword'
		control:LM-Password		:= 'sambaLmPassword'
		#control:NT-Password		:= 'sambaNtPassword'
		control:NT-Password		:= 'ipaNTHash'
		control:LM-Password		:= 'dBCSPwd'
		control:Password-With-Header	+= 'userPassword'
		control:SMB-Account-CTRL-TEXT	:= 'acctFlags'
		control:Expiration		:= 'radiusExpiration'
		control:NAS-IP-Address		:= 'radiusNASIpAddress'
		reply:Service-Type		:= 'radiusServiceType'
		reply:Framed-Protocol		:= 'radiusFramedProtocol'
		reply:Framed-IP-Address		:= 'radiusFramedIPAddress'
		reply:Framed-IP-Netmask		:= 'radiusFramedIPNetmask'
		reply:Framed-Route		:= 'radiusFramedRoute'
		reply:Framed-Routing		:= 'radiusFramedRouting'
		reply:Filter-Id			:= 'radiusFilterId'
		reply:Framed-MTU		:= 'radiusFramedMTU'
		reply:Framed-Compression	:= 'radiusFramedCompression'
		reply:Login-IP-Host		:= 'radiusLoginIPHost'
		reply:Login-Service		:= 'radiusLoginService'
		reply:Login-TCP-Port		:= 'radiusLoginTCPPort'
		reply:Callback-Number		:= 'radiusCallbackNumber'
		reply:Callback-Id		:= 'radiusCallbackId'
		reply:Framed-IPX-Network	:= 'radiusFramedIPXNetwork'
		reply:Class			:= 'radiusClass'
		reply:Session-Timeout		:= 'radiusSessionTimeout'
		reply:Idle-Timeout		:= 'radiusIdleTimeout'
		reply:Termination-Action	:= 'radiusTerminationAction'
		reply:Login-LAT-Service		:= 'radiusLoginLATService'
		reply:Login-LAT-Node		:= 'radiusLoginLATNode'
		reply:Login-LAT-Group		:= 'radiusLoginLATGroup'
		reply:Framed-AppleTalk-Link	:= 'radiusFramedAppleTalkLink'
		reply:Framed-AppleTalk-Network	:= 'radiusFramedAppleTalkNetwork'
		reply:Framed-AppleTalk-Zone	:= 'radiusFramedAppleTalkZone'
		reply:Port-Limit		:= 'radiusPortLimit'
		reply:Login-LAT-Port		:= 'radiusLoginLATPort'
		reply:Reply-Message		:= 'radiusReplyMessage'
		reply:Tunnel-Type		:= 'radiusTunnelType'
		reply:Tunnel-Medium-Type	:= 'radiusTunnelMediumType'
		reply:Tunnel-Private-Group-Id	:= 'radiusTunnelPrivateGroupId'
		control:			+= 'radiusControlAttribute'
		request:			+= 'radiusRequestAttribute'
		reply:				+= 'radiusReplyAttribute'
	}

	edir_account_policy_check = no

	options {
		idle = 60
		probes = 3
		interval = 3
### MS Active Directory Compatibility is disabled ###
		# ldap_debug = 0x0028
		res_timeout = 4
		srv_timelimit = 3
		net_timeout = 1
	}

	pool {
		start = 0
		min = 0
		max = 5
		spare = ${thread[pool].max_spare_servers}
		uses = 0
		retry_delay = 30
		lifetime = 0
		idle_timeout = 60
	}

#	accounting {
#		reference = "%{tolower:type.%{Acct-Status-Type}}"
#		type {
#			start {
#				update {
#					description := "Online at %S"
#				}
#			}
#			interim-update {
#				update {
#					description := "Last seen at %S"
#				}
#			}
#			stop {
#				update {
#					description := "Offline at %S"
#				}
#			}
#		}
#	}

	post-auth {
		update {
			description := "Authenticated at %S"
		}
	}
}

And finally, here is my /etc/freeradius/mods-enabled/mschap file. Nothing needs to be modified here.

# -*- text -*-
#
#  $Id: 5fbdcee67e8d2ac2033119b48e3f76999c8190be $

#
#  Microsoft CHAP authentication
#
#  This module supports MS-CHAP and MS-CHAPv2 authentication.
#  It also enforces the SMB-Account-Ctrl attribute.
#
mschap {
	#
	#  If you are using /etc/smbpasswd, see the 'passwd'
	#  module for an example of how to use /etc/smbpasswd
	#

	#
	#  If use_mppe is not set to no mschap, will
	#  add MS-CHAP-MPPE-Keys for MS-CHAPv1 and
	#  MS-MPPE-Recv-Key/MS-MPPE-Send-Key for MS-CHAPv2
	#
	use_mppe = no

	#
	#  If MPPE is enabled, require_encryption makes
	#  encryption moderate
	#
	require_encryption = yes

	#
	#  require_strong always requires 128 bit key
	#  encryption
	#
	require_strong = yes

	#
	#  This module can perform authentication itself, OR
	#  use a Windows Domain Controller.  This configuration
	#  directive tells the module to call the ntlm_auth
	#  program, which will do the authentication, and return
	#  the NT-Key.  Note that you MUST have "winbindd" and
	#  "nmbd" running on the local machine for ntlm_auth
	#  to work.  See the ntlm_auth program documentation
	#  for details.
	#
	#  If ntlm_auth is configured below, then the mschap
	#  module will call ntlm_auth for every MS-CHAP
	#  authentication request.  If there is a cleartext
	#  or NT hashed password available, you can set
	#  "MS-CHAP-Use-NTLM-Auth := No" in the control items,
	#  and the mschap module will do the authentication itself,
	#  without calling ntlm_auth.
	#
	#  This authentication can go wrong for a number of reasons:
	#    1) the user does not exist in AD
	#    2) the password entered by the user is not the same as
	#      what is in AD
	#    3) some magic MS-CHAP data is wrong.
	#
	#  These situations can be checked by running ntlm_auth
	#  from the command line with a name and a password:
	#
	#	ntlm_auth --username=NAME --password=PASSWORD
	#
	#  If that works, you know both that the user exists, and the
	#  password is correct.  You also know what AD expects for the
	#  username.
	#
	#  There is often confusion between different formats of the
	#  username.  Is it "user", or "user@domain" or "DOMAIN\\user"?
	#  The answer is "that depends on your local AD system".
	#
	#  One solution is to use this for the username:
	#
	#	... --username=%{mschap:User-Name} ...
	#
	#  In that case, the mschap module will look at the User-Name
	#  attribute, and do prefix/suffix checks in order to obtain
	#  the "best" user name for the request.
	#
	#  Another option is to use the Stripped-User-Name, as in the
	#  example configuration below.
	#
	#  You can test which format works by running the server in
	#  debug mode, and copying the hex strings from the
	#  --challenge=... and --nt-response=... output.
	#
	#  Then, run ntlm_auth from the command line, using the same
	#  command-line options as given below.  Since you can't
	#  change the challenge or nt-response strings, try changing
	#  the --username=... and --domain=... parameters.  Try
	#  different formats for them until one works.  There should only
	#  be a small number of variations possible.
	#
	#  That is the username and domain format which you need to
	#  configure here in this file.
	#
	#  For Samba 4, you should also set the "ntlm auth" parameter
	#  in the Samba configuration:
	#
	#	ntlm auth = yes
	#
	#  or
	#
	#	ntlm auth = mschapv2-and-ntlmv2-only
	#
	#  This will let Samba 4 accept the MS-CHAP authentication
	#  method that is needed by FreeRADIUS.
	#
	#  Depending on the Samba version, you may also need to add:
	#
	#	--allow-mschapv2
	#
	#  to the command-line parameters.
	#
#	ntlm_auth = "/path/to/ntlm_auth --request-nt-key --allow-mschapv2 --username=%{%{Stripped-User-Name}:-%{%{User-Name}:-None}} --challenge=%{%{mschap:Challenge}:-00} --nt-response=%{%{mschap:NT-Response}:-00}"

	#
	#  The default is to wait 10 seconds for ntlm_auth to
	#  complete.  This is a long time, and if it's taking that
	#  long then you likely have other problems in your domain.
	#  The length of time can be decreased with the following
	#  option, which can save clients waiting if your ntlm_auth
	#  usually finishes quicker. Range 1 to 10 seconds.
	#
#	ntlm_auth_timeout = 10

	#
	#  An alternative to using ntlm_auth is to connect to the
	#  winbind daemon directly for authentication. This option
	#  is likely to be faster and may be useful on busy systems,
	#  but is less well tested.
	#
	#  Using this option requires libwbclient from Samba 4.2.1
	#  or later to be installed. Make sure that ntlm_auth above is
	#  commented out.
	#
#	winbind_username = "%{mschap:User-Name}"
#	winbind_domain = "%{mschap:NT-Domain}"

	#
	#  When using single sign-on with a winbind connection and the
	#  client uses a different casing for the username than the
	#  casing is according to the backend, reauth may fail because
	#  of some Windows internals. This switch tries to find the
	#  user in the correct casing in the backend, and retry
	#  authentication with that username.
	#
#	winbind_retry_with_normalised_username = no

	#
	#  Information for the winbind connection pool.  The configuration
	#  items below are the same for all modules which use the new
	#  connection pool.
	#
	pool {
		#
		#  Connections to create during module instantiation.
		#  If the server cannot create specified number of
		#  connections during instantiation it will exit.
		#  Set to 0 to allow the server to start without the
		#  winbind daemon being available.
		#
		start = ${thread[pool].start_servers}

		#
		#  Minimum number of connections to keep open
		#
		min = ${thread[pool].min_spare_servers}

		#
		#  Maximum number of connections
		#
		#  If these connections are all in use and a new one
		#  is requested, the request will NOT get a connection.
		#
		#  Setting 'max' to LESS than the number of threads means
		#  that some threads may starve, and you will see errors
		#  like 'No connections available and at max connection limit'
		#
		#  Setting 'max' to MORE than the number of threads means
		#  that there are more connections than necessary.
		#
		max = ${thread[pool].max_servers}

		#
		#  Spare connections to be left idle
		#
		#  NOTE: Idle connections WILL be closed if "idle_timeout"
		#  is set.  This should be less than or equal to "max" above.
		#
		spare = ${thread[pool].max_spare_servers}

		#
		#  Number of uses before the connection is closed
		#
		#  0 means "infinite"
		#
		uses = 0

		#
		#  The number of seconds to wait after the server tries
		#  to open a connection, and fails.  During this time,
		#  no new connections will be opened.
		#
		retry_delay = 30

		#
		#  The lifetime (in seconds) of the connection
		#
		#  NOTE: A setting of 0 means infinite (no limit).
		#
		lifetime = 86400

		#
		#  The pool is checked for free connections every
		#  "cleanup_interval".  If there are free connections,
		#  then one of them is closed.
		#
		cleanup_interval = 300

		#
		#  The idle timeout (in seconds).  A connection which is
		#  unused for this length of time will be closed.
		#
		#  NOTE: A setting of 0 means infinite (no timeout).
		#
		idle_timeout = 600

		#
		#  NOTE: All configuration settings are enforced.  If a
		#  connection is closed because of "idle_timeout",
		#  "uses", or "lifetime", then the total number of
		#  connections MAY fall below "min".  When that
		#  happens, it will open a new connection.  It will
		#  also log a WARNING message.
		#
		#  The solution is to either lower the "min" connections,
		#  or increase lifetime/idle_timeout.
		#
	}

	passchange {
		#
		#  This support MS-CHAPv2 (not v1) password change
		#  requests.  See doc/mschap.rst for more IMPORTANT
		#  information.
		#
		#  Samba/ntlm_auth - if you are using ntlm_auth to
		#  validate passwords, you will need to use ntlm_auth
		#  to change passwords.  Uncomment the three lines
		#  below, and change the path to ntlm_auth.
		#
#		ntlm_auth = "/usr/bin/ntlm_auth --helper-protocol=ntlm-change-password-1"
#		ntlm_auth_username = "username: %{mschap:User-Name}"
#		ntlm_auth_domain = "nt-domain: %{mschap:NT-Domain}"

		#
		#  To implement a local password change, you need to
		#  supply a string which is then expanded, so that the
		#  password can be placed somewhere.  e.g. passed to a
		#  script (exec), or written to SQL (UPDATE/INSERT).
		#  We give both examples here, but only one will be
		#  used.
		#
#		local_cpw = "%{exec:/path/to/script %{mschap:User-Name} %{MS-CHAP-New-Cleartext-Password}}"
		#
#		local_cpw = "%{sql:UPDATE radcheck set value='%{MS-CHAP-New-NT-Password}' where username='%{SQL-User-Name}' and attribute='NT-Password'}"
	}

	#
	#  For Apple Server, when running on the same machine as
	#  Open Directory.  It has no effect on other systems.
	#
#	use_open_directory = yes

	#
	#  On failure, set (or not) the MS-CHAP error code saying
	#  "retries allowed".
	#
#	allow_retry = yes

	#
	#  An optional retry message.
	#
#	retry_msg = "Re-enter (or reset) the password"
}

The final step will be configuring Unifi (or another networking platform you're using) to connect to freeRADIUS. Doing so requires a configuration in freeRADIUS at /etc/freeradius/clients.conf to indicate what devices can connect, and what shared secret they should use to do so. Mine looks like this:

client usg {
	ipaddr = 192.168.32.1 # This is the docker networking gateway that freeRADIUS sees all incoming connections coming from in my setup.  I may add a proxy between so freeRADIUS can see the original IP address of the Unifi Security Gateway (USG) that initiates the connection.
	secret = <USE A GENERATOR TO CREATE A SECRET> # CHANGE-THIS
}

Finally, you'll need to configure Unifi (or your equivalent) to query the FreeRADIUS server for authentication. Ensure it is configured to require strong authentication (MSCHAPv2) from the user.

At this point, you can try initiating a connection to Unifi (or your equivalent). Monitor FreeRADIUS output for debugging.

@QuantumCorral
Copy link

Hi @Junto026

First of all, thank you for your tutorial! It has been incredibly helpful, and I’ve successfully implemented the expression policy in both the enrollment flow and user settings flow.

However, I’m running into an issue when using the password recovery flow. I get the following error:
Error Details
During the password recovery process, I receive the following traceback:
Traceback (most recent call last): File "default-PW-Hash", line 32, in <module> File "default-PW-Hash", line 30, in handler builtins.KeyError: 'prompt_data'

It seems like the password recovery flow does not provide prompt_data, which is available in the other flows. I’m not sure if this is expected behavior or if I’m missing something in my configuration.

Would you happen to know why this is happening? Is there a way to work around it or retrieve the necessary data differently in the password recovery flow?

I really appreciate any guidance you can provide. Thanks in advance for your help!

@Junto026
Copy link

@QuantumCorral I'm not sure. The original version PopcornPanda posted worked for the recovery flow for me. Perhaps share a screenshot of the stages in your password recovery flow, and an exact copy-paste of the expression policy you're using.

Assuming your password recovery flow has a prompt stage in it, also share a screenshot of the details of that prompt stage's configuration.

Finally, verify the expression policy is binded to the 'user write' stage (the very last stage) in the password recovery flow. Not an earlier stage in the flow.

@QuantumCorral
Copy link

QuantumCorral commented Feb 1, 2025

@Junto026 I appreciate your input. Here’s what I’ve checked so far:

  1. Password Recovery Flow Stages
  • I've verified that my password recovery flow has a prompt stage included.
  • Attached is a screenshot showing the full sequence of stages in my password recovery flow.

Image
Image

  1. Expression Policy
  • Below is an exact copy-paste of the expression policy I'm using:
import hashlib

def generate_ntlm_hash(password):
    """Generate an NTLM hash from the given password."""
    password_unicode = password.encode('utf-16le')
    return hashlib.new('md4', password_unicode).hexdigest()

def store_ntlm_hash(user, password):
    """Store the NTLM hash in the user's attributes."""
    if not user.attributes:
        user.attributes = {}
    user.attributes["ipaNTHash"] = generate_ntlm_hash(password)
    return user.save()

# Determine if the user is authenticated or pending
user = request.user if request.user and not request.user.is_anonymous else context.get("pending_user")

if user:
    # Extract the password from the context
    password = context.get("prompt_data", {}).get("password") or context.get("password")
    
    if password:
        store_ntlm_hash(user, password)
        return True
    else:
        ak_message("There is no new password value in context")
        return False
else:
    request.context["prompt_data"]["attributes.ipaNTHash"] = generate_ntlm_hash(request.context["prompt_data"]["password"])
    return True
  1. Prompt Stage Details
  • I’ve also attached a screenshot of the prompt stage's configuration to ensure it aligns with how it's supposed to work.

Image
Image

Despite these checks, I'm still encountering the error. Could you take a look and see if there's anything I'm missing? Any insights would be greatly appreciated!

Thanks in advance! :)

@Junto026
Copy link

Junto026 commented Feb 1, 2025

@QuantumCorral Everything looks lined up to me, except I notice in your recovery flow it is possible to go through the flow without doing the identification stage. Can you test your recovery flow, making sure you go through the identification stage?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

10 participants