From 00a007da16f7400d7a4016d4c0772890e077d172 Mon Sep 17 00:00:00 2001 From: Rene Krenn Date: Wed, 21 May 2025 19:19:54 +0200 Subject: [PATCH] MT#62568 subscriber 2FA impl Change-Id: Iada4c3a6b0d61babe50178b31c773f5b5897c8c1 --- lib/NGCP/Panel/Controller/API/OTPSecret.pm | 68 ++++++-- lib/NGCP/Panel/Controller/Login.pm | 92 ++++++++--- lib/NGCP/Panel/Controller/Root.pm | 27 +++- lib/NGCP/Panel/Utils/Auth.pm | 172 ++++++++++++++++++++- 4 files changed, 314 insertions(+), 45 deletions(-) diff --git a/lib/NGCP/Panel/Controller/API/OTPSecret.pm b/lib/NGCP/Panel/Controller/API/OTPSecret.pm index 87eb144e43..8fe4c1abc3 100644 --- a/lib/NGCP/Panel/Controller/API/OTPSecret.pm +++ b/lib/NGCP/Panel/Controller/API/OTPSecret.pm @@ -9,9 +9,9 @@ use HTTP::Status qw(:constants); __PACKAGE__->set_config({ GET => { - 'ReturnContentType' => ['image/png'],#, + 'ReturnContentType' => [ 'image/png', 'text/plain' ],#, }, - allowed_roles => [qw/admin reseller ccareadmin ccare/], + allowed_roles => [qw/admin reseller ccareadmin ccare subscriberadmin subscriber/], }); sub allowed_methods { @@ -27,37 +27,75 @@ sub resource_name { } sub item_by_id_valid { + my ($self, $c) = @_; my $item_rs = $self->item_rs($c); my $item = $item_rs->first; $self->error($c, HTTP_BAD_REQUEST, "no OTP") unless $item; return $item; + } sub _item_rs { my ($self, $c) = @_; - my $where; - my $item_rs = $c->model('DB')->resultset('admins')->search({ - -and => [ + my $item_rs; + + if ($c->user->auth_realm =~ /admin/) { + $item_rs = $c->model('DB')->resultset('admins')->search({ + -and => [ + id => $c->user->id, + enable_2fa => 1, + show_otp_registration_info => 1, + \[ 'length(`me`.`otp_secret`) > ?', '0' ], + ] + },{ + }); + } elsif ($c->user->auth_realm =~ /subscriber/) { + $item_rs = $c->model('DB')->resultset('provisioning_voip_subscribers')->search({ id => $c->user->id, - enable_2fa => 1, - show_otp_registration_info => 1, - \[ 'length(`me`.`otp_secret`) > ?', '0' ], - ] - },{ - #'+select' => { '' => \[ 'length(`me`.`otp_secret`)' ], -as => 'otp_secret_length' }, - #select => [ { length => 'otp_secret' } ], - #s => [ 'otp_secret_length' ], - }); + },{ + }); + + my $show = (NGCP::Panel::Utils::Auth::get_subscriber_enable_2fa($c,$item_rs->first) + and NGCP::Panel::Utils::Auth::get_subscriber_show_otp_registration_info($c,$item_rs->first) ? 1 : 0); + + $item_rs = $item_rs->search({ + -and => [ + \[ '1 = ?', $show ], + ], + },{ + }); + } return $item_rs; } -sub get_item_binary_data{ +sub return_requested_type { + + my ($self, $c, $id, $item, $return_type) = @_; + + #$c->log->debug("return_requested_type: " . $return_type); + + if ($return_type eq 'text/plain') { + $c->response->status(200); + $c->response->content_type($return_type); + $c->response->body(NGCP::Panel::Utils::Auth::get_otp_secret($c,$item)); + return; + } elsif ($return_type eq 'image/png') { + return NGCP::Panel::Role::API::return_requested_type($self, $c, $id, $item, $return_type); + } else { + $self->error($c, HTTP_BAD_REQUEST, 'unsupported accept content type'); + } + +} + +sub get_item_binary_data { my($self, $c, $id, $item, $return_type) = @_; + #$c->log->debug("get_item_binary_data"); + my $data = NGCP::Panel::Utils::Auth::generate_otp_qr($c,$item); my $t = time(); diff --git a/lib/NGCP/Panel/Controller/Login.pm b/lib/NGCP/Panel/Controller/Login.pm index 1b0b00f1b6..6d8c6551fd 100644 --- a/lib/NGCP/Panel/Controller/Login.pm +++ b/lib/NGCP/Panel/Controller/Login.pm @@ -49,6 +49,7 @@ sub login_index :Path Form { $c->log->debug("Login::index user=$user, pass=****, realm=$realm"); } my $res; + my ($u, $d, $t); if($realm eq 'admin') { $res = NGCP::Panel::Utils::Auth::perform_auth( c => $c, @@ -59,7 +60,7 @@ sub login_index :Path Form { bcrypt_realm => 'admin_bcrypt', ); } elsif($realm eq 'subscriber') { - my ($u, $d, $t) = split /\@/, $user; + ($u, $d, $t) = split /\@/, $user; if(defined $t) { # in case username is an email address $u = $u . '@' . $d; @@ -68,23 +69,47 @@ sub login_index :Path Form { unless(defined $d) { $d = $c->req->uri->host; } - $res = NGCP::Panel::Utils::Auth::perform_subscriber_auth($c, $u, $d, $pass); + $res = NGCP::Panel::Utils::Auth::perform_subscriber_auth( + c => $c, + user => $u, + domain => $d, + pass => $pass, + otp => $otp, + ); } if(defined $res && $res == -3) { $form = NGCP::Panel::Form::get("NGCP::Panel::Form::LoginOtp", $c); $form->field('username')->value($user); - $form->field('password')->value($pass); + $form->field('password')->value($pass); $form->field('otp')->value(undef); $form->add_form_error($c->loc('Invalid one-time code')) if $otp; - my $dbadmin = $c->model('DB')->resultset('admins')->search({ - login => $user, - })->first; - $c->stash(show_otp_registration_info => $dbadmin->show_otp_registration_info); - if ($dbadmin && $dbadmin->show_otp_registration_info) { - $c->stash( - otp_secret_qr_base64 => encode_base64(${NGCP::Panel::Utils::Auth::generate_otp_qr($c,$dbadmin)}), - ); + if($realm eq 'admin') { + my $dbadmin = $c->model('DB')->resultset('admins')->search({ + login => $user, + })->first; + $c->stash(show_otp_registration_info => $dbadmin->show_otp_registration_info); + if ($dbadmin->show_otp_registration_info) { + $c->stash( + otp_secret_qr_base64 => encode_base64(${NGCP::Panel::Utils::Auth::generate_otp_qr($c,$dbadmin)}), + ); + } + } elsif($realm eq 'subscriber') { + my $sub = $c->model('DB')->resultset('provisioning_voip_subscribers')->search({ + webusername => $u, + 'voip_subscriber.status' => 'active', + ($c->config->{features}->{multidomain} ? ('domain.domain' => $d) : ()), + 'contract.status' => 'active', + }, { + join => ['domain', 'contract', 'voip_subscriber'], + })->first; + my $show_otp_registration_info = NGCP::Panel::Utils::Auth::get_subscriber_show_otp_registration_info($c,$sub); + $c->stash(show_otp_registration_info => $show_otp_registration_info); + if ($show_otp_registration_info) { + $c->stash( + otp_secret_qr_base64 => encode_base64(${NGCP::Panel::Utils::Auth::generate_otp_qr($c,$sub)}), + ); + } } } elsif($res && $res == -2) { $c->log->warn("invalid http login from '".$c->qs($c->req->address)."'"); @@ -353,6 +378,7 @@ sub change_password :Chained('/') :PathPart('changepassword') Args(0) { $c->log->debug("Password change user=$user, pass=****, realm=$realm"); } my $res; + my ($u, $d, $t); if($realm eq 'admin') { $res = NGCP::Panel::Utils::Auth::perform_auth( c => $c, @@ -363,7 +389,7 @@ sub change_password :Chained('/') :PathPart('changepassword') Args(0) { bcrypt_realm => 'admin_bcrypt', ); } elsif($realm eq 'subscriber') { - my ($u, $d, $t) = split /\@/, $user; + ($u, $d, $t) = split /\@/, $user; if(defined $t) { # in case username is an email address $u = $u . '@' . $d; @@ -372,7 +398,13 @@ sub change_password :Chained('/') :PathPart('changepassword') Args(0) { unless(defined $d) { $d = $c->req->uri->host; } - $res = NGCP::Panel::Utils::Auth::perform_subscriber_auth($c, $u, $d, $pass); + $res = NGCP::Panel::Utils::Auth::perform_subscriber_auth( + c => $c, + user => $u, + domain => $d, + pass => $pass, + otp => $otp, + ); } if(defined $res && $res == -3) { @@ -383,14 +415,32 @@ sub change_password :Chained('/') :PathPart('changepassword') Args(0) { $form->field('new_password')->value($new_pass); $form->field('new_password')->value($new_pass2); $form->add_form_error($c->loc('Invalid one-time code')) if $otp; - my $dbadmin = $c->model('DB')->resultset('admins')->search({ - login => $user, - })->first; - $c->stash(show_otp_registration_info => $dbadmin->show_otp_registration_info); - if ($dbadmin && $dbadmin->show_otp_registration_info) { - $c->stash( - otp_secret_qr_base64 => encode_base64(${NGCP::Panel::Utils::Auth::generate_otp_qr($c,$dbadmin)}), - ); + if($realm eq 'admin') { + my $dbadmin = $c->model('DB')->resultset('admins')->search({ + login => $user, + })->first; + $c->stash(show_otp_registration_info => $dbadmin->show_otp_registration_info); + if ($dbadmin->show_otp_registration_info) { + $c->stash( + otp_secret_qr_base64 => encode_base64(${NGCP::Panel::Utils::Auth::generate_otp_qr($c,$dbadmin)}), + ); + } + } elsif($realm eq 'subscriber') { + my $sub = $c->model('DB')->resultset('provisioning_voip_subscribers')->search({ + webusername => $u, + 'voip_subscriber.status' => 'active', + ($c->config->{features}->{multidomain} ? ('domain.domain' => $d) : ()), + 'contract.status' => 'active', + }, { + join => ['domain', 'contract', 'voip_subscriber'], + })->first; + my $show_otp_registration_info = NGCP::Panel::Utils::Auth::get_subscriber_show_otp_registration_info($c,$sub); + $c->stash(show_otp_registration_info => $show_otp_registration_info); + if ($show_otp_registration_info) { + $c->stash( + otp_secret_qr_base64 => encode_base64(${NGCP::Panel::Utils::Auth::generate_otp_qr($c,$sub)}), + ); + } } } elsif($res && $res == -2) { $c->log->warn("invalid http login from '".$c->qs($c->req->address)."'"); diff --git a/lib/NGCP/Panel/Controller/Root.pm b/lib/NGCP/Panel/Controller/Root.pm index 74f311e641..e4713431a0 100644 --- a/lib/NGCP/Panel/Controller/Root.pm +++ b/lib/NGCP/Panel/Controller/Root.pm @@ -231,6 +231,7 @@ sub auto :Private { } my ($username,$password) = $c->req->headers->authorization_basic; + my ($otp) = $c->request->header('X-OTP'); unless ($username && defined $password) { $c->user->logout if ($c->user); @@ -245,7 +246,14 @@ sub auto :Private { if ($d) { $c->req->headers->authorization_basic($u,$password); } - my $res = NGCP::Panel::Utils::Auth::perform_subscriber_auth($c, $u, $d, $password); + my $res = NGCP::Panel::Utils::Auth::perform_subscriber_auth( + c => $c, + user => $u, + domain => $d, + pass => $password, + otp => $otp, + skip_otp => ($c->req->uri->path =~ m|^/api/otpsecret/?$| ? 1 : 0), + ); if ($res && $res == -2) { $c->detach(qw(API::Root banned_user), [$username]); @@ -809,6 +817,23 @@ sub login_jwt :Chained('/') :PathPart('login_jwt') :Args(0) :Method('POST') { } else { $auth_user = $authrs->search({webpassword => $pass})->first; } + + if (NGCP::Panel::Utils::Auth::get_subscriber_enable_2fa($c,$auth_user)) { + if (NGCP::Panel::Utils::Auth::verify_otp($c, + NGCP::Panel::Utils::Auth::get_subscriber_otp_secret($c,$auth_user), + $otp,time())) { + NGCP::Panel::Utils::Auth::set_subscriber_show_otp_registration_info($c,$auth_user,0) + if NGCP::Panel::Utils::Auth::get_subscriber_show_otp_registration_info($c,$auth_user); + } else { + $c->response->status(HTTP_FORBIDDEN); + $c->response->body(encode_json({ + code => HTTP_FORBIDDEN, + message => "Invalid OTP" })."\n"); + $c->log->info("Invalid OTP"); + return; + } + } + } } } diff --git a/lib/NGCP/Panel/Utils/Auth.pm b/lib/NGCP/Panel/Utils/Auth.pm index 947a98f9fa..353dd15bf9 100644 --- a/lib/NGCP/Panel/Utils/Auth.pm +++ b/lib/NGCP/Panel/Utils/Auth.pm @@ -11,6 +11,7 @@ use MIME::Base32 qw(); use Digest::HMAC_SHA1 qw(); use Imager::QRCode qw(); use URI::Encode qw(uri_encode); +use NGCP::Panel::Utils::Preferences qw(); use NGCP::Panel::Utils::Ldap qw( auth_ldap_simple @@ -36,6 +37,8 @@ our $OTP_SECRET_LENGTH = 20; #160 Bits our $OTP_WINDOW = 3; our $OTP_STEP_SIZE = 30; #secs +my $lock_timeout = 5; + sub check_password { my $pass = shift // return; @@ -208,7 +211,22 @@ sub is_salted_hash { } sub perform_subscriber_auth { - my ($c, $user, $domain, $pass) = @_; + + my %params = @_; + my ($c, + $user, + $domain, + $pass, + $otp, + $skip_otp) = @params{qw/ + c + user + domain + pass + otp + skip_otp + /}; + my $res; if ($pass && $pass =~ /[^[:ascii:]]/) { @@ -298,6 +316,17 @@ sub perform_subscriber_auth { } } + if ($res + and not $skip_otp + and get_subscriber_enable_2fa($c,$sub)) { + if (verify_otp($c,get_subscriber_otp_secret($c,$sub),$otp,time())) { + NGCP::Panel::Utils::Auth::set_subscriber_show_otp_registration_info($c,$sub,0) + if NGCP::Panel::Utils::Auth::get_subscriber_show_otp_registration_info($c,$sub); + } else { + $res = -3; + } + } + $res ? do { clear_failed_login_attempts($c, $userdom, 'subscriber'); reset_ban_increment_stage($c, $userdom, 'subscriber'); @@ -834,9 +863,122 @@ sub create_otp_secret { } +sub get_subscriber_enable_2fa { + my ($c,$prov_subscriber) = @_; + + return 0 unless $prov_subscriber; + + my $rs = NGCP::Panel::Utils::Preferences::get_chained_preference_rs( + $c, + 'enable_2fa', + $prov_subscriber, + { + type => 'usr', + 'order' => [qw/usr prof dom/] + }, + ); + + if ($rs->first) { + return 1 if $rs->first->value; + } + + return 0; +} + +sub get_subscriber_otp_secret { + + my ($c,$prov_subscriber) = @_; + + return unless $prov_subscriber; + + $prov_subscriber = $c->model('DB')->resultset('provisioning_voip_subscribers')->find({ + id => $prov_subscriber->id + },{ for => \"update wait $lock_timeout" }); + + my $otp_secret; + my $rs = NGCP::Panel::Utils::Preferences::get_usr_preference_rs( + c => $c, + prov_subscriber => $prov_subscriber, + attribute => 'otp_secret', + ); + + if ($rs->first) { + $otp_secret = $rs->first->value; + } else { + $otp_secret = create_otp_secret(); + $rs->create({ value => $otp_secret }); + } + + return $otp_secret; + +} + +sub get_subscriber_show_otp_registration_info { + + my ($c,$prov_subscriber) = @_; + + return unless $prov_subscriber; + + $prov_subscriber = $c->model('DB')->resultset('provisioning_voip_subscribers')->find({ + id => $prov_subscriber->id + },{ for => \"update wait $lock_timeout" }); + + my $show_otp_registration_info; + my $rs = NGCP::Panel::Utils::Preferences::get_usr_preference_rs( + c => $c, + prov_subscriber => $prov_subscriber, + attribute => 'show_otp_registration_info', + ); + + if ($rs->first) { + $show_otp_registration_info = $rs->first->value; + } else { + $show_otp_registration_info = 1; + $rs->create({ value => $show_otp_registration_info }); + } + + return $show_otp_registration_info; + +} + +sub set_subscriber_show_otp_registration_info { + + my ($c,$prov_subscriber,$value) = @_; + + return unless $prov_subscriber; + + $prov_subscriber = $c->model('DB')->resultset('provisioning_voip_subscribers')->find({ + id => $prov_subscriber->id + },{ for => \"update wait $lock_timeout" }); + + my $rs = NGCP::Panel::Utils::Preferences::get_usr_preference_rs( + c => $c, + prov_subscriber => $prov_subscriber, + attribute => 'show_otp_registration_info', + ); + + if ($rs->first) { + $rs->first->update({ value => $value }); + } else { + $rs->create({ value => $value }); + } + + return $value; + +} + +sub get_otp_secret { + my ($c,$user) = @_; + if (ref $user eq 'NGCP::Panel::Model::DB::admins') { + return $user->otp_secret; + } elsif (ref $user eq 'NGCP::Panel::Model::DB::provisioning_voip_subscribers') { + return get_subscriber_otp_secret($c,$user); + } +} + sub generate_otp_qr { - my ($c,$admin) = @_; + my ($c,$user) = @_; # my $qrcode = Imager::QRCode->new( @@ -851,12 +993,26 @@ sub generate_otp_qr { darkcolor => Imager::Color->new(0, 0, 0), ); - my $image = $qrcode->plot(sprintf("otpauth://totp/%s@%s?secret=%s&issuer=%s", - uri_encode($admin->login), - uri_encode($c->req->uri->host), - $admin->otp_secret, - 'NGCP', # . $c->config->{ngcp_version} - )); + #$c->log->debug("xxx: " . ref $user ); + + my $image; + if (ref $user eq 'NGCP::Panel::Model::DB::admins') { + $image = $qrcode->plot(sprintf("otpauth://totp/%s@%s?secret=%s&issuer=%s", + uri_encode($user->login), + uri_encode($c->req->uri->host), + $user->otp_secret, + 'NGCP', # . $c->config->{ngcp_version} + )); + } elsif (ref $user eq 'NGCP::Panel::Model::DB::provisioning_voip_subscribers') { + #$c->log->debug("xxx: " . $user->webusername ); + #$c->log->debug("yyy: " . $user->domain->domain ); + $image = $qrcode->plot(sprintf("otpauth://totp/%s@%s?secret=%s&issuer=%s", + uri_encode($user->webusername), + uri_encode($user->domain->domain), + get_subscriber_otp_secret($c,$user), + 'NGCP', # . $c->config->{ngcp_version} + )); + } my $data; $image->write(data => \$data, type => 'png')