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
mr13.0
Kirill Solomko 9 months ago
parent a2a01d4690
commit b041888807

@ -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__

@ -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;
}

@ -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:

@ -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;

@ -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;

@ -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})) {

@ -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);
}

@ -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);
}

@ -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;

@ -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) {

@ -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;

@ -0,0 +1,69 @@
<body class="login" id="change_password_page_v1">
<div class="account-container login stacked">
[% IF messages -%]
<div>
[% FOREACH m IN messages -%]
<div class="alert alert-[% m.type %]">[% m.text %]</div>
[% END -%]
</div>
[% END -%]
<div class="content clearfix">
<h1>[% c.loc('Password Change') %]</h1>
<p>[% c.loc('Change password using your [_1] credentials:', realm.ucfirst) %]</p>
[% form.render %]
</div>
</div>
<div class="login-extra">
[% IF realm == 'subscriber' && c.config.security.password_allow_recovery -%]
[% c.loc('Forgot your password?') %] <a href="[% c.uri_for_action('/subscriber/reset_webpassword_nosubscriber') %]">[% c.loc('Reset Password') %]</a>.
[% ELSIF realm == 'admin' -%]
[% c.loc('Forgot your password?') %] <a href="[% c.uri_for_action('/login/reset_password') %]">[% c.loc('Reset Password') %]</a>.
[% END -%]
<br/>
</div>
<div class="login-footer">
[% IF c.config.general.ui_enable && realm != 'subscriber' -%]
<div>
<b><a href="[% c.uri_for('/') -%]v2/#/login/admin" style="padding-right: 20px">[% c.loc('GO TO NEW ADMIN PANEL') -%]</a></b>
</div>
[% END -%]
</div class="footer">
<script src="/js/libs/jquery-1.7.2.min.js"></script>
<script src="/js/libs/jquery-ui-1.10.3.custom.min.js"></script>
<script src="/js/libs/jquery.ui.touch-punch.min.js"></script>
<script src="/js/libs/bootstrap/bootstrap.min.js"></script>
<script src="/js/Theme.js"></script>
<script src="/js/signin.js"></script>
<script>
$(function () {
Theme.init();
Object.keys(localStorage).forEach((key)=>{
if(!key.startsWith('DataTables_') && !key.startsWith('aui_')){
localStorage.removeItem(key);
}
})
localStorage.removeItem('aui_jwt');
localStorage.removeItem('aui_adminId');
});
</script>
<style>
.login-footer {
box-sizing: border-box;
position: fixed;
left: 0;
bottom: 0;
width: 100%;
color: white;
text-align: right;
padding-bottom: 30px;
padding-right: 50px;
}
</style>
</body>
[% # vim: set tabstop=4 syntax=html expandtab: -%]
Loading…
Cancel
Save