a293fff996
Webmin now uses the bundled QRCode::Encoder implementation by default to generate TOTP QR codes locally and inline, without relying on qr.cgi or external services. This encoder requires Perl 5.24 or newer, so qrencode is no longer included as a recommended package dependency. On older systems where the bundled encoder cannot run, admins can still install qrencode manually to restore QR generation support. Systems old enough to lack Perl 5.24 are typically already well past their supported security lifecycle, so keeping qrencode preinstalled by default is no longer treated as a packaging requirement. When neither option is available, Webmin falls back cleanly to manual TOTP setup using the shared secret. https://github.com/webmin/webmin/issues/2667#issuecomment-4247431279 [no-build]
403 lines
11 KiB
Perl
403 lines
11 KiB
Perl
# Functions for two-factor enrollment and verification
|
|
|
|
# list_twofactor_providers()
|
|
# Returns a list of all supported providers, each of which is an array ref
|
|
# containing an ID, name and URL for more info
|
|
sub list_twofactor_providers
|
|
{
|
|
return ( [ 'totp', $text{'twofactor_totp'},
|
|
'https://en.wikipedia.org/wiki/Time-based_one-time_password' ],
|
|
[ 'authy', 'Authy',
|
|
'http://www.authy.com/' ] );
|
|
}
|
|
|
|
# show_twofactor_apikey_authy(&miniserv)
|
|
# Returns HTML for the form for authy-specific provider inputs
|
|
sub show_twofactor_apikey_authy
|
|
{
|
|
my ($miniserv) = @_;
|
|
my $rv;
|
|
$rv .= ui_table_row($text{'twofactor_apikey'},
|
|
ui_textbox("authy_apikey", $miniserv->{'twofactor_apikey'}, 40));
|
|
return $rv;
|
|
}
|
|
|
|
# validate_twofactor_apikey_authy(&in, &miniserv)
|
|
# Validates inputs from show_twofactor_apikey_authy, and stores them. Returns
|
|
# undef if OK, or an error message on failure
|
|
sub validate_twofactor_apikey_authy
|
|
{
|
|
my ($in, $miniserv) = @_;
|
|
my $key = $in->{'authy_apikey'};
|
|
my $test = $miniserv->{'twofactor_test'};
|
|
$key =~ /^\S+$/ || return $text{'twofactor_eapikey'};
|
|
my $host = $test ? "sandbox-api.authy.com" : "api.authy.com";
|
|
my $port = $test ? 80 : 443;
|
|
my $page = "/protected/xml/app/details?api_key=".&urlize($key);
|
|
my $ssl = $test ? 0 : 1;
|
|
my ($out, $err);
|
|
&http_download($host, $port, $page, \$out, \$err, undef, $ssl, undef, undef,
|
|
60, 0, 1);
|
|
if ($err =~ /401/) {
|
|
return $text{'twofactor_eauthykey'};
|
|
}
|
|
elsif ($err) {
|
|
return &text('twofactor_eauthy', $err);
|
|
}
|
|
$miniserv->{'twofactor_apikey'} = $key;
|
|
return undef;
|
|
}
|
|
|
|
# show_twofactor_form_authy(&webmin-user)
|
|
# Returns HTML for a form for enrolling for Authy two-factor
|
|
sub show_twofactor_form_authy
|
|
{
|
|
my ($user) = @_;
|
|
my $rv;
|
|
$rv .= &ui_table_row($text{'twofactor_email'},
|
|
&ui_textbox("email", undef, 40));
|
|
$rv .= &ui_table_row($text{'twofactor_country'},
|
|
&ui_textbox("country", undef, 3));
|
|
$rv .= &ui_table_row($text{'twofactor_phone'},
|
|
&ui_textbox("phone", undef, 20));
|
|
return $rv;
|
|
}
|
|
|
|
# parse_twofactor_form_authy(&in, &user)
|
|
# Parses inputs from show_twofactor_form_authy, and returns a hash ref with
|
|
# enrollment details on success, or an error message on failure.
|
|
sub parse_twofactor_form_authy
|
|
{
|
|
my ($in, $user) = @_;
|
|
$in->{'email'} =~ /^\S+\@\S+$/ || return $text{'twofactor_eemail'};
|
|
$in->{'country'} =~ s/^\+//;
|
|
$in->{'country'} =~ /^\d{1,3}$/ || return $text{'twofactor_ecountry'};
|
|
$in->{'phone'} =~ /^[0-9\- ]+$/ || return $text{'twofactor_ephone'};
|
|
return { 'email' => $in->{'email'},
|
|
'country' => $in->{'country'},
|
|
'phone' => $in->{'phone'} };
|
|
}
|
|
|
|
# enroll_twofactor_authy(&details, &user)
|
|
# Attempts to enroll a user for Authy two-factor. Returns undef on success and
|
|
# sets twofactor_id in &user, or an error message on failure.
|
|
sub enroll_twofactor_authy
|
|
{
|
|
my ($details, $user) = @_;
|
|
my %miniserv;
|
|
&get_miniserv_config(\%miniserv);
|
|
my $host = $miniserv{'twofactor_test'} ? "sandbox-api.authy.com"
|
|
: "api.authy.com";
|
|
my $port = $miniserv{'twofactor_test'} ? 80 : 443;
|
|
my $page = "/protected/xml/users/new?api_key=".
|
|
&urlize($miniserv{'twofactor_apikey'});
|
|
my $ssl = $miniserv{'twofactor_test'} ? 0 : 1;
|
|
my $content = "user[email]=".&urlize($details->{'email'})."&".
|
|
"user[country_code]=".&urlize($details->{'country'})."&".
|
|
"user[cellphone]=".&urlize($details->{'phone'});
|
|
my ($out, $err);
|
|
&http_post($host, $port, $page, $content, \$out, \$err, undef, $ssl, undef,
|
|
undef, 60, 0, 1);
|
|
return $err if ($err);
|
|
if ($out =~ /<id[^>]*>([^<]+)<\/id>/i) {
|
|
$user->{'twofactor_id'} = $1;
|
|
$user->{'twofactor_apikey'} = $miniserv{'twofactor_apikey'};
|
|
return undef;
|
|
}
|
|
else {
|
|
return &text('twofactor_eauthyenroll',
|
|
"<pre>".&html_escape($out)."</pre>");
|
|
}
|
|
}
|
|
|
|
# validate_twofactor_authy(id, token, apikey)
|
|
# Checks the validity of some token for a user ID
|
|
sub validate_twofactor_authy
|
|
{
|
|
my ($id, $token, $apikey) = @_;
|
|
$id =~ /^\d+$/ || return $text{'twofactor_eauthyid'};
|
|
$token =~ /^\d+$/ || return $text{'twofactor_eauthytoken'};
|
|
my %miniserv;
|
|
&get_miniserv_config(\%miniserv);
|
|
my $host = $miniserv{'twofactor_test'} ? "sandbox-api.authy.com"
|
|
: "api.authy.com";
|
|
my $port = $miniserv{'twofactor_test'} ? 80 : 443;
|
|
my $page = "/protected/xml/verify/$token/$id?api_key=".&urlize($apikey).
|
|
"&force=true";
|
|
my $ssl = $miniserv{'twofactor_test'} ? 0 : 1;
|
|
my ($out, $err);
|
|
&http_download($host, $port, $page, \$out, \$err, undef, $ssl, undef, undef,
|
|
60, 0, 1);
|
|
if ($err && $err =~ /401/) {
|
|
# Token rejected
|
|
return $text{'twofactor_eauthyotp'};
|
|
}
|
|
elsif ($err) {
|
|
# Some other error
|
|
return $err;
|
|
}
|
|
elsif ($out && $out =~ /<success[^>]*>([^<]+)<\/success>/i) {
|
|
if (lc($1) eq "true") {
|
|
# Worked!
|
|
return undef;
|
|
}
|
|
elsif ($out =~ /<message[^>]*>([^<]+)<\/message>/i) {
|
|
# Failed, but with a message
|
|
return $1;
|
|
}
|
|
else {
|
|
# Failed, not sure why
|
|
return $out;
|
|
}
|
|
}
|
|
else {
|
|
# Unknown output
|
|
return $out;
|
|
}
|
|
}
|
|
|
|
# validate_twofactor_apikey_totp()
|
|
# Checks that the needed Perl module for TOTP is installed.
|
|
sub validate_twofactor_apikey_totp
|
|
{
|
|
return undef;
|
|
}
|
|
|
|
# show_twofactor_form_totp(&user)
|
|
# Show form allowing the user to choose a twofactor secret
|
|
sub show_twofactor_form_totp
|
|
{
|
|
my ($user) = @_;
|
|
my $secret = $user->{'twofactor_id'};
|
|
$secret = undef if ($secret !~ /^[A-Z0-9=]+$/i ||
|
|
(length($secret) != 16 && length($secret) != 26 && length($secret) != 32));
|
|
my $rv;
|
|
$rv .= &ui_table_row($text{'twofactor_secret'},
|
|
&ui_opt_textbox("totp_secret", $secret, 20, $text{'twofactor_secret1'},
|
|
$text{'twofactor_secret0'}));
|
|
return $rv;
|
|
}
|
|
|
|
# parse_twofactor_form_totp(&in, &user)
|
|
# Generate or use a secret key for this user
|
|
sub parse_twofactor_form_totp
|
|
{
|
|
my ($in, $user) = @_;
|
|
if ($in->{'totp_secret_def'}) {
|
|
$user->{'twofactor_id'} = &encode_base32(&generate_base32_secret());
|
|
}
|
|
else {
|
|
$in{'totp_secret'} =~ /^[A-Z0-9=]{16}$/i ||
|
|
return $text{'twofactor_esecret'};
|
|
$user->{'twofactor_id'} = $in{'totp_secret'};
|
|
}
|
|
return { };
|
|
}
|
|
|
|
# generate_base32_secret([length])
|
|
# Returns a base-32 encoded secret of by default 10 bytes
|
|
sub generate_base32_secret
|
|
{
|
|
my ($length) = @_;
|
|
$length ||= 10;
|
|
&seed_random();
|
|
my $secret = "";
|
|
while(length($secret) < $length) {
|
|
$secret .= chr(rand()*256);
|
|
}
|
|
return $secret;
|
|
}
|
|
|
|
# enroll_twofactor_totp(&in, &user)
|
|
# Generate a secret for this user, based-32 encoded
|
|
sub enroll_twofactor_totp
|
|
{
|
|
my ($in, $user) = @_;
|
|
$user->{'twofactor_id'} ||= &encode_base32(&generate_base32_secret());
|
|
return undef;
|
|
}
|
|
|
|
# message_twofactor_totp(&user)
|
|
# Returns HTML to display after a user enrolls
|
|
sub message_twofactor_totp
|
|
{
|
|
my ($user) = @_;
|
|
my $name = &get_display_hostname()." (".$user->{'name'}.")";
|
|
my $str = "otpauth://totp/".$name."?secret=".$user->{'twofactor_id'};
|
|
my ($qrimg, $mime) = &generate_qr_code($str, 6);
|
|
if ($qrimg) {
|
|
my $qrcode = &ui_tag('p',
|
|
&text('twofactor_qrcode', "<tt>$user->{'twofactor_id'}</tt>"));
|
|
my $src = "data:$mime;base64,".&encode_base64($qrimg, 'noeol');
|
|
my $img = &ui_tag('img', undef,
|
|
{ 'src' => $src, 'border' => 0,
|
|
'style' => 'width:180px; height:180px; '.
|
|
'border:1px solid #444;',
|
|
'alt' => 'QR code' });
|
|
return <<EOF;
|
|
$qrcode$img<p>
|
|
EOF
|
|
}
|
|
else {
|
|
return <<EOF;
|
|
<p>@{[ &text('twofactor_qrcode_manual',
|
|
"<tt>$user->{'twofactor_id'}</tt>") ]}</p>
|
|
<p>
|
|
EOF
|
|
}
|
|
}
|
|
|
|
# validate_twofactor_totp(id, token)
|
|
# Checks the validity of some token with TOPT
|
|
sub validate_twofactor_totp
|
|
{
|
|
my ($id, $token) = @_;
|
|
$id =~ /^[A-Z0-9=]+$/i || return $text{'twofactor_etotpid'};
|
|
$id = &decode_base32($id);
|
|
$token =~ /^\d+$/ || return $text{'twofactor_etotptoken'};
|
|
eval "use lib (\"$root_directory/vendor_perl\")";
|
|
eval "use Digest::HMAC_SHA1 qw/ hmac_sha1 /;";
|
|
my $now = time();
|
|
my $totp = sub {
|
|
my ($secret, $time) = @_;
|
|
|
|
# Compute HMAC-SHA1
|
|
my $data = pack('H*', sprintf("%016x", int($time / 30)));
|
|
my $packed_key = pack('H*', unpack("H*", $secret));
|
|
my $hmac = hmac_sha1($data, $packed_key);
|
|
|
|
# Convert HMAC to hexadecimal
|
|
my $hmac_hex = unpack("H*", $hmac);
|
|
|
|
# Generate the TOTP
|
|
my $offset = hex(substr($hmac_hex, -1));
|
|
my $part1 = hex(substr($hmac_hex, $offset * 2, 8));
|
|
my $part2 = hex("7fffffff");
|
|
return substr(($part1 & $part2), -6);
|
|
};
|
|
foreach my $t ($now - 30, $now, $now + 30) {
|
|
my $expected = $totp->($id, $t);
|
|
return undef if ($expected eq $token);
|
|
}
|
|
return $text{'twofactor_etotpmatch'};
|
|
}
|
|
|
|
# get_user_twofactor(username, &miniserv)
|
|
# Returns the twofactor provider, ID and API key for a user
|
|
sub get_user_twofactor
|
|
{
|
|
my ($user, $miniserv) = @_;
|
|
return () if (!$miniserv->{'twofactorfile'});
|
|
my $lref = &read_file_lines($miniserv->{'twofactorfile'}, 1);
|
|
foreach my $l (@$lref) {
|
|
my @two = split(/:/, $l, -1);
|
|
if ($two[0] eq $user) {
|
|
return ($two[1], $two[2], $two[3]);
|
|
}
|
|
}
|
|
return ();
|
|
}
|
|
|
|
# save_user_twofactor(username, &miniserv, [provider, id, api-key])
|
|
# Updates or removes the twofactor provider for a user
|
|
sub save_user_twofactor
|
|
{
|
|
my ($user, $miniserv, $prov, $id, $key) = @_;
|
|
return 0 if (!$miniserv->{'twofactorfile'});
|
|
&lock_file($miniserv->{'twofactorfile'});
|
|
my $lref = &read_file_lines($miniserv->{'twofactorfile'});
|
|
my $found = 0;
|
|
my $i = 0;
|
|
foreach my $l (@$lref) {
|
|
my @two = split(/:/, $l, -1);
|
|
if ($two[0] eq $user) {
|
|
# Found the line to update or remove
|
|
if ($prov) {
|
|
$lref->[$i] = join(":", $user, $prov, $id, $key);
|
|
}
|
|
else {
|
|
splice(@$lref, $i, 1);
|
|
}
|
|
$found++;
|
|
last;
|
|
}
|
|
$i++;
|
|
}
|
|
if (!$found && $prov) {
|
|
# Need to add the user
|
|
push(@$lref, join(":", $user, $prov, $id, $key));
|
|
}
|
|
&flush_file_lines($miniserv->{'twofactorfile'});
|
|
&unlock_file($miniserv->{'twofactorfile'});
|
|
}
|
|
|
|
# can_generate_qr_encoder()
|
|
# Returns 1 if the local QRCode::Encoder fallback can be used
|
|
sub can_generate_qr_encoder
|
|
{
|
|
eval "use lib (\"$root_directory/vendor_perl\")";
|
|
eval "use QRCode::Encoder qw(qr_encode)";
|
|
return $@ ? 0 : 1;
|
|
}
|
|
|
|
# generate_qr_code(string, [block-size])
|
|
# Turn a string into a QR code image, and returns the data and MIME type
|
|
sub generate_qr_code
|
|
{
|
|
my ($str, $size) = @_;
|
|
if (&can_generate_qr_encoder()) {
|
|
my @rv = eval { &generate_qr_code_encoder($str, $size) };
|
|
return @rv if (!$@ && defined($rv[0]));
|
|
}
|
|
if (&has_command("qrencode")) {
|
|
# Use the qrencode shell command
|
|
my $cmd = "qrencode -o - -t PNG ".quotemeta($str);
|
|
$cmd .= " -s ".quotemeta($size) if ($size);
|
|
my ($out, $err);
|
|
my $ex = &execute_command($cmd, undef, \$out, \$err);
|
|
if ($ex) {
|
|
return (undef, $err);
|
|
}
|
|
return ($out, "image/png");
|
|
}
|
|
return (undef, "QR code generation requires either the qrencode command or ".
|
|
"QRCode::Encoder Perl module");
|
|
}
|
|
|
|
# generate_qr_code_encoder(string, [block-size])
|
|
# Turn a string into a QR code SVG using the local QRCode::Encoder fallback
|
|
sub generate_qr_code_encoder
|
|
{
|
|
my ($str, $size) = @_;
|
|
eval "use lib (\"$root_directory/vendor_perl\")";
|
|
eval "use QRCode::Encoder qw(qr_encode)";
|
|
if ($@) {
|
|
return (undef, "QR code generation requires the ".
|
|
"QRCode::Encoder Perl module");
|
|
}
|
|
my $encoded = qr_encode($str, level => 'M');
|
|
my $matrix = $encoded->{'matrix'};
|
|
my $mod_size = $size || 6;
|
|
my $count = scalar(@$matrix);
|
|
my $dim = $count * $mod_size;
|
|
my $svg = qq{<svg xmlns="http://www.w3.org/2000/svg" }.
|
|
qq{viewBox="0 0 $dim $dim" }.
|
|
qq{width="$dim" height="$dim">};
|
|
$svg .= qq{<rect width="$dim" height="$dim" fill="white"/>};
|
|
for my $y (0 .. $#$matrix) {
|
|
for my $x (0 .. $#{$matrix->[$y]}) {
|
|
if ($matrix->[$y][$x] & 1) {
|
|
my $px = $x * $mod_size;
|
|
my $py = $y * $mod_size;
|
|
$svg .= qq{<rect x="$px" y="$py" }.
|
|
qq{width="$mod_size" }.
|
|
qq{height="$mod_size"/>};
|
|
}
|
|
}
|
|
}
|
|
$svg .= "</svg>";
|
|
return ($svg, "image/svg+xml");
|
|
}
|
|
|
|
1;
|