MT#60585 add user ban support

* users for admin/subscriber realms are now banned if failed
  to login X amount of times (UI/API).
* rework Redis connection and it's now a Catalyst plugin NGCP::Redis
  accessed by $c->redis_get_connection({database => 19}), the connection
  per database, per worker process is established only once and then
  reused (with auto built-in reconnect support).
* remove Utils::Redis.pm as it does not have any code/logic anymore.
* ban values are taken from $config->{security}{login} as
  - ban_enable: 1
  - ban_expire_time: 3600 ban expire time in seconds
  - max_attempts: 5
* if max_attempts set to 0, the ban functionality is disabled as it
  requires to be at least 1 to work.
* upon successful login or ban, the failed attempts counter is removed
* the failed attempts counter is also removed automatically with the
  expire time equals "ban_expire_time" or otherwise 3600 seconds.
* user bans are logged into panel.log
* banned user receives exactly the same return page/codes as per
  invalid logic.

Change-Id: I05cc68c623ee289488fc64f1af50527004dcaae1
mr12.5
Kirill Solomko 9 months ago
parent d9f283cbc8
commit 60fa23cb68

@ -0,0 +1,31 @@
package Catalyst::Plugin::NGCP::Redis;
use strict;
use warnings;
use MRO::Compat;
use Redis;
my $conn = {};
sub redis_get_connection {
my ($c, $params) = @_;
my $db = $params->{database} // return;
my $redis;
$redis = $conn->{$db} // do {
$redis = Redis->new(
server => $c->config->{redis}->{central_url},
reconnect => 10, every => 500000, # 500ms
cnx_timeout => 3,
);
unless ($redis) {
$c->log->error("Failed to connect to central redis url " . $c->config->{redis}->{central_url});
return;
}
$redis->select($params->{database});
$conn->{$db} = $redis;
};
return $redis;
}
1;

@ -30,6 +30,7 @@ use Catalyst qw/
NGCP::EscapeJs
NGCP::EscapeURI
NGCP::License
NGCP::Redis
I18N
/;
use Log::Log4perl::Catalyst qw();

@ -80,18 +80,13 @@ sub POST :Allow {
return;
}
my $redis = Redis->new(
server => $c->config->{redis}->{central_url},
reconnect => 10, every => 500000, # 500ms
cnx_timeout => 3,
);
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
unless ($redis) {
$res = {success => 0};
$self->error($c, HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error',
"Failed to connect to central redis url " . $c->config->{redis}->{central_url});
return;
}
$redis->select($c->config->{'Plugin::Session'}->{redis_db});
my $admin = $redis->hget("password_reset:admin::$uuid_string", "user");
if ($admin) {
$c->log->debug("Entering password recovery for administrator.");

@ -4,7 +4,6 @@ use warnings;
use strict;
use parent 'Catalyst::Controller';
use Redis;
use TryCatch;
use UUID;
@ -196,16 +195,11 @@ sub recover_password :Chained('/') :PathPart('recoverpassword') :Args(0) {
$c->detach('/denied_page')
}
my $redis = Redis->new(
server => $c->config->{redis}->{central_url},
reconnect => 10, every => 500000, # 500ms
cnx_timeout => 3,
);
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
unless ($redis) {
$c->log->error("Failed to connect to central redis url " . $c->config->{redis}->{central_url});
return;
}
$redis->select($c->config->{'Plugin::Session'}->{redis_db});
my $ip = $redis->hget("password_reset:admin::$uuid_string", "ip");
if ($ip && $ip ne $c->req->address) {
$c->log->warn("invalid password recovery attempt for token '$uuid_string' from '".$c->qs($c->req->address)."'");

@ -541,8 +541,7 @@ sub login_jwt :Chained('/') :PathPart('login_jwt') :Args(0) :Method('POST') {
}
$auth_user = $c->user;
} elsif ($auth_token) {
my $redis = NGCP::Panel::Utils::Redis::get_redis_connection($c, {database => $c->config->{'Plugin::Session'}->{redis_db}});
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
unless ($redis) {
$c->response->status(HTTP_INTERNAL_SERVER_ERROR);
$c->response->body(encode_json({
@ -782,7 +781,6 @@ sub login_to_v2 :Chained('/') :PathPart('login_to_v2') :Args(0) {
use JSON qw/encode_json decode_json/;
use Crypt::JWT qw/encode_jwt/;
use Redis;
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};
@ -806,16 +804,11 @@ sub login_to_v2 :Chained('/') :PathPart('login_to_v2') :Args(0) {
$relative_exp ? (relative_exp => $relative_exp) : (),
);
my $redis = Redis->new(
server => $c->config->{redis}->{central_url},
reconnect => 10, every => 500000, # 500ms
cnx_timeout => 3,
);
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
unless ($redis) {
$c->log->error("Failed to connect to central redis url " . $c->config->{redis}->{central_url});
return;
}
$redis->select($c->config->{'Plugin::Session'}->{redis_db});
$redis->set("jwt:$token", '');
$redis->expire("jwt:$token", 300);

@ -13,34 +13,12 @@ my $redis_callqueue_key_prefix = 'callqueue:';
my $redis_dialogdata_key_prefix = 'dialog:cid::';
my $number_search_limit = 100; # scan redis only if collection gets bigger than this
# todo: move this stash factory method below to some util.
sub _get_redis {
my ($self, $c, $select) = @_;
my $stash_key = 'redis';
if (defined $select) {
$stash_key .= '_' . $select;
} else {
$c->error("redis store not specified");
return;
}
my $redis = $c->stash->{$stash_key};
my $redis = $c->redis_get_connection({database => $select});
unless ($redis) {
try {
$redis = Redis->new(
server => $c->config->{redis}->{central_url},
reconnect => 10, every => 500000, # 500ms
cnx_timeout => 3,
);
unless ($redis) {
$c->error("Failed to connect to central redis url " . $c->config->{redis}->{central_url});
return;
}
$redis->select($select) if defined $select;
$c->stash($stash_key => $redis);
} catch($e) {
$c->error("Failed to fetch callqueue information from redis: $e");
return;
}
$c->error("Failed to fetch callqueue information from redis");
return;
}
return $redis;
}

@ -5,9 +5,7 @@ use Crypt::Eksblowfish::Bcrypt qw/bcrypt_hash en_base64 de_base64/;
use Data::Entropy::Algorithms qw/rand_bits/;
use IO::Compress::Zip qw/zip/;
use IPC::System::Simple qw/capturex/;
use Redis;
use UUID;
use NGCP::Panel::Utils::Redis;
our $SALT_LENGTH = 128;
our $ENCRYPT_SUBSCRIBER_WEBPASSWORDS = 1;
@ -58,7 +56,8 @@ sub perform_auth {
my ($c, $user, $pass, $realm, $bcrypt_realm) = @_;
my $res;
return $res unless check_password($pass);
return $res if !check_password($pass);
return $res if user_is_banned($c, $user, $realm);
my $dbadmin;
$dbadmin = $c->model('DB')->resultset('admins')->find({
@ -113,11 +112,14 @@ sub perform_auth {
});
}
}
$res ? clear_failed_login_attempts($c, $user, $realm)
: log_failed_login_attempt($c, $user, $realm);
return $res;
}
sub is_salted_hash {
my $password = shift;
if (length($password)
and (length($password) == 54 or length($password) == 56)
@ -125,7 +127,6 @@ sub is_salted_hash {
return 1;
}
return 0;
}
sub perform_subscriber_auth {
@ -135,7 +136,10 @@ sub perform_subscriber_auth {
if ($pass && $pass =~ /[^[:ascii:]]/) {
return $res;
}
my $userdom = "$user:$domain";
return $res if user_is_banned($c, $userdom, 'subscriber');
my $authrs = $c->model('DB')->resultset('provisioning_voip_subscribers')->search({
webusername => $user,
'voip_subscriber.status' => 'active',
@ -215,6 +219,10 @@ sub perform_subscriber_auth {
'subscriber');
}
}
$res ? clear_failed_login_attempts($c, $userdom, 'subscriber')
: log_failed_login_attempt($c, $userdom, 'subscriber');
return $res;
}
@ -424,15 +432,10 @@ sub initiate_password_reset {
my ($uuid_bin, $uuid_string);
UUID::generate($uuid_bin);
UUID::unparse($uuid_bin, $uuid_string);
my $redis = Redis->new(
server => $c->config->{redis}->{central_url},
reconnect => 10, every => 500000, # 500ms
cnx_timeout => 3,
);
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
unless ($redis) {
return {success => 0, error => "Failed to connect to central redis url " . $c->config->{redis}->{central_url}};
}
$redis->select($c->config->{'Plugin::Session'}->{redis_db});
my $username = $admin->login;
if ($redis->exists("password_reset:admin::$username")) {
return {success => 0, error => 'A password reset attempt has been made already recently, please check your email.'};
@ -460,7 +463,7 @@ sub generate_auth_token {
my ($self, $c, $type, $role, $user_id, $expires) = @_;
my ($uuid_bin, $uuid_string);
my $redis = NGCP::Panel::Utils::Redis::get_redis_connection($c, {database => $c->config->{'Plugin::Session'}->{redis_db}});
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
unless ($redis) {
$c->log->error("Could not generate auth token for user $user_id, no Redis connection available");
@ -487,4 +490,70 @@ sub generate_auth_token {
return $uuid_string;
}
sub user_is_banned {
my ($c, $user, $realm) = @_;
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
my $key = "login:ban:$user:$realm";
return $redis->exists($key) ? 1 : 0;
}
sub log_failed_login_attempt {
my ($c, $user, $realm) = @_;
return unless $c->config->{security}{login}{ban_enable};
my $expire = $c->config->{security}{login}{ban_expire_time} // 0;
my $max_attempts = $c->config->{security}{login}{max_attempts} // return;
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
my $key = "login:fail:$user:$realm";
my $attempted = ($redis->hget($key, 'attempts') // 0) + 1;
$attempted >= $max_attempts
? ban_user($c, $user, $realm)
: do {
$redis->multi();
$redis->hset($key, 'attempts', $attempted);
$redis->hset($key, 'last_attempt', time());
$redis->expire($key, $expire // 3600); # always expire invalid login attempts
$redis->exec();
};
return;
}
sub clear_failed_login_attempts {
my ($c, $user, $realm) = @_;
my $key = "login:fail:$user:$realm";
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
$redis->del($key);
return;
}
sub ban_user {
my ($c, $user, $realm) = @_;
return unless $c->config->{security}{login}{ban_enable};
my $expire = $c->config->{security}{login}{ban_expire_time} // 0;
$expire = 30;
my $key = "login:ban:$user:$realm";
$c->log->info("ban user=$user realm=$realm for $expire seconds");
my $redis = $c->redis_get_connection({database => $c->config->{'Plugin::Session'}->{redis_db}});
$redis->hset($key, 'banned_at', time());
$redis->expire($key, $expire) if $expire;
clear_failed_login_attempts($c, $user, $realm);
return;
}
1;

@ -1,23 +0,0 @@
package NGCP::Panel::Utils::Redis;
use warnings;
use strict;
use Redis;
sub get_redis_connection {
my ($c, $params) = @_;
my $redis = Redis->new(
server => $c->config->{redis}->{central_url},
reconnect => 10, every => 500000, # 500ms
cnx_timeout => 3,
);
unless ($redis) {
$c->log->error("Failed to connect to central redis url " . $c->config->{redis}->{central_url});
return;
}
$redis->select($params->{database});
return $redis;
}
1;

@ -24,7 +24,6 @@ use JSON qw/decode_json encode_json/;
use HTTP::Status qw(:constants);
use IPC::System::Simple qw/capturex/;
use File::Slurp qw/read_file/;
use Redis;
use NGCP::Panel::Utils::Encryption qw();
my %LOCK = (
@ -41,16 +40,11 @@ sub get_subscriber_location_rs {
if ($c->config->{redis}->{usrloc}) {
my $redis;
try {
$redis = Redis->new(
server => $c->config->{redis}->{central_url},
reconnect => 10, every => 500000, # 500ms
cnx_timeout => 3,
);
my $redis = $c->redis_get_connection({database => $c->config->{redis}->{usrloc_db}});
unless ($redis) {
$c->log->error("Failed to connect to central redis url " . $c->config->{redis}->{central_url});
return;
}
$redis->select($c->config->{redis}->{usrloc_db});
my $rs = NGCP::Panel::Utils::RedisLocationResultSet->new(_redis => $redis, _c => $c);
$rs = $rs->search($filter, $opt) if ($filter and scalar keys %$filter);
return $rs;

Loading…
Cancel
Save