From b041888807ab89a0624bd7f9ba75e21ec53861cf Mon Sep 17 00:00:00 2001 From: Kirill Solomko Date: Wed, 7 Aug 2024 15:40:58 +0200 Subject: [PATCH] MT#60656 add max_age password validation support * UI: password are now validated against $c->config->{security}{password}{web_max_age_days} (unless it's 0) and if the password is expired the user is redirected automatically to /changepassword page, and after successful password change back to the original page. * API: if password is expired all API requests will be returning 403 Forbidden "Password expired", except PUT/PATCH to /api/admins or /api/subscribers with the new password in place. * successful login on the UI now redirects to /dashboard instead of / (to prevent unintended redirect to v2) Change-Id: I075f8e17cc9b0658d6b3b3d526ca5b379d050ce4 --- lib/NGCP/Panel/Controller/Login.pm | 136 ++++++++++++++++++++++- lib/NGCP/Panel/Controller/Root.pm | 8 ++ lib/NGCP/Panel/Form/PasswordChange.pm | 52 +++++++++ lib/NGCP/Panel/Role/API.pm | 21 ++++ lib/NGCP/Panel/Role/API/Admins.pm | 7 ++ lib/NGCP/Panel/Role/API/Subscribers.pm | 7 ++ lib/NGCP/Panel/Role/Entities.pm | 9 +- lib/NGCP/Panel/Role/EntitiesItem.pm | 9 +- lib/NGCP/Panel/Utils/Auth.pm | 30 ++++- lib/NGCP/Panel/Utils/Form.pm | 12 ++ share/layout/wrapper.tt | 2 +- share/templates/login/change_password.tt | 69 ++++++++++++ 12 files changed, 343 insertions(+), 19 deletions(-) create mode 100644 lib/NGCP/Panel/Form/PasswordChange.pm create mode 100644 share/templates/login/change_password.tt diff --git a/lib/NGCP/Panel/Controller/Login.pm b/lib/NGCP/Panel/Controller/Login.pm index d6d7c8f7ac..43ad80bfcc 100644 --- a/lib/NGCP/Panel/Controller/Login.pm +++ b/lib/NGCP/Panel/Controller/Login.pm @@ -10,6 +10,8 @@ use UUID; use NGCP::Panel::Form; use NGCP::Panel::Utils::Auth; +use NGCP::Panel::Utils::Form; +use NGCP::Panel::Utils::Subscriber; sub login_index :Path Form { my ( $self, $c, $realm ) = @_; @@ -80,7 +82,7 @@ sub login_index :Path Form { $c->session->{user_tz} = undef; # reset to reload from db $c->session->{user_tz_name} = undef; # reset to reload from db - my $target = $c->session->{'target'} || '/'; + my $target = $c->session->{'target'} || '/dashboard'; delete $c->session->{target}; $target =~ s!^https?://[^/]+/!/!; $c->log->debug("Login::index auth ok, redirecting to $target"); @@ -273,6 +275,138 @@ sub recover_password :Chained('/') :PathPart('recoverpassword') :Args(0) { ); } +sub change_password :Chained('/') :PathPart('changepassword') Args(0) { + my ($self, $c) = @_; + + my $realm = $c->req->env->{NGCP_REALM} // 'admin'; + + $c->user->logout if $c->user; + + my $posted = ($c->req->method eq 'POST'); + my $form = NGCP::Panel::Form::get("NGCP::Panel::Form::PasswordChange", $c); + $form->process( + posted => $posted, + params => $c->request->params, + item => { username => $c->stash->{username} }, + ); + + if($posted && $form->validated) { + $c->log->debug("login form validated"); + my $user = $form->field('username')->value; + my $pass = $form->field('password')->value; + my $new_pass = $form->field('new_password')->value; + my $new_pass2 = $form->field('new_password2')->value; + $c->log->debug("Password change user=$user, realm=$realm"); + my $res; + if($realm eq 'admin') { + $res = NGCP::Panel::Utils::Auth::perform_auth($c, $user, $pass, 'admin', 'admin_bcrypt'); + } elsif($realm eq 'subscriber') { + my ($u, $d, $t) = split /\@/, $user; + if(defined $t) { + # in case username is an email address + $u = $u . '@' . $d; + $d = $t; + } + unless(defined $d) { + $d = $c->req->uri->host; + } + $res = NGCP::Panel::Utils::Auth::perform_subscriber_auth($c, $u, $d, $pass); + } + + if($res) { + # auth ok + + if ($pass eq $new_pass) { + $form->field('new_password')->add_error($c->loc('Password must not be equal to the old password')); + } elsif ($new_pass ne $new_pass2) { + $form->field('new_password2')->add_error($c->loc('New password fields do not match')); + } else { + NGCP::Panel::Utils::Form::validate_password( + c => $c, field => $form->field('new_password'), admin => $realm eq 'admin', password_change => 1 + ); + } + + if (!$form->has_errors) { + if ($realm eq 'admin') { + use Crypt::JWT qw/encode_jwt/; + + $c->user->update({ + saltedpass => NGCP::Panel::Utils::Auth::generate_salted_hash($new_pass) + }); + NGCP::Panel::Utils::Admin::insert_password_journal( + $c, $c->user, $new_pass + ); + + my $key = $c->config->{'Plugin::Authentication'}{api_admin_jwt}{credential}{jwt_key}; + my $relative_exp = $c->config->{'Plugin::Authentication'}{api_admin_jwt}{credential}{relative_exp}; + my $alg = $c->config->{'Plugin::Authentication'}{api_admin_jwt}{credential}{alg}; + + unless ($key) { + NGCP::Panel::Utils::Message::error( + c => $c, + desc => $c->loc('No JWT key has been configured.'), + ); + } + + my $jwt_data = { + id => $c->user->id, + username => $c->user->login, + }; + my $token = encode_jwt( + payload => $jwt_data, + key => $key, + alg => $alg, + $relative_exp ? (relative_exp => $relative_exp) : (), + ); + + $c->session->{aui_adminId} = $c->user->id; + $c->session->{aui_jwt} = $token; + } else { + $c->user->provisioning_voip_subscriber->update({ + webpassword => + $NGCP::Panel::Utils::Auth::ENCRYPT_SUBSCRIBER_WEBPASSWORDS + ? NGCP::Panel::Utils::Auth::generate_salted_hash($new_pass) + : $new_pass + }); + NGCP::Panel::Utils::Subscriber::insert_webpassword_journal( + $c, $c->user->provisioning_voip_subscriber, $new_pass + ); + + } + $c->log->debug("Password successfully changed for user=$user, realm=$realm"); + $c->session->{user_tz} = undef; # reset to reload from db + $c->session->{user_tz_name} = undef; # reset to reload from db + my $target = $c->session->{'target'} || '/dashboard'; + delete $c->session->{target}; + $target =~ s!^https?://[^/]+/!/!; + $c->log->debug("Login::index auth ok, redirecting to $target"); + NGCP::Panel::Utils::Message::info( + c => $c, + desc => $c->loc('Password successfully changed'), + ); + $c->response->redirect($target); + } + } else { + $c->log->warn("invalid http login from '".$c->qs($c->req->address)."'"); + $c->log->debug("Login::index auth failed"); + $form->add_form_error($c->loc('Invalid username/password')); + } + } else { + # initial get + } + + if ($form->has_errors) { + my $request_ip = $c->request->address; + $c->log->error("NGCP Panel Password Change failed realm=$realm ip=" . $c->qs($request_ip)); + } + + $c->stash( + form => $form, + realm => $realm, + template => 'login/change_password.tt', + ); +} + 1; __END__ diff --git a/lib/NGCP/Panel/Controller/Root.pm b/lib/NGCP/Panel/Controller/Root.pm index a2bfe1380f..1db803fc2f 100644 --- a/lib/NGCP/Panel/Controller/Root.pm +++ b/lib/NGCP/Panel/Controller/Root.pm @@ -99,6 +99,7 @@ sub auto :Private { or $c->req->uri->path =~ m|^/recoverwebpassword/?$| or $c->req->uri->path =~ m|^/resetwebpassword/?$| or $c->req->uri->path =~ m|^/resetpassword/?$| + or $c->req->uri->path =~ m|^/changepassword/?$| or $c->req->uri->path =~ m|^/api/passwordreset/?$| or $c->req->uri->path =~ m|^/api/passwordrecovery/?$| or $c->req->uri->path =~ m|^/internalsms/receive/?$| @@ -477,6 +478,13 @@ sub check_user_access { return; } + # redirect to password change page if password is expired + if (! NGCP::Panel::Utils::Auth::check_max_age($c)) { + $c->session(target => $c->request->uri); + $c->response->redirect($c->uri_for('/changepassword')); + return; + } + return 1; } diff --git a/lib/NGCP/Panel/Form/PasswordChange.pm b/lib/NGCP/Panel/Form/PasswordChange.pm new file mode 100644 index 0000000000..02ef196326 --- /dev/null +++ b/lib/NGCP/Panel/Form/PasswordChange.pm @@ -0,0 +1,52 @@ +package NGCP::Panel::Form::PasswordChange; + +use HTML::FormHandler::Moose; +extends 'HTML::FormHandler'; + +use HTML::FormHandler::Widget::Block::Bootstrap; + +has '+widget_wrapper' => ( default => 'Bootstrap' ); + +sub build_form_tags {{ error_class => 'label label-secondary'}} + +has_field 'username' => ( + type => 'Text', + required => 1, + element_attr => { placeholder => 'Username' }, + element_class => [qw/login username-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'password' => ( + type => 'Password', + required => 1, + element_attr => { placeholder => 'Password' }, + element_class => [qw/login password-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'new_password' => ( + type => 'Password', + required => 1, + element_attr => { placeholder => 'New Password' }, + element_class => [qw/login password-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'new_password2' => ( + type => 'Password', + required => 1, + element_attr => { placeholder => 'New Password Again' }, + element_class => [qw/login password-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'submit' => ( + type => 'Submit', + value => 'Submit', + label => '', + element_class => [qw/button btn btn-primary btn-large/], +); + +1; +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Role/API.pm b/lib/NGCP/Panel/Role/API.pm index b6a355d220..de78d0456d 100644 --- a/lib/NGCP/Panel/Role/API.pm +++ b/lib/NGCP/Panel/Role/API.pm @@ -16,6 +16,7 @@ use HTTP::Status qw(:constants); use Scalar::Util qw/blessed/; use DateTime::Format::HTTP qw(); use DateTime::Format::RFC3339 qw(); +use DateTime::Format::Strptime; use Types::Standard qw(InstanceOf); use Regexp::Common qw(delimited); # $RE{delimited} use Encode qw( encode_utf8 ); @@ -24,6 +25,7 @@ use HTTP::Headers::Util qw(split_header_words); use Data::Compare; use Data::HAL qw(); use Data::HAL::Link qw(); +use NGCP::Panel::Utils::Auth qw(); use NGCP::Panel::Utils::ValidateJSON qw(); use NGCP::Panel::Utils::Journal qw(); use List::Util qw(any all); @@ -1772,6 +1774,25 @@ sub check_licenses { sub validate_request { my ($self, $c) = @_; + if (! $self->check_allowed_ngcp_types($c)) { + $self->error($c, HTTP_NOT_FOUND, "Path not found"); + return; + } + + if (! $self->check_licenses($c)) { + $self->error($c, HTTP_FORBIDDEN, "Invalid license"); + return; + } + + if (! NGCP::Panel::Utils::Auth::check_max_age($c)) { + if ($c->req->method =~ /^(PUT|PATCH)$/ && $c->req->path =~ /^api\/(admins|subscribers)\//) { + $c->stash->{validate_password_change} = 1; + } else { + $self->error($c, HTTP_FORBIDDEN, "Password expired"); + return; + } + } + my $page = $c->request->params->{page} // 1; my $rows = $c->request->params->{rows} // 10; diff --git a/lib/NGCP/Panel/Role/API/Admins.pm b/lib/NGCP/Panel/Role/API/Admins.pm index 0b15fb74d8..7a4bc3549c 100644 --- a/lib/NGCP/Panel/Role/API/Admins.pm +++ b/lib/NGCP/Panel/Role/API/Admins.pm @@ -155,6 +155,13 @@ sub update_item { resource => $resource, ); + if ($c->stash->{validate_password_change}) { + if (!$resource->{password} || $resource->{password} eq $old_resource->{saltedpass}) { + $self->error($c, HTTP_FORBIDDEN, "Password expired"); + return; + } + } + if ($item->id == $c->user->id) { # user cannot modify the following own permissions for security reasons my $own_forbidden = 0; diff --git a/lib/NGCP/Panel/Role/API/Subscribers.pm b/lib/NGCP/Panel/Role/API/Subscribers.pm index f1102b663c..ca0bc4723e 100644 --- a/lib/NGCP/Panel/Role/API/Subscribers.pm +++ b/lib/NGCP/Panel/Role/API/Subscribers.pm @@ -407,6 +407,13 @@ sub update_item { my $groupmembers = $full_resource->{groupmembers}; my $prov_subscriber = $subscriber->provisioning_voip_subscriber; + if ($c->stash->{validate_password_change}) { + if (!$resource->{webpassword}) { + $self->error($c, HTTP_FORBIDDEN, "Password expired"); + return; + } + } + $self->process_form_resource($c, $item, $full_resource, $resource, $form); if($subscriber->provisioning_voip_subscriber->is_pbx_pilot && !is_true($resource->{is_pbx_pilot})) { diff --git a/lib/NGCP/Panel/Role/Entities.pm b/lib/NGCP/Panel/Role/Entities.pm index 56cfa1f871..ae98833eb1 100644 --- a/lib/NGCP/Panel/Role/Entities.pm +++ b/lib/NGCP/Panel/Role/Entities.pm @@ -20,14 +20,7 @@ sub auto :Private { if ($self->get_config('log_request')) { $self->log_request($c); } - if (! $self->check_allowed_ngcp_types($c)) { - $self->error($c, HTTP_NOT_FOUND, "Path not found"); - return; - } - if (! $self->check_licenses($c)) { - $self->error($c, HTTP_FORBIDDEN, "Invalid license"); - return; - } + return $self->validate_request($c); } diff --git a/lib/NGCP/Panel/Role/EntitiesItem.pm b/lib/NGCP/Panel/Role/EntitiesItem.pm index 0602da1eb7..93875e0969 100644 --- a/lib/NGCP/Panel/Role/EntitiesItem.pm +++ b/lib/NGCP/Panel/Role/EntitiesItem.pm @@ -25,14 +25,7 @@ sub auto :Private { if ($self->get_config('log_request')) { $self->log_request($c); } - if (! $self->check_allowed_ngcp_types($c)) { - $self->error($c, HTTP_NOT_FOUND, "Path not found"); - return; - } - if (! $self->check_licenses($c)) { - $self->error($c, HTTP_FORBIDDEN, "Invalid license"); - return; - } + return $self->validate_request($c); } diff --git a/lib/NGCP/Panel/Utils/Auth.pm b/lib/NGCP/Panel/Utils/Auth.pm index f15f6ea1b1..e046072d3c 100644 --- a/lib/NGCP/Panel/Utils/Auth.pm +++ b/lib/NGCP/Panel/Utils/Auth.pm @@ -340,7 +340,7 @@ sub check_openvpn_availability { my $openvpn_service = $config->{service}; my $output = cmd($c, {no_debug_output =>1 }, $systemctl_cmd, 'list-unit-files', 'openvpn.service'); #$c->log->debug( $output ); - if ($output =~/^openvpn.service/m) { + if ($output =~ /^openvpn.service/m) { $res = 1; } return $res; @@ -652,4 +652,32 @@ sub ban_user { return; } +sub check_max_age { + my $c = shift; + + my $pass_last_modify_time; + my $strp = DateTime::Format::Strptime->new( + pattern => '%Y-%m-%dT%H:%M:%S', + time_zone => 'local', + ); + if ($c->user->roles eq 'subscriber' || $c->user->roles eq 'subscriberadmin') { + my $webpass_last_modify = $c->user->webpassword_modify_timestamp; + my $dt = $strp->parse_datetime($webpass_last_modify // ''); + $pass_last_modify_time = $dt->epoch if $dt; + } else { + my $saltedpass_last_modify = $c->user->saltedpass_modify_timestamp; + my $dt = $strp->parse_datetime($saltedpass_last_modify // ''); + $pass_last_modify_time = $dt->epoch if $dt; + } + if ($pass_last_modify_time) { + my $max_age = $c->config->{security}{password}{web_max_age_days} // 0; + if (defined $max_age && $max_age > 0) { + if ($pass_last_modify_time < (time()-$max_age*24*60*60)) { + return; + } + } + } + return 1; +} + 1; diff --git a/lib/NGCP/Panel/Utils/Form.pm b/lib/NGCP/Panel/Utils/Form.pm index e110768656..7bfe536585 100644 --- a/lib/NGCP/Panel/Utils/Form.pm +++ b/lib/NGCP/Panel/Utils/Form.pm @@ -8,6 +8,7 @@ sub validate_password { my %params = @_; my $c = $params{c}; my $field = $params{field}; + my $pass_change = $params{password_change}; my $pw = $c->config->{security}{password}; my $utf8 = $params{utf8} // 1; my $pass = $field->value; @@ -25,6 +26,8 @@ sub validate_password { $is_sip_password = 1; } elsif ($field->name eq 'webpassword') { $is_web_password = 1; + } elsif ($field->name eq 'new_password') { + $is_web_password = 1; } if ($is_sip_password) { @@ -88,6 +91,9 @@ sub validate_password { my $prov_sub = $c->stash->{subscriber} ? $c->stash->{subscriber}->provisioning_voip_subscriber : undef; + if ($pass_change && !$prov_sub) { + $prov_sub = $c->user->provisioning_voip_subscriber; + } if ($field->form->field('username')) { $user = $field->form->field('username')->value; } elsif ($prov_sub) { @@ -105,6 +111,9 @@ sub validate_password { my $prov_sub = $c->stash->{subscriber} ? $c->stash->{subscriber}->provisioning_voip_subscriber : undef; + if ($pass_change && !$prov_sub) { + $prov_sub = $c->user->provisioning_voip_subscriber; + } if ($field->form->field('webusername')) { $user = $field->form->field('webusername')->value; } elsif($prov_sub) { @@ -120,6 +129,9 @@ sub validate_password { } elsif ($is_admin_password) { my $user; my $admin = $c->stash->{administrator} // undef; + if ($pass_change && !$admin) { + $admin = $c->user; + } if ($field->form->field('login')) { $user = $field->form->field('login')->value; } elsif($admin) { diff --git a/share/layout/wrapper.tt b/share/layout/wrapper.tt index 444a9974ef..87915948ed 100644 --- a/share/layout/wrapper.tt +++ b/share/layout/wrapper.tt @@ -1,7 +1,7 @@ [% IF template.name.match('^api|(\.html$|\.css$|\.js$|\.txt$)'); content; - ELSIF template.name.match('^login\/login\.tt$'); + ELSIF template.name.match('^login\/(login|change_password)\.tt$'); content WRAPPER html.tt; ELSE; content WRAPPER html.tt + body.tt; diff --git a/share/templates/login/change_password.tt b/share/templates/login/change_password.tt new file mode 100644 index 0000000000..502f9963b9 --- /dev/null +++ b/share/templates/login/change_password.tt @@ -0,0 +1,69 @@ + +
+ +[% IF messages -%] +
+ [% FOREACH m IN messages -%] +
[% m.text %]
+ [% END -%] +
+[% END -%] + + +
+

[% c.loc('Password Change') %]

+

[% c.loc('Change password using your [_1] credentials:', realm.ucfirst) %]

+ [% form.render %] +
+
+
+ [% IF realm == 'subscriber' && c.config.security.password_allow_recovery -%] + [% c.loc('Forgot your password?') %] [% c.loc('Reset Password') %]. + [% ELSIF realm == 'admin' -%] + [% c.loc('Forgot your password?') %] [% c.loc('Reset Password') %]. + [% END -%] +
+
+ + + + + + + + + + + + + +[% # vim: set tabstop=4 syntax=html expandtab: -%]