TT#12601 Move admin pass to bcrypt and drop cert

For the migration of the admin pwd, the logic is as follows:

1. If the admin has a bcrypt password already, use this
2. If not, perform auth via md5, then clear the md5 column
   and write a salted bcrypt hash instead.

For dropping the ssl client cert, we simply not store anymore
the client certificate in the DB. As a result, you cannot
download the certs (pem, p12) after creation anymore, so
we immediately download the two certs in a zip file after
creation.

A cost of 13 takes 500ms on an i7-5500U CPU @ 2.40GHz, which
seems to be a reasonable value.

Change-Id: I1ce21321c58d8c57d7ddce1541995f64821b0053
changes/01/11901/7
Andreas Granig 9 years ago
parent d2cbe01ce2
commit 8ad683d5ae

2
debian/control vendored

@ -22,9 +22,11 @@ Depends: gettext,
libcatalyst-view-tt-perl,
libconfig-general-perl,
libconvert-ascii85-perl,
libcrypt-eksblowfish-perl,
libcrypt-rc4-perl,
libcrypt-rijndael-perl,
libdata-compare-perl,
libdata-entropy-perl,
libdata-hal-perl,
libdata-printer-perl,
libdata-record-perl,

@ -126,6 +126,21 @@ __PACKAGE__->config(
use_userdata_from_session => 1,
}
},
admin_bcrypt => {
credential => {
class => 'Password',
password_field => 'saltedpass',
# we handle the salt and hash management manually in Login.pm
password_type => 'clear',
},
store => {
class => 'DBIx::Class',
user_model => 'DB::admins',
id_field => 'id',
store_user_class => 'NGCP::Panel::AuthenticationStore::RoleFromRealm',
use_userdata_from_session => 1,
}
},
api_admin_cert => {
# TODO: should be NoPassword, but it's not available in our catalyst version yet
credential => {

@ -6,7 +6,7 @@ sub roles {
my ($self) = @_;
if ($self->auth_realm) {
for my $auth_type (qw/admin api_admin_cert api_admin_http/) {
for my $auth_type (qw/admin_bcrypt admin api_admin_cert api_admin_http/) {
if ($auth_type eq $self->auth_realm) {
$self->_user->is_superuser ? return "admin"
: return "reseller";

@ -10,6 +10,7 @@ use HTTP::Headers qw();
use HTTP::Status qw(:constants);
use NGCP::Panel::Utils::DateTime;
use NGCP::Panel::Utils::Admin;
use Path::Tiny qw(path);
use Safe::Isa qw($_isa);
require Catalyst::ActionRole::ACL;
@ -171,7 +172,8 @@ sub POST :Allow {
);
delete $resource->{password};
if(defined $pass) {
$resource->{md5pass} = $pass;
$resource->{md5pass} = undef;
$resource->{saltedpass} = NGCP::Panel::Utils::Admin::generate_salted_hash($pass);
}
if($c->user->roles eq "admin") {
} elsif($c->user->roles eq "reseller") {

@ -3,12 +3,14 @@ use NGCP::Panel::Utils::Generic qw(:all);
use Sipwise::Base;
use parent 'Catalyst::Controller';
use HTTP::Headers qw();
use IO::Compress::Zip qw/zip/;
use NGCP::Panel::Form::Administrator::Reseller;
use NGCP::Panel::Form::Administrator::Admin;
use NGCP::Panel::Form::Administrator::APIGenerate qw();
use NGCP::Panel::Form::Administrator::APIDownDelete qw();
use NGCP::Panel::Utils::Message;
use NGCP::Panel::Utils::Navigation;
use NGCP::Panel::Utils::Admin;
sub auto :Does(ACL) :ACLDetachTo('/denied_page') :AllowedRole(admin) :AllowedRole(reseller) {
my ($self, $c) = @_;
@ -107,7 +109,8 @@ sub create :Chained('list_admin') :PathPart('create') :Args(0) {
} else {
$form->values->{reseller_id} = $c->user->reseller_id;
}
$form->values->{md5pass} = delete $form->values->{password};
$form->values->{md5pass} = undef;
$form->values->{saltedpass} = NGCP::Panel::Utils::Admin::generate_salted_hash(delete $form->values->{password});
$c->stash->{admins}->create($form->values);
delete $c->session->{created_objects}->{reseller};
NGCP::Panel::Utils::Message::info(
@ -195,7 +198,8 @@ sub edit :Chained('base') :PathPart('edit') :Args(0) {
}
delete $form->values->{password} unless length $form->values->{password};
if(exists $form->values->{password}) {
$form->values->{md5pass} = delete $form->values->{password};
$form->values->{md5pass} = undef;
$form->values->{saltedpass} = NGCP::Panel::Utils::Admin::generate_salted_hash(delete $form->values->{password});
}
$c->stash->{administrator}->update($form->values);
delete $c->session->{created_objects}->{reseller};
@ -253,32 +257,62 @@ sub delete :Chained('base') :PathPart('delete') :Args(0) {
sub api_key :Chained('base') :PathPart('api_key') :Args(0) {
my ($self, $c) = @_;
my $serial = $c->stash->{administrator}->ssl_client_m_serial;
my $cert;
my ($pem, $p12);
if ($c->req->body_parameters->{'gen.generate'}) {
$serial = time;
try {
$cert = $c->model('CA')->make_client($c, $serial);
} catch ($e) {
NGCP::Panel::Utils::Message::error(
c => $c,
error => $e,
data => { $c->stash->{administrator}->get_inflated_columns },
desc => $c->loc("Failed to generate client certificate."),
);
NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/administrator'));
}
my $updated;
while (!$updated) {
$serial = time;
try {
$pem = $c->model('CA')->make_client($c, $serial);
$p12 = $c->model('CA')->make_pkcs12($c, $serial, $pem, 'sipwise');
} catch ($e) {
NGCP::Panel::Utils::Message::error(
c => $c,
error => $e,
data => { $c->stash->{administrator}->get_inflated_columns },
desc => $c->loc("Failed to generate client certificate."),
);
NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/administrator'));
}
try {
$c->stash->{administrator}->update({
ssl_client_m_serial => $serial,
ssl_client_certificate => $cert,
ssl_client_certificate => undef, # not used anymore, clear it just in case
});
$updated = 1;
} catch(DBIx::Class::Exception $e where { "$_" =~ qr'Duplicate entry' }) {
$serial++;
};
}
}
my $input = {
"NGCP-API-client-certificate-$serial.pem" => $pem,
"NGCP-API-client-certificate-$serial.p12" => $p12,
};
my $zip_opts = {
AutoClose => 0,
Append => 0,
Name => "README.txt",
CanonicalName => 1,
Stream => 1,
};
my $zipped_file;
my $zip = IO::Compress::Zip->new(\$zipped_file, %{ $zip_opts });
$zip->write("Use the PEM file for programmatical clients like java, perl, php or curl, and the P12 file for browsers like Firefox or Chrome. The password for the P12 import is 'sipwise'. Handle this file with care, as it cannot be downloaded for a second time! Only a new certificate can be generated if the certificate is lost.\n");
foreach my $k(keys %{ $input } ) {
$zip_opts->{Name} = $k;
$zip_opts->{Append} = 1;
$zip->newStream(%{ $zip_opts });
$zip->write($input->{$k});
}
$zip->close();
$c->res->headers(HTTP::Headers->new(
'Content-Type' => 'application/zip',
'Content-Disposition' => sprintf('attachment; filename=%s', "NGCP-API-client-certificate-$serial.zip")
));
$c->res->body($zipped_file);
return;
} elsif ($c->req->body_parameters->{'ca.verify'} || $c->req->parameters->{'ca.verify'}) {
my $result = $c->model('CA')->check_ca_errors($c);
if($result){
@ -296,30 +330,10 @@ sub api_key :Chained('base') :PathPart('api_key') :Args(0) {
}
NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/administrator'));
} elsif ($c->req->body_parameters->{'del.delete'}) {
undef $serial;
undef $cert;
$c->stash->{administrator}->update({
ssl_client_m_serial => $serial,
ssl_client_certificate => $cert,
ssl_client_m_serial => undef,
ssl_client_certificate => undef,
});
} elsif ($c->req->body_parameters->{'pem.download'}) {
$cert = $c->stash->{administrator}->ssl_client_certificate;
$serial = $c->stash->{administrator}->ssl_client_m_serial;
$c->res->headers(HTTP::Headers->new(
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => sprintf('attachment; filename=%s', "NGCP-API-client-certificate-$serial.pem")
));
$c->res->body($cert);
return;
} elsif ($c->req->body_parameters->{'p12.download'}) {
$cert = $c->stash->{administrator}->ssl_client_certificate;
$serial = $c->stash->{administrator}->ssl_client_m_serial;
my $p12 = $c->model('CA')->make_pkcs12($c, $serial, $cert, 'sipwise');
$c->res->headers(HTTP::Headers->new(
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => sprintf('attachment; filename=%s', "NGCP-API-client-certificate-$serial.p12")
));
$c->res->body($p12);
return;
} elsif ($c->req->body_parameters->{'ca.download'}) {
my $ca_cert = $c->model('CA')->get_server_cert($c);

@ -6,6 +6,8 @@ use strict;
use parent 'Catalyst::Controller';
use NGCP::Panel::Form::Login;
use NGCP::Panel::Utils::Admin;
use Crypt::Eksblowfish::Bcrypt qw/bcrypt_hash en_base64 de_base64/;
sub index :Path Form {
my ( $self, $c, $realm ) = @_;
@ -25,23 +27,67 @@ sub index :Path Form {
$c->log->debug("login form validated");
my $user = $form->field('username')->value;
my $pass = $form->field('password')->value;
$c->log->debug("*** Login::index user=$user, pass=$pass, realm=$realm");
$c->log->debug("*** Login::index user=$user, pass=****, realm=$realm");
my $res;
if($realm eq 'admin') {
$res = $c->authenticate(
{
login => $user,
md5pass => $pass,
'dbix_class' => {
searchargs => [{
-and => [
login => $user,
is_active => 1,
],
}],
}
},
$realm);
my $dbadmin = $c->model('DB')->resultset('admins')->find({
login => $user,
is_active => 1,
});
if(defined $dbadmin && defined $dbadmin->saltedpass) {
$c->log->debug("login via bcrypt");
my ($db_b64salt, $db_b64hash) = split /\$/, $dbadmin->saltedpass;
my $salt = de_base64($db_b64salt);
my $usr_b64hash = en_base64(bcrypt_hash({
key_nul => 1,
cost => NGCP::Panel::Utils::Admin::get_bcrypt_cost(),
salt => $salt,
}, $pass));
# fetch again to load user into session etc (otherwise we could
# simply compare the two hashes here :(
$res = $c->authenticate(
{
login => $user,
saltedpass => $db_b64salt . '$' . $usr_b64hash,
'dbix_class' => {
searchargs => [{
-and => [
login => $user,
is_active => 1,
],
}],
}
}, 'admin_bcrypt'
);
} elsif(defined $dbadmin) { # we already know if the username is wrong, no need to check again
# check md5 and migrate over to bcrypt on success
$c->log->debug("login via md5");
$res = $c->authenticate(
{
login => $user,
md5pass => $pass,
'dbix_class' => {
searchargs => [{
-and => [
login => $user,
is_active => 1,
],
}],
}
}, 'admin');
if($res) {
# login ok, time to move user to bcrypt hashing
$c->log->debug("migrating to bcrypt");
my $saltedpass = NGCP::Panel::Utils::Admin::generate_salted_hash($pass);
$dbadmin->update({
md5pass => undef,
saltedpass => $saltedpass,
});
}
}
} elsif($realm eq 'subscriber') {
my ($u, $d, $t) = split /\@/, $user;
if(defined $t) {

@ -16,6 +16,7 @@ use NGCP::Panel::Utils::Reseller;
use NGCP::Panel::Utils::BillingNetworks qw();
use NGCP::Panel::Utils::ProfilePackages qw();
use NGCP::Panel::Utils::Billing qw();
use NGCP::Panel::Utils::Admin;
sub auto :Private {
my ($self, $c) = @_;
@ -428,6 +429,10 @@ sub create_defaults :Path('create_defaults') :Args(0) :Does(ACL) :ACLDetachTo('/
$c->detach('/denied_page') unless $c->request->method eq 'POST';
$c->detach('/denied_page')
if($c->user->read_only);
my $default_pass = 'defaultresellerpassword';
my $saltedpass = NGCP::Panel::Utils::Admin::generate_salted_hash($default_pass);
my $now = NGCP::Panel::Utils::DateTime::current_local;
my %defaults = (
contacts => {
@ -449,7 +454,7 @@ sub create_defaults :Path('create_defaults') :Args(0) :Does(ACL) :ACLDetachTo('/
# start_date => $now,
#},
admins => {
md5pass => 'defaultresellerpassword',
saltedpass => $saltedpass,
is_active => 1,
show_passwords => 1,
call_data => 1,
@ -517,7 +522,7 @@ sub create_defaults :Path('create_defaults') :Args(0) :Does(ACL) :ACLDetachTo('/
};
NGCP::Panel::Utils::Message::info(
c => $c,
desc => $c->loc('Reseller successfully created with login <b>[_1]</b> and password <b>[_2]</b>, please review your settings below', $defaults{admins}->{login}, $defaults{admins}->{md5pass}),
desc => $c->loc('Reseller successfully created with login <b>[_1]</b> and password <b>[_2]</b>, please review your settings below', $defaults{admins}->{login}, $default_pass),
);
$c->res->redirect($c->uri_for_action('/reseller/details', [$r{resellers}->id]));
$c->detach;

@ -34,6 +34,9 @@ use NGCP::Panel::Form::Intercept::Delete;
use Data::Structure::Util qw/unbless/;
use UUID;
use Moose;
use NGCP::Panel::Utils::Admin;
use Crypt::Eksblowfish::Bcrypt qw/bcrypt_hash en_base64 de_base64/;
has 'c' => (is => 'rw', isa => 'Object');
sub _validate {
@ -67,11 +70,40 @@ sub _auth {
$self->_validate(NGCP::Panel::Form::Intercept::Authentication->new(ctx => $c), $auth);
try {
# check for general availability of user first, we need it in
# both md5 and bcrypt cases
my $admin = $c->model('DB')->resultset('admins')->search({
login => $auth->{username},
md5pass => { '=' => \['MD5("'.$auth->{password}.'")'] },
is_active => 1,
})->first;
die unless($admin && ($admin->is_superuser || $admin->lawful_intercept));
if(defined $admin->saltedpass) {
my ($db_b64salt, $db_b64hash) = split /\$/, $admin->saltedpass;
my $salt = de_base64($db_b64salt);
my $usr_b64hash = en_base64(bcrypt_hash({
key_nul => 1,
cost => NGCP::Panel::Utils::Admin::get_bcrypt_cost(),
salt => $salt,
}, $auth->{password}));
die unless($usr_b64hash eq $db_b64hash);
} else {
my $md5admin = $c->model('DB')->resultset('admins')->search({
login => $auth->{username},
is_active => 1,
md5pass => { '=' => \['MD5("'.$auth->{password}.'")'] },
})->first;
die unless($md5admin && ($md5admin->is_superuser || $md5admin->lawful_intercept));
# migrate password to bcrypt
$admin->update({
md5pass => undef,
saltedpass => NGCP::Panel::Utils::Admin::get_salted_hash($auth->{password}),
});
}
} catch($e) {
die SOAP::Fault
->faultcode('Client.Auth.Refused')

@ -8,52 +8,6 @@ has_field 'submitid' => ( type => 'Hidden' );
sub build_render_list {[qw/submitid fields actions/]}
sub build_form_element_class {[qw(form-horizontal)]}
has_field 'pem' => (
type => 'Compound',
label => 'Download in PEM Format',
do_label => 1,
do_wrapper => 1,
wrapper_class => [qw(row)],
);
has_field 'pem.download' => (
type => 'Submit',
value => 'Download PEM',
element_class => [qw(btn btn-primary)],
do_wrapper => 0,
do_label => 0,
);
has_field 'pem.description' => (
type => 'Display',
html => '<div class="ngcp-form-desc">X.509 Client Certificate for API Clients (perl, php etc.)</div>',
do_wrapper => 0,
do_label => 0,
);
has_field 'p12' => (
type => 'Compound',
label => 'Download in PKCS12 Format',
do_label => 1,
do_wrapper => 1,
wrapper_class => [qw(row)],
);
has_field 'p12.download' => (
type => 'Submit',
value => 'Download PKCS12',
element_class => [qw(btn btn-primary)],
do_wrapper => 0,
do_label => 0,
);
has_field 'p12.description' => (
type => 'Display',
html => '<div class="ngcp-form-desc">X.509 Client Certificate for Browsers (Firefox, Chrome etc.). The <strong>password</strong> for the browser import is <strong>sipwise</strong>.</div>',
do_wrapper => 0,
do_label => 0,
);
has_field 'ca' => (
type => 'Compound',
label => 'Download CA Certificate',
@ -104,7 +58,7 @@ has_field 'del.description' => (
has_block 'fields' => (
tag => 'div',
class => [qw(modal-body)],
render_list => [qw(pem p12 ca del)],
render_list => [qw(ca del)],
);
has_field 'close' => (

@ -41,6 +41,7 @@ sub hal_from_item {
my %resource = $item->get_inflated_columns;
delete $resource{md5pass};
delete $resource{saltedpass};
my $hal = NGCP::Panel::Utils::DataHal->new(
links => [
@ -92,7 +93,8 @@ sub update_item {
);
delete $resource->{password};
if(defined $pass) {
$resource->{md5pass} = $pass;
$resource->{md5pass} = undef;
$resource->{saltedpass} = NGCP::Panel::Utils::Admin::generate_salted_hash($pass);
}
if($c->user->roles eq "reseller" && $resource->{reseller_id} != $c->user->reseller_id) {

@ -0,0 +1,25 @@
package NGCP::Panel::Utils::Admin;
use Sipwise::Base;
use Crypt::Eksblowfish::Bcrypt qw/bcrypt_hash en_base64/;
use Data::Entropy::Algorithms qw/rand_bits/;
sub get_bcrypt_cost {
return 13;
}
sub generate_salted_hash {
my $pass = shift;
my $salt = rand_bits(128);
my $b64salt = en_base64($salt);
my $b64hash = en_base64(bcrypt_hash({
key_nul => 1,
cost => get_bcrypt_cost(),
salt => $salt,
}, $pass));
return $b64salt . '$' . $b64hash;
}
1;

@ -0,0 +1,16 @@
#!/usr/bin/perl -w
use strict;
use v5.14;
use lib '../lib';
use lib '../../sipwise-base/lib';
use NGCP::Panel::Utils::Admin;
use Time::HiRes qw/gettimeofday tv_interval/;
my $t0 = [gettimeofday()];
NGCP::Panel::Utils::Admin::generate_salted_hash("testpass");
my $t1 = [gettimeofday()];
my $diff = tv_interval($t0, $t1);
say $diff;
Loading…
Cancel
Save