goauthentik / authentik

The authentication glue you need.
https://goauthentik.io
Other
13.31k stars 889 forks source link

NTLM Hash Attribute in LDAP Outpost? #8768

Closed jcrapuchettes closed 7 months ago

jcrapuchettes commented 7 months ago

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

Version and Deployment (please complete the following information):

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 commented 7 months ago

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

rissson commented 7 months ago

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 commented 7 months ago

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 commented 7 months ago

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 commented 7 months ago

@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 commented 7 months ago

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

ZUCCzwp commented 6 months ago

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

jcrapuchettes commented 6 months ago

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

CppBunny commented 5 months ago

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

jcrapuchettes commented 5 months ago

@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 commented 5 months ago

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 commented 5 months ago

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

MarcoTribuz commented 4 months ago

@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 commented 3 months ago

Hi,

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

PopcornPanda commented 1 month ago

@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 commented 1 month ago

@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 commented 3 weeks ago

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.