From 60fa23cb681afe3223794eccd0d6d8f6cb426152 Mon Sep 17 00:00:00 2001 From: Kirill Solomko Date: Sat, 27 Jul 2024 17:49:02 +0200 Subject: [PATCH] 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 --- lib/Catalyst/Plugin/NGCP/Redis.pm | 31 ++++++ lib/NGCP/Panel.pm | 1 + .../Panel/Controller/API/PasswordRecovery.pm | 7 +- lib/NGCP/Panel/Controller/Login.pm | 8 +- lib/NGCP/Panel/Controller/Root.pm | 11 +-- lib/NGCP/Panel/Role/API/CallQueues.pm | 28 +----- lib/NGCP/Panel/Utils/Auth.pm | 95 ++++++++++++++++--- lib/NGCP/Panel/Utils/Redis.pm | 23 ----- lib/NGCP/Panel/Utils/Subscriber.pm | 8 +- 9 files changed, 122 insertions(+), 90 deletions(-) create mode 100644 lib/Catalyst/Plugin/NGCP/Redis.pm delete mode 100644 lib/NGCP/Panel/Utils/Redis.pm diff --git a/lib/Catalyst/Plugin/NGCP/Redis.pm b/lib/Catalyst/Plugin/NGCP/Redis.pm new file mode 100644 index 0000000000..097eb44b13 --- /dev/null +++ b/lib/Catalyst/Plugin/NGCP/Redis.pm @@ -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; diff --git a/lib/NGCP/Panel.pm b/lib/NGCP/Panel.pm index 38976ba81b..2d402a453c 100644 --- a/lib/NGCP/Panel.pm +++ b/lib/NGCP/Panel.pm @@ -30,6 +30,7 @@ use Catalyst qw/ NGCP::EscapeJs NGCP::EscapeURI NGCP::License + NGCP::Redis I18N /; use Log::Log4perl::Catalyst qw(); diff --git a/lib/NGCP/Panel/Controller/API/PasswordRecovery.pm b/lib/NGCP/Panel/Controller/API/PasswordRecovery.pm index 59d0769765..29387f6b2e 100644 --- a/lib/NGCP/Panel/Controller/API/PasswordRecovery.pm +++ b/lib/NGCP/Panel/Controller/API/PasswordRecovery.pm @@ -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."); diff --git a/lib/NGCP/Panel/Controller/Login.pm b/lib/NGCP/Panel/Controller/Login.pm index 23c4780fc3..d6d7c8f7ac 100644 --- a/lib/NGCP/Panel/Controller/Login.pm +++ b/lib/NGCP/Panel/Controller/Login.pm @@ -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)."'"); diff --git a/lib/NGCP/Panel/Controller/Root.pm b/lib/NGCP/Panel/Controller/Root.pm index 4d2c07bae8..389be6e1f3 100644 --- a/lib/NGCP/Panel/Controller/Root.pm +++ b/lib/NGCP/Panel/Controller/Root.pm @@ -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); diff --git a/lib/NGCP/Panel/Role/API/CallQueues.pm b/lib/NGCP/Panel/Role/API/CallQueues.pm index 73725a6595..5e19416784 100644 --- a/lib/NGCP/Panel/Role/API/CallQueues.pm +++ b/lib/NGCP/Panel/Role/API/CallQueues.pm @@ -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; } diff --git a/lib/NGCP/Panel/Utils/Auth.pm b/lib/NGCP/Panel/Utils/Auth.pm index 70aef68ba7..4b1c7fccba 100644 --- a/lib/NGCP/Panel/Utils/Auth.pm +++ b/lib/NGCP/Panel/Utils/Auth.pm @@ -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; diff --git a/lib/NGCP/Panel/Utils/Redis.pm b/lib/NGCP/Panel/Utils/Redis.pm deleted file mode 100644 index 9ec29323d6..0000000000 --- a/lib/NGCP/Panel/Utils/Redis.pm +++ /dev/null @@ -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; diff --git a/lib/NGCP/Panel/Utils/Subscriber.pm b/lib/NGCP/Panel/Utils/Subscriber.pm index 971e1ec0fa..6fc5fa6289 100644 --- a/lib/NGCP/Panel/Utils/Subscriber.pm +++ b/lib/NGCP/Panel/Utils/Subscriber.pm @@ -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;