]> git.ipfire.org Git - ipfire-2.x.git/commitdiff
OpenVPN: Add support for 2FA / One-Time Password
authorTimo Eissler <timo.eissler@ipfire.org>
Fri, 8 Apr 2022 08:50:20 +0000 (10:50 +0200)
committerMichael Tremer <michael.tremer@ipfire.org>
Fri, 17 Jun 2022 10:20:17 +0000 (10:20 +0000)
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>
config/httpd/vhosts.d/ipfire-interface-ssl.conf
config/httpd/vhosts.d/ipfire-interface.conf
config/ovpn/otp-verify [new file with mode: 0644]
html/cgi-bin/ovpnmain.cgi
html/html/images/qr-code.png [new file with mode: 0644]
html/html/images/qr-code.svg [new file with mode: 0644]
langs/de/cgi-bin/de.pl
langs/en/cgi-bin/en.pl
lfs/openvpn

index 8c4cf3806922b88454934e055d935c0798f07eb3..639f1d479689e4df397d2339bf7dcb77cc281af2 100644 (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
 
index 2cf57dd29637d5aebcb49d718e3ea250012654a4..caa4b92f0f6cb5dcea8a1bb2b4325846fa17e7d5 100644 (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
 
diff --git a/config/ovpn/otp-verify b/config/ovpn/otp-verify
new file mode 100644 (file)
index 0000000..80a1a1a
--- /dev/null
@@ -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
index c2558bd81ea4f0b7bdb63fc1ce9f64f8fa70d661..01cbf6833853c6f935076361890ad10e215e7c83 100644 (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
diff --git a/html/html/images/qr-code.png b/html/html/images/qr-code.png
new file mode 100644 (file)
index 0000000..946e10a
Binary files /dev/null and b/html/html/images/qr-code.png differ
diff --git a/html/html/images/qr-code.svg b/html/html/images/qr-code.svg
new file mode 100644 (file)
index 0000000..66c6b9d
--- /dev/null
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="iso-8859-1"?>\r
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->\r
+<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"\r
+        viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">\r
+<path d="M0,0v233.739h233.739V0H0z M200.348,200.348H33.391V33.391h166.957V200.348z"/>\r
+<rect x="66.783" y="66.783" width="100.174" height="100.174"/>\r
+<path d="M278.261,0v233.739H512V0H278.261z M478.609,200.348H311.652V33.391h166.957V200.348z"/>\r
+<rect x="345.043" y="66.783" width="100.174" height="100.174"/>\r
+<path d="M0,278.261V512h233.739V278.261H0z M200.348,478.609H33.391V311.652h166.957V478.609z"/>\r
+<rect x="66.783" y="345.043" width="100.174" height="100.174"/>\r
+<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 \r
+       345.043,378.435 311.652,378.435 311.652,311.652 345.043,311.652 345.043,278.261 "/>\r
+<rect x="478.609" y="278.261" width="33.391" height="33.391"/>\r
+<polygon points="478.609,478.609 445.217,478.609 445.217,512 512,512 512,356.174 478.609,356.174 "/>\r
+<rect x="378.435" y="278.261" width="66.783" height="33.391"/>\r
+<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 \r
+       378.435,445.217 445.217,445.217 "/>\r
+<rect x="378.435" y="478.609" width="33.391" height="33.391"/>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+</svg>\r
index 7a39e233b415e4f8100e705812de2e0637cb1610..1799d8c74306a482b30826d6806c08101d8526ec 100644 (file)
 '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:',
 'other login script' => 'Anderes Anmeldeskript',
 'otherip' => 'Andere IP',
 'otherport' => 'Anderer Port',
+'otp qrcode' => 'OTP QRCode',
 'our donors' => 'Unsere Unterstützer',
 'out' => 'Aus',
 'outgoing' => 'ausgehend',
 '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',
 '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?',
index f90e3103bca21b1d734fb174aa0eebc2b7b86453..9cc2fde0503a934d5541ff5e17c5702e46995191 100644 (file)
 '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)',
 'other login script' => 'Other login script',
 'otherip' => 'other IP',
 'otherport' => 'other Port',
+'otp qrcode' => 'OTP QRCode',
 'our donors' => 'Our donors',
 'out' => 'Out',
 'outgoing' => 'outgoing',
 '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',
 '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',
index 27a052ae152847dbafe4650c08b1b22119397cbb..29c5f4a2a76ee3583689389517ee90601904d7d3 100644 (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