TOTP on Windows based server

Scripts which allow the use of special authentication methods (LDAP, AD, MySQL/PostgreSQL, etc).

Moderators: TinCanTech, TinCanTech, TinCanTech, TinCanTech, TinCanTech, TinCanTech

Post Reply
bjd223
OpenVpn Newbie
Posts: 1
Joined: Thu Apr 19, 2018 2:26 pm

TOTP on Windows based server

Post by bjd223 » Fri Apr 12, 2019 5:05 pm

Hello,

I implemented a TOTP code into my Windows based OpenVPN server. There are not a lot of examples beyond Linux modules/scripts for this so thought I would post it to see if it helps someone else.

My environment is a Windows 2012R2 server with the community edition of OpenVPN installed. I am running AD-DS which is what authenticates the user. There is no extra software installed to make this work, it is all powershell scripts. You should already have a working OpenVPN config that uses username/password authentication with an external script before trying this.

First you need to add the following directive to your Server config, so the user is prompted to enter a TOTP code with their username and password.

Code: Select all

static-challenge "2FA Code" 0
Then everything else is in the authentication script you use. After you add that directive the username/password is converted into a new format so it needs to be converted back so you can pass it to AD for authentication. Here is an example in PS how to convert this encoded string back to plain text.

Code: Select all

#read openvpn authentication
$creds = gc $authfile
$username = $creds[0]
$passenc = $creds[1].Replace("SCRV1:","")
$passenc = $passenc.Split(":")
$password = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($passenc[0]))
$twofacode = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($passenc[1]))
The next hurdle is storing the users shared secret somewhere. I put it in AD under the Web page section under the general tab of the user in question. I do this because I didn't want to extend the AD schema and this is at my house, so it doesn't really matter if other people can see it. You might want to store it somewhere else or encrypt it. Anyway here is the PS code to read the shared secret out of the Web page field in AD.

Code: Select all

$sharedsecret = (Get-ADUser $username -Properties HomePage).HomePage
Now that you have the shared secret for the user, you need to calculate the current TOTP code so we can match it against whatever the user enters.

Code: Select all

if ($sharedsecret -ne "")
{
    $totp = & $totpscript -sharedSecret $sharedsecret | Select OTP
}
The $totp script contains

Code: Select all

# .\Get-OTP.ps1 -sharedSecret "blahblahblahblah" | Select SharedSecret, Key, Time, HMAC, OTP

[CmdletBinding()]
param
(
    [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
    [ValidateLength(16,16)]
    [string]$sharedSecret
)

function Convert-DecimalToHex($in)
{
    return ([String]("{0:x}" -f [Int64]$in)).ToUpper()
}

function Convert-HexToDecimal($in)
{
    return [Convert]::ToInt64($in,16)
}

function Convert-HexStringToByteArray($String)
{
    return $String -split '([A-F0-9]{2})' | foreach-object { if ($_) {[System.Convert]::ToByte($_,16)}}
}

function Convert-Base32ToHex([String]$base32)
{
    $base32 = $base32.ToUpper()
    $base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
    $bits = ""
    $hex = ""

    foreach ($char in $base32.ToCharArray())
    {
        $tmp = $base32chars.IndexOf($char)
        $bits = $bits + (([Convert]::ToString($tmp,2))).PadLeft(5,"0")
    }
    
    while ($bits.Length % 4 -ne 0)
    {
        $bits = "0" + $bits
    }

    for (($tmp = $bits.Length -4); $tmp -ge 0; $tmp = $tmp - 4)
    {
        $chunk = $bits.Substring($tmp, 4);
        $dec = [Convert]::ToInt32($chunk,2)
        $h = Convert-DecimalToHex $dec
        $hex = $h + $hex  
    }

    return $hex
}

function Get-EpochHex()
{
    $unixEpoch = ([DateTime]::Now.ToUniversalTime().Ticks - 621355968000000000) / 10000000
    $h = Convert-DecimalToHex ([Math]::Floor($unixEpoch / 30))
    return $h.PadLeft(16,"0")
}

function Get-HMAC($key, $time)
{
    $hashAlgorithm = New-Object System.Security.Cryptography.HMACSHA1
    $hashAlgorithm.key = Convert-HexStringToByteArray $key
    $signature = $hashAlgorithm.ComputeHash((Convert-HexStringToByteArray $time))
    $result = [string]::join("", ($signature | % {([int]$_).toString('x2')}))
    $result = $result.ToUpper()
    return $result
}

function Get-OTPFromHMAC($hmac)
{
    $offset = Convert-HexToDecimal($hmac.Substring($hmac.Length -1))
    $p1 = Convert-HexToDecimal($hmac.Substring($offset*2,8))
    $p2 = Convert-HexToDecimal("7fffffff")
    [string]$otp = $p1 -band $p2
    $otp =  $otp.Substring($otp.Length - 6, 6)
    return $otp
}

$reportObject = New-Object PSObject –Property @{'SharedSecret' = "";'Key' = "";'Time' = "";'HMAC' = "";'OTP' = ""}

$key  = Convert-Base32ToHex $sharedSecret
$time = Get-EpochHex
$hmac = Get-HMAC $key $time
$otp  = Get-OTPFromHMAC $hmac

$reportObject.SharedSecret = $sharedSecret
$reportObject.Key = $key
$reportObject.Time = $time
$reportObject.HMAC = $hmac
$reportObject.OTP = $otp

$reportObject
This is a few different scripts combined that I found online. I can't find the (main) source to attribute but can say it does work. Just make sure your TOTP is exactly 16 characters long and it can ONLY contain a-z or 2-7 as seen by this line

Code: Select all

$base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
If you include anything else it will give you bad codes. So don't do that...

Now it just comes down to comparing the current TOTP to the user's TOTP in the authentication script before authorizing them. The way I wrote it is so that if the Web page section is empty in AD, then then TOTP for that account is disabled.

Here is my complete AD auth script for reference. This checks a lot of stuff before letting the user login, you might want to strip some of that stuff out.

If you are trying to run this on a non server Windows OS I think you will have to install the powershell AD modules from Microsoft. I don't remember installing them on S2012R2 but I do see them under roles and features so you may need to add those before it works on server also.

Code: Select all

param
(
    [string]$authfile
)

$FQDN = "my.domain"
$totpscript = "C:\Users\Public\Public Scripts\get-otp.ps1"
$logfile = "C:\Users\Public\Public Scripts\adauth.log"
$x509whitelist = "C:\Users\Public\Public Scripts\adauth_x509.whitelist"
$ipblacklist = "C:\Users\Public\Public Scripts\adauth_ip.blacklist"
$logfilemax = 1000
$group = "VPN to Domain"

#read openvpn authentication
$creds = gc $authfile
$username = $creds[0]
$passenc = $creds[1].Replace("SCRV1:","")
$passenc = $passenc.Split(":")
$password = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($passenc[0]))
$twofacode = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($passenc[1]))
$sharedsecret = (Get-ADUser $username -Properties HomePage).HomePage

if ($sharedsecret -ne "")
{
    $totp = & $totpscript -sharedSecret $sharedsecret | Select OTP
}

#openvpn env variables
$cn = $env:common_name;
$ip = $env:untrusted_ip;

#authenticate the username/password to domain
function AD-Auth([String]$uid, [String]$pwd)
{
    Add-Type -AssemblyName System.DirectoryServices.AccountManagement
    $au = New-Object System.DirectoryServices.AccountManagement.PrincipalContext('domain', $FQDN)
    $au.ValidateCredentials($uid, $pwd)
}

#write script output to logfile
function Write-Log ([string]$message, [int]$typeofmsg)
{
    $datelog = Get-Date -Format 'MM/dd/yyyy hh:mm:ss tt'
    
    switch ($typeofmsg)
    {
        00 { $errlvl = "info " }
        01 { $errlvl = "ERROR" }
    }

    $logheader = "          DATE            TYPE    USER   X509NAME   IP ADDRESS     MESSAGE"
    $temp = "[$datelog] [$errlvl] [$username] [$cn] [$ip] $message"
    $contents = Get-Content $logfile
    $counter = 0

    foreach ($line in $contents)
    {
        if ($counter -lt $logfilemax - 2 -and $counter -gt 0)
        {
            $parsed += $line + "`r`n"
        }
        $counter++
    }
    [system.io.file]::WriteAllText($logfile, $logheader + "`r`n" + $temp + "`r`n" + $parsed)
}

#check if client cert is allowed
function Check-x509 ([string]$certname)
{
    $arrClient = Get-Content $x509whitelist

    foreach ($wlClient in $arrClient)
    {
        if ($certname -eq $wlClient)
        {
            return $true
        }
    }
    return $false
}

#check if ip is banned
function Check-IP ([string]$ip)
{
    $arrip = Get-Content $ipblacklist
    
    foreach ($blip in $arrip)
    {
        if ($ip -eq $blip)
        {
            return $false
        }
    }
    return $true
}

#check if cert name is whitelisted
if (Check-x509 $cn)
{
    #check if ip is blacklisted
    if (Check-IP $ip)
    {
        #check if acct is enabled
        if((Get-Aduser $username -Properties LockedOut).Enabled)
        {
            #check is acct is locked
            if (-Not(Get-Aduser $username -Properties LockedOut).LockedOut)
            {
                #check if they are a member of the AD group
                $members = Get-ADGroupMember -Identity $group -Recursive | Select -ExpandProperty SAMAccountName

                if ($members -contains $username)
                {
                    if (Ad-Auth $username $password)
                    {
                        if(($sharedsecret -eq $null) -or ($sharedsecret -eq ""))
                        {
                            #authenticate user and return 0 (success)
                            Write-Log "Authentication successful without TOTP" 0
                            exit 0
                        }
                        else
                        {
                            if ($twofacode -eq $totp.OTP.ToString())
                            {
                                #authenticate user and return 0 (success)
                                Write-Log "Authentication successful with TOTP match" 0
                                exit 0
                            }
                            else
                            {
                                $temp = $totp.OTP.ToString()
                                Write-Log "Two factor codes do not match. Directory=$temp User=$twofacode" 1
                            } 
                        }
                    }
                    else
                    {
                        Write-Log "Active Directory authentication has failed" 1
                    }
                }
                else
                {
                    Write-Log "User cannot login because they are not a member of the [$group] AD group" 1
                }
            }
            else
            {
                Write-Log "User cannot login because AD account is locked" 1
            }
        }
        else
        {
            Write-Log "User cannot login because AD account is disabled" 1
        }
    }
    else
    {
        Write-Log "Client's IP address is blacklisted" 1
    }
}
else
{
    Write-Log "Client's x509 cert name was not found in the whitelist" 1
}

#default output return 1 (fail) to ovpn
exit 1
Obviously you will need to create/name the files to match the variables at the top, or change those variables to match whatever you are doing. Also this is using some of the OpenVPN environmental variables to check IP and Cert name so if you also want to do that you will have to add the corresponding stuff in your server config to pass that.

I am currently using this at my house with no issues but probably isn't anywhere near production ready. I am using the Microsoft Authenticator app on Android I just manually type in the 16 digit code to add it. It should also work in the Google Authenticator app also.

Combined with certs this should be just about as secure as it could get. Requiring cert, cert name in whitelist, IP not in blacklist, user in VPN AD group, current AD username/password, and current TOTP code.

Post Reply