OpenVPN: Add support for 2FA / One-Time Password

Add two-factor authentication (2FA) to OpenVPN host connections with
one-time passwords.

The 2FA can be enabled or disabled per host connection and requires the
client to download it's configuration again after 2FA has beend enabled
for it.
Additionally the client needs to configure an TOTP application, like
"Google Authenticator" which then provides the second factor.
To faciliate this every connection with enabled 2FA
gets an "show qrcode" button after the "show file" button in the
host connection list to show the 2FA secret and an 2FA configuration QRCode.

When 2FA is enabled, the client needs to provide the second factor plus
the private key password (if set) to successfully authorize.

This only supports time based one-time passwords, TOTP with 30s
window and 6 digits, for now but we may update this in the future.

Signed-off-by: Timo Eissler <timo.eissler@ipfire.org>
This commit is contained in:
Timo Eissler 2022-04-08 10:50:20 +02:00 committed by Michael Tremer
parent dc124917e3
commit e1e10515ec
9 changed files with 255 additions and 6 deletions

View File

@ -21,7 +21,7 @@
SSLCertificateKeyFile /etc/httpd/server-ecdsa.key
Header always set X-Content-Type-Options nosniff
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
Header always set Referrer-Policy strict-origin
Header always set X-Frame-Options sameorigin

View File

@ -7,7 +7,7 @@
RewriteRule .* - [F]
Header always set X-Content-Type-Options nosniff
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src: 'self' data:"
Header always set Referrer-Policy strict-origin
Header always set X-Frame-Options sameorigin

106
config/ovpn/otp-verify Normal file
View File

@ -0,0 +1,106 @@
#!/usr/bin/perl
############################################################################
# #
# This file is part of the IPFire Firewall. #
# #
# IPFire is free software; you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation; either version 2 of the License, or #
# (at your option) any later version. #
# #
# IPFire is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with IPFire; if not, write to the Free Software #
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA #
# #
# Copyright (C) 2022 IPFire Team <info@ipfire.org>. #
# #
############################################################################
use strict;
use warnings;
use MIME::Base64;
require '/var/ipfire/general-functions.pl';
my $cn;
my $prefix;
my $password;
my $otp;
my @valid_otps;
#&General::log("otp-verify DEBUG: ENV:common_name: $ENV{'common_name'}");
# line 1: <COMMON NAME>
# line 2: <CREDENTIALS> e.g.: SCRV1:cGFzc3dvcmQ=:ODg2MTM2
while(<>) {
#&General::log("otp-verify DEBUG: line: $_");
if ($_ =~ /^(?!SCRV[[:digit:]]).+/) {
chomp;
$cn = $_;
#$cn =~ s/\s*$//g;
}
if ($_ =~ /^SCRV[[:digit:]]:.+/) {
($prefix, $password, $otp) = split /:/;
$password = decode_base64($password);
$otp = decode_base64($otp);
}
}
if ($cn == "") {
#&General::log("otp-verify DEBUG: no credentials provided by client, setting CN from ENV.");
$cn = $ENV{'common_name'};
}
#&General::log("otp-verify DEBUG: CN: \"$cn\"\n");
#&General::log("otp-verify DEBUG: PW: \"$password\"\n");
#&General::log("otp-verify DEBUG: OTP: \"$otp\"\n");
#&General::log("otp-verify DEBUG: ----\n");
my %confighash = ();
if (-f "${General::swroot}/ovpn/ovpnconfig") {
&General::readhasharray("${General::swroot}/ovpn/ovpnconfig", \%confighash);
foreach my $key (keys %confighash){
if ($cn eq $confighash{$key}[2]) {
# Exit successfully for non-roadwarrior connections.
exit 0 unless ($confighash{$key}[3] eq "host");
# Exit successfully for disabled otp connections.
exit 0 unless (defined $confighash{$key}[43] and $confighash{$key}[43] eq "on");
# Exit with failure if required otp config is missing.
exit 1 if (not defined $confighash{$key}[42]);
exit 1 if (not defined $confighash{$key}[44]);
#&General::log("otp-verify DEBUG: connection key: $key\n");
#&General::log("otp-verify DEBUG: connection type: $confighash{$key}[3]\n");
#&General::log("otp-verify DEBUG: CN: $confighash{$key}[2]\n");
#&General::log("otp-verify DEBUG: otp Type: $confighash{$key}[42]\n");
#&General::log("otp-verify DEBUG: otp State: $confighash{$key}[43]\n");
#&General::log("otp-verify DEBUG: otp Secret: $confighash{$key}[44]\n");
# Get valid OTPs.
my @valid_otps = &General::system_output("/usr/bin/oathtool", "--totp", "-w", "3", "$confighash{$key}[44]");
foreach (@valid_otps) {
# Exit successfully if OTP is correct.
exit 0 if ($otp == $_)
}
# Exit with failure if no matching OTP was found.
exit 1;
}
}
} else {
# Return an error if ovpnconfig could not be found.
exit 1;
}
# Exit successfully if no auth-user-pass data received.
exit 0;
# vim: ts=3 sts=3 sw=3 et nu list

View File

@ -38,8 +38,8 @@ require "${General::swroot}/countries.pl";
require "${General::swroot}/location-functions.pl";
# enable only the following on debugging purpose
#use warnings;
#use CGI::Carp 'fatalsToBrowser';
use warnings;
use CGI::Carp 'fatalsToBrowser';
#workaround to suppress a warning when a variable is used only once
my @dummy = ( ${Header::colourgreen}, ${Header::colourblue} );
undef (@dummy);
@ -372,6 +372,9 @@ sub writeserverconf {
}
print CONF "tls-verify /usr/lib/openvpn/verify\n";
print CONF "crl-verify /var/ipfire/ovpn/crls/cacrl.pem\n";
print CONF "auth-user-pass-verify \"/usr/lib/openvpn/otp-verify\" via-file\n";
print CONF "auth-user-pass-optional\n";
print CONF "reneg-sec 86400\n";
print CONF "user nobody\n";
print CONF "group nobody\n";
print CONF "persist-key\n";
@ -2430,6 +2433,17 @@ else
if ($vpnsettings{FRAGMENT} ne '' && $vpnsettings{DPROTOCOL} ne 'tcp' ) {
print CLIENTCONF "fragment $vpnsettings{'FRAGMENT'}\r\n";
}
if ($confighash{$cgiparams{'KEY'}}[43] eq 'on') {
print CLIENTCONF "auth-nocache\r\n";
print CLIENTCONF "auth-user-pass credentials\r\n";
print CLIENTCONF "static-challenge \"One Time Password (OTP): \" 1\r\n";
open(CLIENTCREDS, ">$tempdir/credentials") or die "Unable to open tempfile: $!";
print CLIENTCREDS "user\r\n";
print CLIENTCREDS "password";
close(CLIENTCREDS);
$zip->addFile( "$tempdir/credentials", "credentials") or die "Can't add file credentials\n";
}
if ($include_certs) {
print CLIENTCONF "\r\n";
@ -2617,6 +2631,48 @@ else
exit(0);
}
###
### Display OTP QRCode
###
} elsif ($cgiparams{'ACTION'} eq $Lang::tr{'show otp qrcode'}) {
&General::readhasharray("${General::swroot}/ovpn/ovpnconfig", \%confighash);
use MIME::Base32;
use MIME::Base64;
use Imager::QRCode;
my $qrcode = Imager::QRCode->new(
size => 6,
margin => 0,
version => 0,
level => 'M',
mode => '8-bit',
casesensitive => 1,
lightcolor => Imager::Color->new(255, 255, 255),
darkcolor => Imager::Color->new(0, 0, 0),
);
my $cn = $confighash{$cgiparams{'KEY'}}[2];
my $secret = encode_base32($confighash{$cgiparams{'KEY'}}[44]);
my $issuer = "$mainsettings{'HOSTNAME'}.$mainsettings{'DOMAINNAME'}";
my $qrcodeimg = $qrcode->plot("otpauth://totp/$cn?secret=$secret&issuer=$issuer");
my $qrcodeimgdata;
$qrcodeimg->write(data => \$qrcodeimgdata, type=> 'png')
or die $qrcodeimg->errstr;
$qrcodeimgdata = encode_base64($qrcodeimgdata, '');
&Header::showhttpheaders();
&Header::openpage($Lang::tr{'ovpn'}, 1, '');
&Header::openbigbox('100%', 'LEFT', '', '');
&Header::openbox('100%', 'LEFT', "$Lang::tr{'otp qrcode'}:");
print <<END;
$Lang::tr{'secret'}:&nbsp;$secret</br></br>
<img alt="$Lang::tr{'otp qrcode'}" src="data:image/png;base64,$qrcodeimgdata">
END
&Header::closebox();
print "<div align='center'><a href='/cgi-bin/ovpnmain.cgi'>$Lang::tr{'back'}</a></div>";
&Header::closebigbox();
&Header::closepage();
exit(0);
###
### Display Diffie-Hellman key
###
@ -3660,6 +3716,7 @@ if ($confighash{$cgiparams{'KEY'}}) {
$cgiparams{'DAUTH'} = $confighash{$cgiparams{'KEY'}}[39];
$cgiparams{'DCIPHER'} = $confighash{$cgiparams{'KEY'}}[40];
$cgiparams{'TLSAUTH'} = $confighash{$cgiparams{'KEY'}}[41];
$cgiparams{'OTP_STATE'} = $confighash{$cgiparams{'KEY'}}[43];
} elsif ($cgiparams{'ACTION'} eq $Lang::tr{'save'}) {
$cgiparams{'REMARK'} = &Header::cleanhtml($cgiparams{'REMARK'});
@ -4422,6 +4479,15 @@ if ($cgiparams{'TYPE'} eq 'net') {
$confighash{$key}[41] = "no-pass";
}
$confighash{$key}[42] = 'HOTP/T30/6';
$confighash{$key}[43] = $cgiparams{'OTP_STATE'};
if (($confighash{$key}[43] == 'on') && ($confighash{$key}[44] == '')) {
my @otp_secret = &General::system_output("/usr/bin/openssl", "rand", "-hex", "20");
$confighash{$key}[44] = $otp_secret[0];
} elsif ($confighash{$key}[43] == '') {
$confighash{$key}[44] = '';
}
&General::writehasharray("${General::swroot}/ovpn/ovpnconfig", \%confighash);
if ($cgiparams{'CHECK1'} ){
@ -4835,6 +4901,7 @@ if ($cgiparams{'TYPE'} eq 'host') {
print"</td></tr></table><br><br>";
my $name=$cgiparams{'CHECK1'};
$checked{'RG'}{$cgiparams{'RG'}} = 'CHECKED';
$checked{'OTP_STATE'}{$cgiparams{'OTP_STATE'}} = 'CHECKED';
if (! -z "${General::swroot}/ovpn/ccd.conf"){
print"<table border='0' width='100%' cellspacing='1' cellpadding='0'><tr><td width='1%'></td><td width='30%' class='boldbase' align='center'><b>$Lang::tr{'ccd name'}</td><td width='15%' class='boldbase' align='center'><b>$Lang::tr{'network'}</td><td class='boldbase' align='center' width='18%'><b>$Lang::tr{'ccd clientip'}</td></tr>";
@ -4970,6 +5037,7 @@ if ($cgiparams{'TYPE'} eq 'host') {
print <<END;
<table border='0' width='100%'>
<tr><td width='20%'>$Lang::tr{'enable otp'}:</td><td colspan='3'><input type='checkbox' name='OTP_STATE' $checked{'OTP_STATE'}{'on'} /></td></tr>
<tr><td width='20%'>Redirect Gateway:</td><td colspan='3'><input type='checkbox' name='RG' $checked{'RG'}{'on'} /></td></tr>
<tr><td colspan='4'><b><br>$Lang::tr{'ccd routes'}</b></td></tr>
<tr><td colspan='4'>&nbsp</td></tr>
@ -5413,7 +5481,7 @@ END
<th width='15%' class='boldbase' align='center'><b>$Lang::tr{'type'}</b></th>
<th width='20%' class='boldbase' align='center'><b>$Lang::tr{'remark'}</b></th>
<th width='10%' class='boldbase' align='center'><b>$Lang::tr{'status'}</b></th>
<th width='5%' class='boldbase' colspan='7' align='center'><b>$Lang::tr{'action'}</b></th>
<th width='5%' class='boldbase' colspan='8' align='center'><b>$Lang::tr{'action'}</b></th>
</tr>
END
}
@ -5427,7 +5495,7 @@ END
<th width='15%' class='boldbase' align='center'><b>$Lang::tr{'type'}</b></th>
<th width='20%' class='boldbase' align='center'><b>$Lang::tr{'remark'}</b></th>
<th width='10%' class='boldbase' align='center'><b>$Lang::tr{'status'}</b></th>
<th width='5%' class='boldbase' colspan='7' align='center'><b>$Lang::tr{'action'}</b></th>
<th width='5%' class='boldbase' colspan='8' align='center'><b>$Lang::tr{'action'}</b></th>
</tr>
END
}
@ -5560,6 +5628,19 @@ END
; } else {
print "<td>&nbsp;</td>";
}
if ($confighash{$key}[43] eq 'on') {
print <<END;
<form method='post' name='frm${key}o'><td align='center' $col>
<input type='image' name='$Lang::tr{'show otp qrcode'}' src='/images/qr-code.png' alt='$Lang::tr{'show otp qrcode'}' title='$Lang::tr{'show otp qrcode'}' border='0' />
<input type='hidden' name='ACTION' value='$Lang::tr{'show otp qrcode'}' />
<input type='hidden' name='KEY' value='$key' />
</td></form>
END
; } else {
print "<td $col>&nbsp;</td>";
}
if ($confighash{$key}[4] eq 'cert' && -f "${General::swroot}/ovpn/certs/$confighash{$key}[1].p12") {
print <<END;
<form method='post' name='frm${key}c'><td align='center' $col>
@ -5628,6 +5709,8 @@ END
<td class='base'>$Lang::tr{'download certificate'}</td>
<td>&nbsp; &nbsp; <img src='/images/openvpn.png' alt='?RELOAD'/></td>
<td class='base'>$Lang::tr{'dl client arch'}</td>
<td>&nbsp; &nbsp; <img src='/images/qr-code.png' alt='$Lang::tr{'show otp qrcode'}'/></td>
<td class='base'>$Lang::tr{'show otp qrcode'}</td>
</tr>
</table><br>
END

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<path d="M0,0v233.739h233.739V0H0z M200.348,200.348H33.391V33.391h166.957V200.348z"/>
<rect x="66.783" y="66.783" width="100.174" height="100.174"/>
<path d="M278.261,0v233.739H512V0H278.261z M478.609,200.348H311.652V33.391h166.957V200.348z"/>
<rect x="345.043" y="66.783" width="100.174" height="100.174"/>
<path d="M0,278.261V512h233.739V278.261H0z M200.348,478.609H33.391V311.652h166.957V478.609z"/>
<rect x="66.783" y="345.043" width="100.174" height="100.174"/>
<polygon points="278.261,278.261 278.261,512 345.043,512 345.043,478.609 311.652,478.609 311.652,411.826 345.043,411.826
345.043,378.435 311.652,378.435 311.652,311.652 345.043,311.652 345.043,278.261 "/>
<rect x="478.609" y="278.261" width="33.391" height="33.391"/>
<polygon points="478.609,478.609 445.217,478.609 445.217,512 512,512 512,356.174 478.609,356.174 "/>
<rect x="378.435" y="278.261" width="66.783" height="33.391"/>
<polygon points="445.217,411.826 411.826,411.826 411.826,378.435 445.217,378.435 445.217,345.043 378.435,345.043
378.435,445.217 445.217,445.217 "/>
<rect x="378.435" y="478.609" width="33.391" height="33.391"/>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -979,6 +979,7 @@
'empty profile' => 'Unbenannt',
'enable ignore filter' => '&quot;Ignorieren&quot;-Filter ein',
'enable javascript' => 'Javascript aktivieren',
'enable otp' => 'Aktiviere OTP',
'enable smt' => 'Simultaneous Multi-Threading (SMT) einschalten',
'enable wildcards' => 'Wildcards erlauben:',
'enabled' => 'Aktiviert:',
@ -1903,6 +1904,7 @@
'other login script' => 'Anderes Anmeldeskript',
'otherip' => 'Andere IP',
'otherport' => 'Anderer Port',
'otp qrcode' => 'OTP QRCode',
'our donors' => 'Unsere Unterstützer',
'out' => 'Aus',
'outgoing' => 'ausgehend',
@ -2201,6 +2203,7 @@
'secondary ntp server' => 'Sekundärer NTP-Server',
'secondary wins server address' => 'Sekundärer WINS-Server',
'seconds' => 'Sek.',
'secret' => 'Geheimnis',
'section' => 'Abschnitt',
'secure shell server' => 'Secure Shell Server',
'security' => 'Sicherheit',
@ -2244,6 +2247,7 @@
'show last x lines' => 'die letzten x Zeilen anzeigen',
'show root certificate' => 'Root-Zertifikat anzeigen',
'show share options' => 'Anzeige der Freigabeeinstellungen',
'show otp qrcode' => 'Zeige OTP QRCode',
'shuffle' => 'Zufall',
'shutdown' => 'Herunterfahren',
'shutdown ask' => 'Herunterfahren?',

View File

@ -1018,6 +1018,7 @@
'empty' => 'This field may be left blank',
'empty profile' => 'empty',
'enable' => 'Enable',
'enable otp' => 'Enable OTP',
'enable ignore filter' => 'Enable ignore filter',
'enable javascript' => 'Enable javascript',
'enable smt' => 'Enable Simultaneous Multi-Threading (SMT)',
@ -1955,6 +1956,7 @@
'other login script' => 'Other login script',
'otherip' => 'other IP',
'otherport' => 'other Port',
'otp qrcode' => 'OTP QRCode',
'our donors' => 'Our donors',
'out' => 'Out',
'outgoing' => 'outgoing',
@ -2253,6 +2255,7 @@
'secondary ntp server' => 'Secondary NTP server',
'secondary wins server address' => 'Secondary WINS server address',
'seconds' => 'Secs',
'secret' => 'Secret',
'section' => 'Section',
'secure shell server' => 'Secure Shell Server',
'security' => 'Security',
@ -2297,6 +2300,7 @@
'show host certificate' => 'Show host certificate',
'show last x lines' => 'Show last x lines',
'show lines' => 'Show lines',
'show otp qrcode' => 'Show OTP QRCode',
'show root certificate' => 'Show root certificate',
'show share options' => 'Show shares options',
'show tls-auth key' => 'Show tls-auth key',

View File

@ -96,6 +96,9 @@ $(TARGET) : $(patsubst %,$(DIR_DL)/%,$(objects))
mv -v /var/ipfire/ovpn/verify /usr/lib/openvpn/verify
chown root:root /usr/lib/openvpn/verify
chmod 755 /usr/lib/openvpn/verify
mv -v /var/ipfire/ovpn/otp-verify /usr/lib/openvpn/otp-verify
chown root:root /usr/lib/openvpn/otp-verify
chmod 755 /usr/lib/openvpn/otp-verify
# Add crl updater
mv -v /var/ipfire/ovpn/openvpn-crl-updater /etc/fcron.daily
chown root:root /etc/fcron.daily/openvpn-crl-updater