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
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]))
Code: Select all
$sharedsecret = (Get-ADUser $username -Properties HomePage).HomePage
Code: Select all
if ($sharedsecret -ne "")
{
$totp = & $totpscript -sharedSecret $sharedsecret | Select OTP
}
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
Code: Select all
$base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
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
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.