From e1e10515ece3bbe51936d572f32b14f02db6750d Mon Sep 17 00:00:00 2001 From: Timo Eissler Date: Fri, 8 Apr 2022 10:50:20 +0200 Subject: [PATCH] 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 --- .../httpd/vhosts.d/ipfire-interface-ssl.conf | 2 +- config/httpd/vhosts.d/ipfire-interface.conf | 2 +- config/ovpn/otp-verify | 106 ++++++++++++++++++ html/cgi-bin/ovpnmain.cgi | 91 ++++++++++++++- html/html/images/qr-code.png | Bin 0 -> 760 bytes html/html/images/qr-code.svg | 49 ++++++++ langs/de/cgi-bin/de.pl | 4 + langs/en/cgi-bin/en.pl | 4 + lfs/openvpn | 3 + 9 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 config/ovpn/otp-verify create mode 100644 html/html/images/qr-code.png create mode 100644 html/html/images/qr-code.svg diff --git a/config/httpd/vhosts.d/ipfire-interface-ssl.conf b/config/httpd/vhosts.d/ipfire-interface-ssl.conf index 8c4cf38069..639f1d4796 100644 --- a/config/httpd/vhosts.d/ipfire-interface-ssl.conf +++ b/config/httpd/vhosts.d/ipfire-interface-ssl.conf @@ -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 diff --git a/config/httpd/vhosts.d/ipfire-interface.conf b/config/httpd/vhosts.d/ipfire-interface.conf index 2cf57dd296..caa4b92f0f 100644 --- a/config/httpd/vhosts.d/ipfire-interface.conf +++ b/config/httpd/vhosts.d/ipfire-interface.conf @@ -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 diff --git a/config/ovpn/otp-verify b/config/ovpn/otp-verify new file mode 100644 index 0000000000..80a1a1a044 --- /dev/null +++ b/config/ovpn/otp-verify @@ -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 . # +# # +############################################################################ + +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: +# line 2: 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 diff --git a/html/cgi-bin/ovpnmain.cgi b/html/cgi-bin/ovpnmain.cgi index c2558bd81e..01cbf68338 100644 --- a/html/cgi-bin/ovpnmain.cgi +++ b/html/cgi-bin/ovpnmain.cgi @@ -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 <
+$Lang::tr{'otp qrcode'} +END + &Header::closebox(); + print ""; + &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"

"; my $name=$cgiparams{'CHECK1'}; $checked{'RG'}{$cgiparams{'RG'}} = 'CHECKED'; + $checked{'OTP_STATE'}{$cgiparams{'OTP_STATE'}} = 'CHECKED'; if (! -z "${General::swroot}/ovpn/ccd.conf"){ print""; @@ -4970,6 +5037,7 @@ if ($cgiparams{'TYPE'} eq 'host') { print < + @@ -5413,7 +5481,7 @@ END - + END } @@ -5427,7 +5495,7 @@ END - + END } @@ -5560,6 +5628,19 @@ END ; } else { print ""; } + + if ($confighash{$key}[43] eq 'on') { + print < +END +; } else { + print ""; + } + if ($confighash{$key}[4] eq 'cert' && -f "${General::swroot}/ovpn/certs/$confighash{$key}[1].p12") { print < + +
$Lang::tr{'ccd name'}$Lang::tr{'network'}$Lang::tr{'ccd clientip'}
$Lang::tr{'enable otp'}:
Redirect Gateway:

$Lang::tr{'ccd routes'}
 
$Lang::tr{'type'} $Lang::tr{'remark'} $Lang::tr{'status'}$Lang::tr{'action'}$Lang::tr{'action'}
$Lang::tr{'type'} $Lang::tr{'remark'} $Lang::tr{'status'}$Lang::tr{'action'}$Lang::tr{'action'}
  + + + +  @@ -5628,6 +5709,8 @@ END $Lang::tr{'download certificate'}     ?RELOAD $Lang::tr{'dl client arch'}    $Lang::tr{$Lang::tr{'show otp qrcode'}

END diff --git a/html/html/images/qr-code.png b/html/html/images/qr-code.png new file mode 100644 index 0000000000000000000000000000000000000000..946e10a2a5202e614d3628ac1463b772d752e903 GIT binary patch literal 760 zc-rd>@N?(olHy`uVBq!ia0vp^A|TAc0wmQNuC@UwmUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VW5Sk21sKVxC%v=9~KU4N5-fpMazi(`n!`QFRtyL%HQj(t4;d0u#5vBoJUO5 z>;5oyMc&Y;?wxC7X6(IKO?v4R4Qc63i%xc()F|@lbvdW;)3kR{r{lT8d*3(CFwS4T za^=qVcfa0={eJcDTg9$Vixyk%Et?s;<#V@1b3FTVRjbA;sxM0-^j$#k(*@J}TDR<(`g$5ylZ@kprBdBN z=|zFgX~*=VRqTE**Pd~8O6i(Bt7Q-8nd((MnzoA3c-_)#Qf9a8g4Z2iFlka~+ud)6 zHC2OW`7K)%UiZp5$;bDao5jP$XZtj=t6pYz-MMGBW-9BUNw%+c&G{U6*2;XE(&j@8 zgp2miEssC?Ybo1owmVZI-gZ6S#M<)sT!+xsM~imudjIO&s*2p#Hiv5e{a`Y?p(q+y zbYn}tT}9SpW~RVpp(Xb|inhso@$5gn=!g8J!YM1}eZOi7j4#y^*NBpo#FA92UW`PKttorh>vbj|tpB>li#; L{an^LB{Ts5rGG6& literal 0 Hc-jL100001 diff --git a/html/html/images/qr-code.svg b/html/html/images/qr-code.svg new file mode 100644 index 0000000000..66c6b9d17a --- /dev/null +++ b/html/html/images/qr-code.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/langs/de/cgi-bin/de.pl b/langs/de/cgi-bin/de.pl index 7a39e233b4..1799d8c743 100644 --- a/langs/de/cgi-bin/de.pl +++ b/langs/de/cgi-bin/de.pl @@ -979,6 +979,7 @@ 'empty profile' => 'Unbenannt', 'enable ignore filter' => '"Ignorieren"-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?', diff --git a/langs/en/cgi-bin/en.pl b/langs/en/cgi-bin/en.pl index f90e3103bc..9cc2fde050 100644 --- a/langs/en/cgi-bin/en.pl +++ b/langs/en/cgi-bin/en.pl @@ -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', diff --git a/lfs/openvpn b/lfs/openvpn index 27a052ae15..29c5f4a2a7 100644 --- a/lfs/openvpn +++ b/lfs/openvpn @@ -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 -- 2.39.2