Skip to content
 

OpenVPN LDAP authentication

Task: make sure that users connecting to the VPN server are authorized, that is, belong to a certain group in the LDAP database (say, "vpnauth"), which will be assumed to be a Windows AD controller here, although the idea should be applicable to other directory servers.

From a quick search, it seems that the most common way to authenticate OpenVPN users against LDAP is the openvpn-auth-ldap module. It does work, however it's a bit clumsy to set up. There seems to be another LDAP authentication plugin floating around (here), which looks a bit better, but on the downside it looks like there are no binary packages available. In any case, I have not tried it.

But here we're going to make things simpler and use a simple script to do the authentication. The original source of inspiration was here (spanish), which in turn got it from here. In all cases, the idea is, as said, to check that the user is member of a specific group (and while doing so, also confirm that the username and password that the user supplied are correct).

For all these examples to work, the client configuration file needs to include the auth-user-pass option so the user is prompted for username and password when starting the connection (graphical tools like the windows GUI or NetworkManager also have ways to prompt the user for the same information).

Another thing to note is that, in addition to the classical distinguished name (DN) traditionally used to bind against LDAP, Microsoft LDAP also allows binding using the UPN (user@example.com) and the older EXAMPLE\user format (source: this excellent post). The two alternate forms are useful because they don't depend on where in the LDAP tree the user is, an information that instead is embedded in the DN and would make programmatic DN construction a bit difficult if connecting users belong to different OUs: it wouldn't be possible to just concatenate the username with some other fixed part. In these examples, we're going to use the user@example.com form (the UPN).

So here's a slightly refactored Perl script that can be used to check that the connecting user is authorized:

#!/usr/bin/perl
 
use warnings;
use strict;
 
use Sys::Syslog qw(:standard :macros);
use Net::LDAP;
 
my $facility = LOG_AUTH;
my $ourname = 'ldap_auth.pl';
 
my $ldapserver = 'windowsdc';
my $domain = 'example.com';
my $vpngroup = 'vpnauth';
 
# base DN for the search; adjust the code if the vpn group isn't directly in here.
my $basedn = 'ou=Users,dc=example,dc=com';
 
my $ldap_uri = "ldap://${ldapserver}.${domain}";
 
# these are passed by OpenVPN
my $username = $ENV{'username'};
my $password = $ENV{'password'};
 
openlog($ourname, 'nofatal,pid', $facility);
 
my @filter = ( "(sAMAccountName=${username})",
               "(memberOf=cn=${vpngroup},${basedn})",
               '(accountStatus=active)',
             );
 
# bind as the authenticating user
my $bindname = $username . '@' . $domain;
 
syslog(LOG_INFO, "Attempting to authenticate user $username ($bindname)");
 
my $ldap;
 
if (not ($ldap = Net::LDAP->new($ldap_uri))) {
  syslog(LOG_ERR, "Connect to $ldap_uri failed, error: %m");
  closelog();
  exit 1;
}
 
my $result = $ldap->bind($bindname, password => $password);
 
if ($result->code()) {
  syslog(LOG_ERR, "LDAP binding failed (wrong user/password?), error: " . $result->error);
  closelog();
  exit 1;
}
 
$result = $ldap->search( base => $basedn, filter => "(&" . join("", @filter) . ")" );
 
if ($result->code()) {
  syslog(LOG_ERR, "LDAP search failed, error: " . $result->error);
  closelog();
  exit 1;
}
 
my $count = $result->count();
 
if ($count == 1) {
  syslog(LOG_INFO, "User $username authenticated successfully");
} else {
  syslog(LOG_ERR, "User $username not authenticated (user not in group?)");
}
 
closelog();
 
exit ($count == 1 ? 0 : 1);

The script needs the Net::LDAP module to run (under Debian, it's called libnet-ldap-perl).
What it does is bind against the LDAP server using the given username and password (this fails if the password or the username is not correct), and then performs an LDAP query to verify that the user is active and is a member of the specified group (that is, that the query returns one element as the result).

If you're lazy or don't want to mess around with Perl, here's the bash version of the same logic:

#!/bin/bash
 
# passed by openvpn (in the environment):
#
# $username
# $password
 
ourname=ldap_auth.sh
facility=auth
 
ldapserver=windowsdc
domain=example.com
 
vpngroup=vpnauth
basedn='ou=Users,dc=example,dc=com'
 
bindname=${username}@${domain}
 
declare -a query
 
# writing the query this way is useful because it's easier to
# include it in the log; using an array instead of a string is
# safer, see http://mywiki.wooledge.org/BashFAQ/050
 
query[0]='-L'
query[1]='-s'
query[2]='sub'
query[3]='-x'
query[4]='-w'
query[5]="${password}"
query[6]='-D'
query[7]="${bindname}"
query[8]='-b'
query[9]="${basedn}"
query[10]='-H'
query[11]="ldap://${ldapserver}.${domain}"
query[12]="(&(sAMAccountName=${username})(memberOf=cn=${vpngroup},${basedn})(accountStatus=active))"
query[13]='dn'
 
output=$(mktemp)
error=$(mktemp)
 
# clean temp files when we terminate
trap "rm -f ${output} ${error}" EXIT
 
logger -p "${facility}.info" -t "$ourname" "Trying to authenticate user ${bindname} against AD"
 
ldapsearch "${query[@]}" 1>"${output}" 2>"${error}"
 
# save exist status here, otherwise the following assignment resets $?
status=$?
 
query[5]='xxxxxxxxx'   # obfuscate password to put query in the logs
 
if [ $status -ne 0 ]; then
  logger -p "${facility}.err" -t "${ourname}" "There was an error authenticating user ${username} (${bindname}) against AD."
  logger -p "${facility}.err" -t "${ourname}" "The query was: ldapsearch ${query[*]}"
  logger -p "${facility}.err" -t "${ourname}" "The error was: $(tr '\n' ' ' < "${error}" )"  # turn multiline into single line
  exit 1
fi
 
# look for the "numEntries" line in the output of ldapsearch
numentries=$(awk '/numEntries:/{ne = $3} END{print ne + 0}' "$output")
 
if [ $numentries -eq 1 ]; then
  logger -p "${facility}.info" -t "{$ourname}" "User ${username} authenticated successfully"
  exit 0
else
  logger -p "${facility}.err" -t "${ourname}" "User ${username} NOT authenticated (user not in group?)"
  logger -p "${facility}.err" -t "${ourname}" "The query was: ldapsearch ${query[*]}"
  exit 1
fi

This version, of course, needs the ldapsearch tool (under Debian, part of the ldap-utils package).

In both cases, the OpenVPN server needs to be told about the script and to use it with the option

auth-user-pass-verify /path/to/the/script via-env

The via-env bit is what tells OpenVPN to pass the user credentials to the script via environment variables; another possibility is to use via-file, which instead puts them into a file, whose name is communicated to the script. All the details are in the man page for OpenVPN. An important detail is that if using via-env, we need to set script-security 3 in the server configuration file, whereas with via-file, script-security 2 is enough. It's trivial to modify the scripts to read from the file if using the via-file method.

The good thing about using the scripts is that the user-supplied credentials are used to perform the operations, so no sensitive password has to be stored in the script. On the other hand, the plugin-based solutions use a predefined user (well, distinguished name) and password, whose values need to be put in the plugin configuration file.

Some final notes: the scripts use syslog to log their progress (using the "auth" facility), and it's easy to extend them to check for membership of any group from a list (they can be ORed together in the query), or membership of more than one group (they can be ANDed); if doing so, the test that checks whether the LDAP query returned exactly one entry has to be adjusting accordingly, of course.

Be Sociable, Share!

2 Comments

  1. Manu says:

    with ldapsearch you may prefer to -j instead of -w :

    -j file read bind passwd (for simple authentication)
    -w passwd bind passwd (for simple authentication)

    So no need to obfuscate anything and a simple :

    ps -ef

    don'l let people sharing with you the server discover your password

    • waldner says:

      My version of ldapsearch doesn't have the -j option, however I see it does have a -y file option which I suppose will do something very similar.

      However, since then ps -ef would show the name of the file where you stored the password, it should be made readable only by root or by the user running the openVPN script.

Leave a Reply

(required)