From 653b5298e3e74218629cf6fb90a155555609a8e6 Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Sat, 27 Jun 2026 16:53:04 +0300 Subject: [PATCH 01/13] =?UTF-8?q?dnk:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D1=82=D0=B5=D1=81=D1=82=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20pluck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/t/unit/utils/pluck.t | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 app/t/unit/utils/pluck.t diff --git a/app/t/unit/utils/pluck.t b/app/t/unit/utils/pluck.t new file mode 100644 index 00000000..eb37cb80 --- /dev/null +++ b/app/t/unit/utils/pluck.t @@ -0,0 +1,66 @@ +use v5.14; +use utf8; + +use Test::More; +use Test::Deep; +use Core::Base; +use Core::Utils qw/pluck/; + +subtest 'pluck from hashrefs by field key' => sub { + my $res = pluck( + [ + { login => 'a@example.com' }, + { login => 'b@example.com' }, + ], + 'login', + ); + + cmp_deeply( $res, [ 'a@example.com', 'b@example.com' ], 'extracts login from hashes' ); +}; + +subtest 'pluck from hashrefs by getter key is not supported' => sub { + my $res = pluck( + [ + { login => 'a@example.com' }, + { login => 'b@example.com' }, + ], + 'get_login', + ); + + cmp_deeply( $res, [], 'getter-like key does not map to hash field names' ); +}; + +subtest 'pluck from blessed objects by getter key' => sub { + { + package Test::PluckObj; + sub new { bless { login => $_[1] }, $_[0] } + sub get_login { $_[0]->{login} } + } + + my $res = pluck( + [ + Test::PluckObj->new('a@example.com'), + Test::PluckObj->new('b@example.com'), + ], + 'get_login', + ); + + cmp_deeply( $res, [ 'a@example.com', 'b@example.com' ], 'extracts via object getter method' ); +}; + +subtest 'pluck from Base-like objects by field key' => sub { + { + package Test::PluckObj2; + use parent -norequire, 'Core::Base'; + sub new { bless { res => { login => $_[1] } }, $_[0] } + } + + my $res = pluck( + [ Test::PluckObj2->new('a@example.com') ], + 'login', + ); + + cmp_deeply( $res, [ 'a@example.com' ], 'extracts login via Base AUTOLOAD when key is login' ); +}; + +done_testing(); From 2cb0cc2203baeb4b55d3414b487b01cd7cfd5455 Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Sat, 27 Jun 2026 16:19:48 +0300 Subject: [PATCH 02/13] =?UTF-8?q?dnk:=20=D0=A8=D0=B0=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D1=8B=20-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=84=D0=BB=D0=B0=D0=B3=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20filter=20isTrue=20=D0=B8=20isFalse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/Core/Sql/Data.pm | 16 +++++++++++----- app/lib/Core/Template.pm | 2 ++ app/t/unit/sql/query_filter.t | 26 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/app/lib/Core/Sql/Data.pm b/app/lib/Core/Sql/Data.pm index d27c3093..e19900e3 100644 --- a/app/lib/Core/Sql/Data.pm +++ b/app/lib/Core/Sql/Data.pm @@ -348,6 +348,12 @@ sub prepare_query_for_filtering { } elsif ($$value eq 'false') { # Поле равно лжи (для boolean полей) $result{$field} = 0; + } elsif ($$value eq 'isTrue') { + # Поле истинно (поддерживает и true, и 1) + $result{"--LOWER($field)"} = { '-in' => [ 'true', '1' ] }; + } elsif ($$value eq 'isFalse') { + # Поле ложно (поддерживает и false, и 0) + $result{"--LOWER($field)"} = { '-in' => [ 'false', '0' ] }; } elsif ($$value =~ /^(lt|gt|le|ge|eq|ne):(.*)$/) { # Операторы сравнения с числами: lt:5, gt:10, le:100, etc. my ($op, $val) = ($1, $2); @@ -433,12 +439,12 @@ sub query_for_filtering { } else { # Если есть специальные ключи с префиксом --, обрабатываем их for my $prep_key ( keys %$prepared ) { - if ( $prep_key =~ /^--COALESCE\(temp_field,/ ) { + if ( $prep_key =~ /^--/ ) { # Заменяем temp_field на реальный путь JSON и убираем префикс -- - my $coalesce_key = $prep_key; - $coalesce_key =~ s/temp_field/$field_path/; - $coalesce_key =~ s/^--//; # Убираем префикс -- - $where{ $coalesce_key } = $prepared->{ $prep_key }; + my $raw_key = $prep_key; + $raw_key =~ s/temp_field/$field_path/g; + $raw_key =~ s/^--//; + $where{ $raw_key } = $prepared->{ $prep_key }; } else { $where{ $field_path } = $prepared->{ $prep_key }; } diff --git a/app/lib/Core/Template.pm b/app/lib/Core/Template.pm index 31d40851..1fa67ced 100644 --- a/app/lib/Core/Template.pm +++ b/app/lib/Core/Template.pm @@ -177,6 +177,8 @@ sub parse { isNotNull => sub { return \'isNotNull' }, isEmpty => sub { return \'isEmpty' }, isNotEmpty => sub { return \'isNotEmpty' }, + isTrue => sub { return \'isTrue' }, + isFalse => sub { return \'isFalse' }, # Numeric comparisons lt => sub { return \('lt:' . ($_[0] // '')) }, # less than gt => sub { return \('gt:' . ($_[0] // '')) }, # greater than diff --git a/app/t/unit/sql/query_filter.t b/app/t/unit/sql/query_filter.t index f3914aeb..581785a6 100644 --- a/app/t/unit/sql/query_filter.t +++ b/app/t/unit/sql/query_filter.t @@ -95,6 +95,20 @@ subtest 'prepare_query_for_filtering - true/false' => sub { ); }; +subtest 'prepare_query_for_filtering - isTrue/isFalse' => sub { + cmp_deeply( + prepare_query_for_filtering({ + enabled => \'isTrue', + blocked => \'isFalse', + }), + { + '--LOWER(enabled)' => { '-in' => [ 'true', '1' ] }, + '--LOWER(blocked)' => { '-in' => [ 'false', '0' ] }, + }, + 'isTrue/isFalse convert to LOWER(field) IN (...) checks' + ); +}; + subtest 'prepare_query_for_filtering - numeric comparisons' => sub { cmp_deeply( prepare_query_for_filtering({ @@ -293,6 +307,8 @@ subtest 'query_for_filtering - nested JSON expressions with mock' => sub { 'auto_backup' => \'ne:1', 'max_items' => \'gt:10', 'notifications' => \'true', + 'enabled_bool' => \'isTrue', + 'disabled_bool' => \'isFalse', 'theme' => 'dark', 'priority' => \'le:5', 'enabled' => \'false', @@ -307,6 +323,16 @@ subtest 'query_for_filtering - nested JSON expressions with mock' => sub { cmp_deeply($result->{"settings->>'\$.priority'"}, { '<=' => '5' }, 'le:5 converted correctly for JSON field'); is($result->{"settings->>'\$.notifications'"}, 1, 'true converted to 1 for JSON field'); is($result->{"settings->>'\$.enabled'"}, 0, 'false converted to 0 for JSON field'); + cmp_deeply( + $result->{"LOWER(settings->>'\$.enabled_bool')"}, + { '-in' => [ 'true', '1' ] }, + 'isTrue converted correctly for JSON field' + ); + cmp_deeply( + $result->{"LOWER(settings->>'\$.disabled_bool')"}, + { '-in' => [ 'false', '0' ] }, + 'isFalse converted correctly for JSON field' + ); is($result->{"settings->>'\$.theme'"}, 'dark', 'Regular string value preserved for JSON field'); cmp_deeply($result->{"settings->>'\$.api_key'"}, { '!=' => undef }, 'isNotNull converted correctly for JSON field'); ok(exists $result->{"COALESCE(settings->>'\$.temp_data', '')"}, 'COALESCE field exists for isEmpty in JSON'); From 7b4d2fa5c76303fffea34a92cf02d663a55b3095 Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Sat, 27 Jun 2026 15:18:37 +0300 Subject: [PATCH 03/13] dnk: temporary off validate_attributes --- app/lib/Core/Base.pm | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/lib/Core/Base.pm b/app/lib/Core/Base.pm index 4d79bae0..5158d822 100644 --- a/app/lib/Core/Base.pm +++ b/app/lib/Core/Base.pm @@ -334,12 +334,12 @@ sub _add_or_set { my $method = shift; my %args = @_; - if ( $self->can('validate_attributes') ) { - unless ( $self->validate_attributes( $method, %args ) ) { - logger->warning('validate attribute error:', $method, \%args ); - return undef; - } - } + # if ( $self->can('validate_attributes') ) { + # unless ( $self->validate_attributes( $method, %args ) ) { + # logger->warning('validate attribute error:', $method, \%args ); + # return undef; + # } + # } if ( $method eq 'add' ) { if ( my $defaults = cfg('defaults')->{ lc $self->kind } ) { From 8b7a22813a51ae2d67798a4a75925921c3b37f3e Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Sat, 20 Jun 2026 12:32:34 +0300 Subject: [PATCH 04/13] =?UTF-8?q?dnk:=20ACCOUNTS=20-=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/bin/migrations/3.0.0.sql | 62 ++ app/lib/Core/Sql/Data.pm | 47 +- app/lib/Core/Transport/Telegram.pm | 128 ++-- app/lib/Core/User.pm | 551 ++++++------------ app/lib/Core/User/Logins.pm | 217 +++++++ app/lib/Core/User/Passwd.pm | 220 +++++++ app/lib/Core/Utils.pm | 1 + app/public_html/shm/v1.cgi | 65 ++- app/sql/shm/shm_dev_cleanup.sql | 1 + app/sql/shm/shm_dev_test_data.sql | 19 +- app/sql/shm/shm_structure.sql | 25 +- app/t/integration/billing/full_test.t | 7 +- app/t/integration/spool/make_task.t | 8 +- app/t/integration/template/parser.t | 4 +- .../transport/telegram_oidc_flow.t | 106 ++-- app/t/integration/user/functions.t | 9 +- app/t/unit/services/user_emails.t | 210 +++++++ app/t/unit/sql/clean_query_args.t | 1 + app/version.json | 4 +- 19 files changed, 1150 insertions(+), 535 deletions(-) create mode 100644 app/bin/migrations/3.0.0.sql create mode 100644 app/lib/Core/User/Logins.pm create mode 100644 app/t/unit/services/user_emails.t diff --git a/app/bin/migrations/3.0.0.sql b/app/bin/migrations/3.0.0.sql new file mode 100644 index 00000000..2ab2d4d1 --- /dev/null +++ b/app/bin/migrations/3.0.0.sql @@ -0,0 +1,62 @@ +BEGIN; +SET FOREIGN_KEY_CHECKS = 0; + +-- 1. Create accounts table (safe to run if already exists) +CREATE TABLE IF NOT EXISTS `accounts` ( + `login` varchar(128) NOT NULL, + `type` char(16) NOT NULL DEFAULT 'login', + `user_id` int(11) NOT NULL, + `settings` json DEFAULT NULL, + PRIMARY KEY (`login`, `type`), + KEY `idx_accounts_user_id` (`user_id`), + CONSTRAINT `fk_accounts_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. Copy primary accounts from users.login +INSERT IGNORE INTO `accounts` (`login`, `user_id`, `type`) + SELECT LOWER(`login`), `user_id`, 'login' FROM `users`; + +-- 3. Copy secondary accounts from users.login2 where they exist and are not duplicates +INSERT IGNORE INTO `accounts` (`login`, `user_id`, `type`) + SELECT LOWER(`login2`), `user_id`, 'login' FROM `users` + WHERE `login2` IS NOT NULL AND `login2` != ''; + +-- 4. Copy email from users.settings.email where set and not already in accounts +INSERT IGNORE INTO `accounts` (`login`, `user_id`, `type`, `settings`) + SELECT LOWER(JSON_UNQUOTE(JSON_EXTRACT(`settings`, '$.email'))), `user_id`, 'email', + JSON_OBJECT( + 'email', JSON_OBJECT( + 'verified', COALESCE(JSON_EXTRACT(`settings`, '$.email_verified'), 0) + ) + ) + FROM `users` + WHERE JSON_EXTRACT(`settings`, '$.email') IS NOT NULL + AND JSON_EXTRACT(`settings`, '$.email') != 'null'; + +-- 4.1 Remove migrated legacy email fields from users.settings +UPDATE `users` +SET `settings` = JSON_REMOVE(`settings`, '$.email', '$.email_verified') +WHERE JSON_CONTAINS_PATH(`settings`, 'one', '$.email', '$.email_verified'); + +-- 4.2 Copy telegram user_id from users.settings.telegram.user_id as login +INSERT IGNORE INTO `accounts` (`login`, `user_id`, `type`, `settings`) + SELECT JSON_UNQUOTE(JSON_EXTRACT(`settings`, '$.telegram.user_id')), + `user_id`, 'telegram', + JSON_OBJECT('telegram', JSON_EXTRACT(`settings`, '$.telegram')) + FROM `users` + WHERE JSON_EXTRACT(`settings`, '$.telegram.user_id') IS NOT NULL + AND JSON_EXTRACT(`settings`, '$.telegram.user_id') != 'null' + AND JSON_UNQUOTE(JSON_EXTRACT(`settings`, '$.telegram.user_id')) != ''; + +-- 4.3 Remove migrated telegram settings from users.settings +-- UPDATE `users` +-- SET `settings` = JSON_REMOVE(`settings`, '$.telegram') +-- WHERE JSON_CONTAINS_PATH(`settings`, 'one', '$.telegram'); + +-- 5. Remove login2 column from users (run after verifying the migration above) +-- ALTER TABLE `users` DROP KEY `users_uniq`; +-- ALTER TABLE `users` DROP KEY `users_uniq_login2`; +-- ALTER TABLE `users` DROP COLUMN `login2`; + +SET FOREIGN_KEY_CHECKS = 1; +COMMIT; diff --git a/app/lib/Core/Sql/Data.pm b/app/lib/Core/Sql/Data.pm index e19900e3..fb2bf525 100644 --- a/app/lib/Core/Sql/Data.pm +++ b/app/lib/Core/Sql/Data.pm @@ -546,6 +546,27 @@ sub clean_query_args { } } + if ( $f eq $self->get_table_key2() ) { + if ( $settings->{is_update} ) { + unless ( $args->{where}{ $f } ) { + # Добавляем во WHERE ключевое поле + if ( my $id = $self->{res}->{ $f } ) { + $args->{where}{ $f } = $id; + } elsif ( $self->can( $f ) ) { + $args->{where}{ $f } = $self->$f; + } + logger->fatal( "`$f` required", $self ) unless length $args->{where}{ $f }; + } + # Запрещаем обновлять ключевое поле + delete $args->{ $f } if exists $args->{ $f }; + } elsif ( exists $args->{ $f } ) { + # Не используем ключи в insert-ах (админам можно) + unless ( $self->user->authenticated->is_admin ) { + delete $args->{ $f } unless $self->table_allow_insert_key; + } + } + } + if ( $settings->{is_list} ) { if ( $v->{auto_fill} ) { # получаем автоматически if ( exists $self->{ $f } ) { @@ -854,12 +875,19 @@ sub get { $user_id = $self->user_id; } + my %where = ( + sprintf("%s.%s", $self->table, $table_key ) => $self->id, + $user_id ? ( sprintf("%s.%s", $self->table, 'user_id' ) => $user_id, ) : (), + ); + + my $key2 = $self->get_table_key2; + if ( $key2 ) { + $where{ $key2 } = $self->{res}->{ $key2 }; + } + # do not use list() because of list might contain default selectors my ( $ret ) = $self->_list( - where => { - sprintf("%s.%s", $self->table, $table_key ) => $self->id, - $user_id ? ( sprintf("%s.%s", $self->table, 'user_id' ) => $user_id, ) : (), - }, + where => \%where, limit => 1, @_, ); @@ -877,6 +905,17 @@ sub get_table_key { return undef; } +sub get_table_key2 { + my $self = shift; + + my $structure = $self->structure; + + for ( keys %$structure ) { + return $_ if $structure->{ $_ }->{key2}; + } + return undef; +} + sub res_by_arr { my $self = shift; return $self->{res} ? [ keys %{ $self->{res} } ] : []; diff --git a/app/lib/Core/Transport/Telegram.pm b/app/lib/Core/Transport/Telegram.pm index 55426773..107a30eb 100644 --- a/app/lib/Core/Transport/Telegram.pm +++ b/app/lib/Core/Transport/Telegram.pm @@ -111,13 +111,9 @@ sub api_set_user_tg_settings { sub api_delete_user_tg_settings { my $self = shift; - my $username = $self->user_tg_settings->{username}; - my $login2 = $self->user->get_login2; - - if ( defined $username && $username ne '' && defined $login2 && $login2 ne '' ) { - if ( $login2 eq $username || $login2 eq '@' . $username ) { - $self->user->set( login2 => undef ); - } + my $tg_user_id = $self->user_tg_settings->{user_id}; + if ( defined $tg_user_id && $tg_user_id ne '' ) { + $self->user->logins->delete( where => { login => $tg_user_id, type => 'telegram' } ); } $self->user->set_settings({ @@ -129,6 +125,7 @@ sub api_delete_user_tg_settings { # methods for Templates sub settings { shift->user_tg_settings }; sub login { shift->user_tg_settings->{username} }; +sub shm_login { shift->{shm_login} }; sub username { shift->user_tg_settings->{username} }; sub response { my $self = shift; @@ -625,23 +622,13 @@ sub find_user_by_tg { my $self = shift; my $tg_user = shift; - my ( $user ) = $self->user->_list( - where => { - -OR => [ - login => $self->get_shm_login( $tg_user->{id} ), - login2 => '@' . $tg_user->{id}, - $tg_user->{username} ? ( login2 => $tg_user->{username} ) : (), - ], - }, - limit => 1, - ); - return $user; + return $self->user->logins->id( $tg_user->{id}, ['telegram'] ); } sub get_shm_login { my $self = shift; my $tg_user_id = shift; - my $prefix = $self->tg_settings->{login_prefix} || '@'; + my $prefix = $self->tg_settings->{login_prefix}; return sprintf( "%s%s", $prefix, $tg_user_id ); } @@ -656,34 +643,42 @@ sub auth { my $username = $tg_user->{username}; my $full_name = sprintf("%s %s", $tg_user->{first_name}, $tg_user->{last_name} ); - my $user = $self->find_user_by_tg( $tg_user ); - return undef unless $user; - - switch_user( $user->{user_id} ); + my $login = $self->find_user_by_tg( $tg_user ); + return undef unless $login; + return undef unless $self->chat_id; + + switch_user( $login->user_id ); + + my %telegram_settings = ( + telegram => { + user_id => $telegram_user_id, # field for auth + login => $tg_user->{username}, # for backward compatible + username => $tg_user->{username}, + first_name => $tg_user->{first_name}, + last_name => $tg_user->{last_name}, + language_code => $tg_user->{language_code}, + is_premium => $tg_user->{is_premium}, + chat_id => $self->chat_id, # for backward compatible + $self->profile_name() => { + chat_id => $self->chat_id, + status => 'member', + }, + }, + ); - return $self->user unless $self->chat_id; + $login->user->set( last_login => now ); + $login->user->set_settings( \%telegram_settings ); # for backward compatible - $self->user->set( last_login => now ); - $self->user->set_json( - 'settings', { - telegram => { - user_id => $telegram_user_id, # field for auth - login => $tg_user->{username}, # for backward compatible - username => $tg_user->{username}, - first_name => $tg_user->{first_name}, - last_name => $tg_user->{last_name}, - language_code => $tg_user->{language_code}, - is_premium => $tg_user->{is_premium}, - chat_id => $self->chat_id, # for backward compatible - $self->profile_name() => { - chat_id => $self->chat_id, - status => 'member', - }, - }, + $login->set_settings({ + auth => { + date => now(), }, - ) if $self->message->{chat}->{type} eq 'private'; + %telegram_settings, + }) if $self->message->{chat}->{type} eq 'private'; + + $self->{shm_login} = $login->get_login; - return $self->user; + return $login->user; } sub tg_user { @@ -1483,7 +1478,6 @@ sub shmRegister { callback_data => undef, error => undef, partner_id => undef, - user_login => undef, settings => {}, get_smart_args(@_), ); @@ -1503,8 +1497,8 @@ sub shmRegister { my $telegram_user_id = $tg_user->{id}; my $user = $self->user->reg( - login => $args{user_login} || $self->get_shm_login( $telegram_user_id ), - password => passgen(), + login => $self->get_shm_login( $telegram_user_id ), + login_type => 'telegram', full_name => sprintf("%s %s", $tg_user->{first_name}, $tg_user->{last_name} ), settings => { %{ $args{settings} || {} }, @@ -1624,7 +1618,6 @@ sub shmServiceDelete { sub webapp_auth { my $self = shift; my %args = ( - uid => undef, initData => undef, profile => 'telegram_bot', @_, @@ -1640,23 +1633,11 @@ sub webapp_auth { my %in = CGI->new( $args{initData} )->Vars(); my $tg_user = decode_json( $in{user} ); - if ( $args{uid} && $self->user->id($args{uid}) ) { - switch_user( $args{uid} ); - - if ( $tg_user->{id} ne $self->user_tg_settings->{user_id} ) { - report->error("Telegram WebApp auth error: user_id doesn't match"); - $self->set_user_fail_attempt( 'webapp_auth', 3600, $self->telegram_ips ); # 5 fails/hour - return undef; - } - } else { - my $user = $self->find_user_by_tg( $tg_user ); - unless ( $user ) { - logger->error("Telegram WebApp auth error: user not found"); - $self->set_user_fail_attempt( 'webapp_auth', 3600, $self->telegram_ips ); # 5 fails/hour - return undef; - } - - switch_user( $user->{user_id} ); + my $login = $self->find_user_by_tg( $tg_user ); + unless ( $login ) { + logger->error("Telegram WebApp auth error: user not found"); + $self->set_user_fail_attempt( 'webapp_auth', 3600, $self->telegram_ips ); # 5 fails/hour + return undef; } $self->profile( $args{profile} ); @@ -1677,14 +1658,14 @@ sub webapp_auth { } return { - session_id => $self->srv('sessions')->add(), + session_id => $login->user->srv('sessions')->add(), }; } sub web_auth { my $self = shift; my %args = ( - profile => 'telegram_bot', + profile => 'telegram_bot', register_if_not_exists => 0, bind_to_profile => 0, bind_only_if_new => 0, @@ -1761,7 +1742,7 @@ sub web_auth { my @arr = map { "$_=$in{$_}" } sort keys %in; my $data_check_string = join("\n", @arr); - my $token = $self->config->{ $args{profile} }->{token} // $self->config->{token}; + my $token = $self->config->{ $profile }->{token} // $self->config->{token}; use Digest::SHA qw(sha256 hmac_sha256_hex); my $secret_key = sha256( $token ); @@ -1789,9 +1770,9 @@ sub web_auth { } } - my $login2 = '@' . $in{id}; - unless ( $self->user->get_login2 ) { - $self->user->set( login2 => $login2 ); + my $login = $in{id}; + unless ( $self->user->logins->id( $login, ['telegram'] ) ) { + $self->user->logins->add( login => $login, type => 'telegram' ); } my $settings = $self->user->settings->{telegram} || {}; if ( !$settings->{user_id} ) { @@ -1824,7 +1805,7 @@ sub web_auth { if ( !$user && $args{register_if_not_exists} ) { $user = $self->user->reg( - login => sprintf("@%s", $in{id}), + login => $self->get_shm_login( $in{id} ), password => passgen(), full_name => sprintf("%s %s", $in{first_name} || '', $in{last_name} || ''), settings => { @@ -1852,12 +1833,9 @@ sub web_auth { return undef; } - switch_user( $user->{user_id} ); - return { - session_id => $self->srv('sessions')->add(), + session_id => $user->srv('sessions')->add(), }; - } sub web_auth_callback { diff --git a/app/lib/Core/User.pm b/app/lib/Core/User.pm index 1c337dd9..2660d962 100644 --- a/app/lib/Core/User.pm +++ b/app/lib/Core/User.pm @@ -16,6 +16,7 @@ use Core::Utils qw( hash_merge add_period round + pluck ); use Core::User::Captcha qw( gen_captcha @@ -41,18 +42,11 @@ sub structure { }, login => { type => 'text', - required => 1, title => 'логин', - }, - login2 => { - type => 'text', - required => 0, - default => undef, - title => 'логин (дополнительный)', + default => '', }, password => { type => 'text', - required => 1, hide_for_user => 1, title => 'пароль', description => 'пароль в зашифровнном виде', @@ -303,85 +297,49 @@ sub auth_api_safe { sub auth { my $self = shift; my %args = ( - login => undef, + login => undef, password => undef, @_, ); $args{login} = lc( $args{login} ); + return undef unless $args{login} && $args{password}; - return undef unless $args{login} || $args{password}; - - my ( $user_row ) = $self->_list( - where => { - -OR => [ - { login => $args{login} }, - { login2 => $args{login} }, - ], - }, - limit => 1, - ); - - unless ( $user_row ) { - return undef; - } - - unless ( $self->verify_password( $args{password}, $user_row->{password}, $user_row->{login} ) ) { + my $login = $self->logins->id( $args{login} ); + unless ( $login ) { return undef; } - my $user = $self->id( $user_row->{user_id} ); - return undef if $user->is_blocked; + my $self = $self->id( $login->user_id ); + return undef if $self->is_blocked; - # Auto-upgrade to current scheme ($N$) on successful login - if ( $user_row->{password} !~ /^\$\d+\$/ ) { - $user->set( password => $user->make_password( $args{password} ) ); - } + my $login_password = $login->get_password; + my $password = $login_password || $self->get_password; + return undef unless $password; - return $user; -} - -sub passwd { - my $self = shift; - my %args = ( - password => undef, - @_, - ); - - my $report = get_service('report'); - unless ( $args{password} ) { - $report->add_error('Password is empty'); + unless ( $self->verify_password( $args{password}, $password, $self->get_login ) ) { + report->warning('Incorrect login or password: ' . $login->get_login ); return undef; } - my $user = $self; - - if ( $args{admin} && $args{user_id} ) { - $user = get_service('user', _id => $args{user_id} ); + # Auto-upgrade to current scheme ($N$) on successful login + if ( $password !~ /^\$\d+\$/ ) { + my $new_password = $self->make_password( $args{password} ); + if ( $login_password ) { + $login->set_password( $new_password ); + } else { + $self->set( password => $new_password ); + } } - my $password = $user->make_password( $args{password} ); - - get_service('sessions')->delete_user_sessions( user_id => $self->user_id ); - - $user->set( password => $password ); - return scalar $user->get; -} - -sub set_new_passwd { - my $self = shift; - my %args = ( - len => 10, - admin => 0, - @_, - ); - - return undef if $self->is_admin && !$args{admin}; - - my $new_password = passgen( $args{len} ); - $self->passwd( password => $new_password ); + $login->set_settings({ + auth => { + date => now(), + ip => get_user_ip, + }, + }); - return $new_password; + return $self; } sub render_mail_text { @@ -443,260 +401,161 @@ sub gen_session { }; } -sub passwd_reset_request { +sub set_email { my $self = shift; my %args = ( email => undef, - login => undef, @_, ); - my $email; - if ( is_email($args{email}) ) { - $email = $args{email}; - } - - my $existing_user = $self->check_exists_logins( login => $args{login} || $email ); - my $user_id = $existing_user ? $existing_user->{user_id} : undef; - - if ( !$user_id && $email ) { - my $profile = get_service("profile"); - my ( $profile_data ) = $profile->_list( - where => { - sprintf('%s->>"$.%s"', 'data', 'email') => $email, - }, - limit => 1, - ); - $user_id = $profile_data->{user_id} if $profile_data; - } - - return { msg => 'User not found' } unless $user_id; - - $self = $self->id( $user_id ); - if ( $self->is_blocked ) { - return { msg => 'User is blocked' }; + my $email = lc( $args{email} ); + unless ( is_email( $email ) ) { + return { msg => 'is not email' }; } - unless ( cfg('cli')->{use_for_reset_password} ) { - $self->make_event( 'user_password_reset' ); - return { msg => 'Successful' }; + my $login = $self->logins->id( $email, ['email'] ); + if ( $login ) { + return { msg => 'already in use' }; } - my $token = passgen( 35 ); - my $expires = time() + 3600; - - $self->user->set_settings({ - reset_password_verify_token => $token, - reset_password_verify_expires => $expires, - }); - - my $project_name = cfg('company')->{name} || 'SHM'; - my $url = cfg('cli')->{url}; - my $link = $url ? "$url?token=$token" : undef; - my %mail_vars = ( - token => $token, - link => $link || '', - url => $url || '', - email => $args{email} || '', - project_name => $project_name, - ); - - my $subject = $self->render_mail_text( - text => cfg('mail')->{reset_password}->{subject} || "$project_name - Сброс пароля", - vars => \%mail_vars, - ); - - my $message = $self->render_mail_text( - text => cfg('mail')->{reset_password}->{message} || "Ваша ссылка для сброса пароля: {{ link }}\n\nСсылка действительна в течение часа.", - vars => \%mail_vars, - ); - - $self->send_mail_message( - to => $args{email}, - subject => $subject, - message => $message, + $self->logins->add( + login => $email, + type => 'email', + settings => { + email => { + verified => 0, + }, + }, ); return { msg => 'Successful' }; } -sub passwd_reset_verify { +sub get_email { my $self = shift; - my %args = ( - token => undef, - password => undef, - @_, - ); - - my $token = $args{token}; - - my ( $user ) = $self->_list( - where => { - sprintf('%s->>"$.%s"', 'settings', 'reset_password_verify_token') => $token, - }, - limit => 1, - ); - - unless ( $user ) { - return { msg => 'Invalid token' }; - } - - $self = $self->id( $user->{user_id} ); - - my $settings = $self->get_settings; - unless ( $settings->{reset_password_verify_token} && $settings->{reset_password_verify_token} eq $token ) { - return { msg => 'Invalid token' }; - } - if ( $settings->{reset_password_verify_expires} && $settings->{reset_password_verify_expires} < time() ) { - return { msg => 'Token expired' }; + my $email_row = first_item $self->logins->items( where => { type => 'email' } ); + if ( $email_row ) { + return { + email => $email_row->get_login, + email_verified => $email_row->settings->{email}->{verified} // 0, + }; } - - unless ( $args{password} ) { - return { msg => 'Successful' }; - } - - delete $settings->{reset_password_verify_token}; - delete $settings->{reset_password_verify_expires}; - $self->set( settings => $settings ); - - $self->passwd( password => $args{password} ); - - return { msg => 'Password reset successful' }; + return {}; } -sub set_email { +sub verify_email { my $self = shift; my %args = ( email => undef, + code => undef, @_, ); - unless ( is_email($args{email}) ) { + my $email = lc( $args{email} ); + unless ( is_email( $email ) ) { return { msg => 'is not email' }; } - if ( $self->check_exists_logins( login => $args{email}, exclude_user_id => $self->id ) ) { - return { msg => 'already in use' }; - } - - my $verified = 0; - my $current_email = $self->user->email; - if ( $current_email && $current_email eq $args{email} ) { - $verified = $self->get_settings->{email_verified} // 0; + my $login = $self->logins->id( $email, ['email'] ); + unless ( $login ) { + return { msg => 'email not found' }; } - $self->user->set_settings({ - email_verified => $verified, - email => $args{email}, - }); - - unless ( $self->user->get_login2 ) { - $self->user->set( login2 => $args{email} ); + if ( $email->settings->{email}->{verified} ) { + return { msg => 'Email verified successfully' }; } - return { msg => 'Successful' }; + return $args{code} ? + $self->email_verify_code_check( $login, $args{code} ) : + $self->email_verify_code_send( $login ); } -sub get_email { +sub email_verify_code_send { my $self = shift; - return { - email => $self->user->email, - email_verified => $self->get_settings->{email_verified} // 0, - }; -} + my $login = shift; -sub verify_email { - my $self = shift; - my %args = ( - email => undef, - code => undef, - @_, - ); - - if ( $args{email} ) { - unless ( is_email($args{email}) ) { - return { msg => 'is not email' }; - } - - my $current_email = $self->user->email; - unless ( $current_email && $args{email} eq $current_email ) { - return { msg => 'Email mismatch. Use the email shown in your profile.' }; - } + my $code = sprintf("%06d", int(rand(1000000))); + my $expires = time() + 600; - my $code = sprintf("%06d", int(rand(1000000))); - my $expires = time() + 600; - - $self->user->set_settings({ - email_verify_code => $code, - email_verify_expires => $expires, - }); - - my $project_name = cfg('company')->{name} || 'SHM'; - my %mail_vars = ( + $login->set_settings({ + email_verify => { code => $code, - email => $args{email}, - project_name => $project_name, - ); + expires => $expires, + }, + }); - my $subject = $self->render_mail_text( - text => cfg('mail')->{email_verify}->{subject} || "$project_name - Код подтверждения email", - vars => \%mail_vars, - ); + my $email = $login->get_login; - my $message = $self->render_mail_text( - text => cfg('mail')->{email_verify}->{message} || "Ваш код подтверждения: {{ code }}\n\nКод действителен 10 минут.", - vars => \%mail_vars, - ); + my $project_name = cfg('company')->{name} || 'SHM'; + my %mail_vars = ( + code => $code, + email => $email, + project_name => $project_name, + ); - $self->send_mail_message( - to => $args{email}, - subject => $subject, - message => $message, - ); + my $subject = $self->render_mail_text( + text => cfg('mail')->{email_verify}->{subject} || "$project_name - Код подтверждения email", + vars => \%mail_vars, + ); - return { msg => 'Verification code sent' }; - } + my $message = $self->render_mail_text( + text => cfg('mail')->{email_verify}->{message} || "Ваш код подтверждения: {{ code }}\n\nКод действителен 10 минут.", + vars => \%mail_vars, + ); - if ( $args{code} ) { - my $settings = $self->get_settings; + $self->send_mail_message( + to => $email, + subject => $subject, + message => $message, + ); - unless ( $args{code} eq $settings->{email_verify_code} ) { - return { msg => 'Invalid code' }; - } + return { msg => 'Verification code sent' }; +} - if ( $settings->{email_verify_expires} && $settings->{email_verify_expires} < time() ) { - return { msg => 'Code expired' }; - } +sub email_verify_code_check { + my $self = shift; + my $login = shift; + my $code = shift; - delete $settings->{email_verify_code}; - delete $settings->{email_verify_expires}; - $settings->{email_verified} = 1; + my $settings = $login->settings->{email_verify}; + unless ( $settings ) { + return { msg => 'Code for email is not set' }; + } - $self->set( settings => $settings ); + if ( $code ne $settings->{code} ) { + return { msg => 'Invalid code' }; + } - return { msg => 'Email verified successfully' }; + if ( $settings->{expires} && $settings->{expires} < time() ) { + return { msg => 'Code expired' }; } - return { msg => 'Email or code required' }; + $login->set_settings({ + email => { + verified => 1, + }, + email_verify => undef, + }); + + return { msg => 'Email verified successfully' }; } sub delete_email { my $self = shift; + my $email = shift; - $self->user->set_settings({ - email => undef, - email_verified => 0, - }); + my $login = $self->logins->id( $email, ['email'] ); + unless ( $login ) { + return { msg => 'Email not found' }; + } + + $login->delete(); return { msg => 'Successful' }; } -sub is_blocked { - my $self = shift; - - return $self->get_block(); -} +sub is_blocked { shift->get_block }; sub validate_attributes { my $self = shift; @@ -705,6 +564,7 @@ sub validate_attributes { my $report = get_service('report'); return 1 if $method eq 'set'; + return 1 if $self->authenticated->is_admin; unless ( $args{login} ) { $report->add_error('Login is empty'); @@ -750,7 +610,7 @@ sub reg_api_safe { } } - if ( $self->check_exists_logins( login => $args{login} ) ) { + if ( $args{login} && $self->check_exists_logins( login => $args{login} ) ) { report->status( 409 ); report->add_error('Login already in use'); return undef; @@ -759,9 +619,9 @@ sub reg_api_safe { $self->set_user_fail_attempt( 'reg_api_safe', 3600 ); # 5 regs/hour return $self->reg( - login => $args{login}, - password => $args{password}, - partner_id => $args{partner_id}, + $args{login} ? ( login => $args{login} ) : (), + $args{password} ? ( password => $args{password} ) : (), + $args{partner_id} ? ( partner_id => $args{partner_id} ) : (), ); } @@ -769,15 +629,15 @@ sub reg { my $self = shift; my %args = ( login => undef, + login_type => 'login', password => undef, partner_id => undef, get_smart_args( @_ ), ); - $args{login} = lc( $args{login} ); $args{settings}{ip} = get_user_ip(); - my $password = $self->make_password( $args{password} ); + my $password = $self->make_password( $args{password} ) if $args{password}; my $partner_id = delete $args{partner_id} || get_cookies('partner_id'); if ( $partner_id ) { @@ -785,62 +645,40 @@ sub reg { delete $args{partner_id} if $partner_id == $self->id; } - my $user_id = $self->add( %args, password => $password ); - - unless ( $user_id ) { - get_service('report')->add_error('Login already exists'); + my $user = $self->create( %args, password => $password ); + unless ( $user ) { + get_service('report')->add_error("Can't create new user"); return undef; } + if ( $args{login} ) { + my $ret = $user->logins->add( login => $args{login}, type => $args{login_type} ); + unless ( $ret ) { + get_service('report')->add_error("Can't create login"); + return undef; + } + } - my $user = $self->id( $user_id ); $user->make_event( 'registered' ); - return scalar $user->get; + $user->{login} = $args{login}; + return $user; } sub check_exists_logins { my $self = shift; my %args = ( login => undef, - exclude_user_id => undef, - get_smart_args( @_ ), - ); - - my %where_by_login = ( - -OR => [ - { login => $args{login} }, - { login2 => $args{login} }, - ], - ); - - # Исключаем текущего пользователя при проверке, чтобы можно было сохранять email, совпадающий с login - if ( $args{exclude_user_id} ) { - $where_by_login{user_id} = { '!=' => $args{exclude_user_id} }; - } - - my ( $user ) = $self->_list( - where => \%where_by_login, - limit => 1, + @_, ); - return $user if $user; - return undef unless is_email( $args{login} ); + return undef unless $args{login}; - my %where_by_email = ( - sprintf('%s->>"$.%s"', 'settings', 'email') => $args{login}, - ); - - if ( $args{exclude_user_id} ) { - $where_by_email{user_id} = { '!=' => $args{exclude_user_id} }; + if ( my $login = $self->logins->id( $args{login} ) ) { + return scalar $login->get; } - my ( $user_by_email ) = $self->_list( - where => \%where_by_email, - limit => 1, - ); - - return $user_by_email; + return undef; } sub services { @@ -1095,6 +933,7 @@ sub delete { Acts ActsData promo + User::Logins ); get_service( $_, user_id => $self->id )->delete_all for @objects; @@ -1166,7 +1005,6 @@ sub list_for_api { } return $self->SUPER::list_for_api( %args ); - } sub _list { @@ -1199,22 +1037,21 @@ sub profile { return %{ $item->{data} || {} }; } -sub emails { +sub logins { my $self = shift; + return $self->srv('User::Logins'); +} - my %profile = $self->profile; - my @emails = ( - $self->get_settings->{email}, - $self->get_login2, - $self->get_login, - $profile{email}, - ); +sub emails { + my $self = shift; - for ( @emails ) { - return $_ if is_email( $_ ); - } + my $emails = $self->logins->filter( + type => \('eq:email'), + settings => { 'email.verified' => \'isTrue' }, + )->items; - return undef; + my $logins = pluck( $emails, 'get_login' ) || []; + return wantarray ? @$logins : $logins; } sub email { @@ -1436,61 +1273,6 @@ sub partner { return $self->id( $partner_id ); } -sub is_password_auth_disabled { - my $self = shift; - return $self->get_settings->{password_auth_disabled} || 0; -} - -sub api_disable_password_auth { - my $self = shift; - - my $report = get_service('report'); - - my $passkey = get_service('User::Passkey'); - unless ($passkey->get_enabled($self)) { - $report->add_error('PASSKEY_REQUIRED'); - return undef; - } - - my $settings = $self->get_settings; - $settings->{password_auth_disabled} = 1; - - delete $settings->{otp}; - - $self->set(settings => $settings); - - return { - success => 1, - password_auth_disabled => 1, - }; -} - -sub api_enable_password_auth { - my $self = shift; - - my $settings = $self->get_settings; - delete $settings->{password_auth_disabled}; - $self->set(settings => $settings); - - return { - success => 1, - password_auth_disabled => 0, - }; -} - -sub api_password_auth_status { - my $self = shift; - - my $passkey = get_service('User::Passkey'); - my $otp = get_service('User::OTP'); - - return { - password_auth_disabled => $self->is_password_auth_disabled ? 1 : 0, - passkey_enabled => $passkey->get_enabled($self) ? 1 : 0, - otp_enabled => $otp->get_enabled($self) ? 1 : 0, - }; -} - sub api_search_for_admins { my $self = shift; my %args = ( @@ -1515,22 +1297,27 @@ sub api_search_for_admins { $args{limit} = 25 if $args{limit} == 0 && !$args{admin}; $args{limit} = 1000 if !$args{admin} && $args{limit} > 1000; + my $order = $self->query_for_order(%args); + + # Find user_ids that have a matching login in the logins table + my @matched_logins = $self->logins->_list( + where => { login => { '-like' => "%$text%" } }, + ); + my @logins_user_ids = map { $_->{user_id} } @matched_logins; + my @or_where = ( - { login => { '-like' => "%$text%" } }, - { login2 => { '-like' => "%$text%" } }, { full_name => { '-like' => "%$text%" } }, - { phone => { '-like' => "%$text%" } }, + { phone => { '-like' => "%$text%" } }, { sprintf('CAST(%s AS CHAR)', 'settings') => { '-like' => "%$text%" } }, ); + push @or_where, { user_id => { '-in' => \@logins_user_ids } } if @logins_user_ids; push @or_where, { user_id => int($text) } if $text =~ /^\d+$/; my %where = ( -OR => \@or_where, ); - my $order = $self->query_for_order(%args); - return $self->_list( limit => $args{limit}, offset => $args{offset}, diff --git a/app/lib/Core/User/Logins.pm b/app/lib/Core/User/Logins.pm new file mode 100644 index 00000000..48855d4e --- /dev/null +++ b/app/lib/Core/User/Logins.pm @@ -0,0 +1,217 @@ +package Core::User::Logins; + +use v5.14; + +use parent 'Core::Base'; +use Core::Base; +use Core::Utils qw( + is_email + encode_json + now + get_user_ip +); + +sub table { return 'accounts' } +sub table_allow_insert_key { return 1 } + +sub structure { + return { + login => { + type => 'text', + key => 1, + title => 'логин', + }, + type => { + type => 'text', + key2 => 1, + required => 1, + title => 'тип логина', + default => 'login', + }, + user_id => { + type => 'number', + required => 1, + auto_fill => 1, + hide_for_user => 1, + title => 'id пользователя', + }, + settings => { + type => 'json', + value => {}, + title => 'настройки логина', + hide_for_user => 1, + }, + }; +} + +sub id { + my $self = shift; + my $login = shift; + my $types = shift || ['login','email']; + + if ( $login ) { + my $obj = first_item $self->items( + admin => 1, + where => { + login => $login, + ref $types eq 'ARRAY' ? + ( type => { '-in' => $types } ) : + ( type => $types ), + }, + limit => 1, + ); + return $obj if $obj && $self->user->id( $obj->get_user_id ); # Check exists user_id + return undef; + } + return $self->SUPER::id(); +} + +sub get { + my $self = shift; + my %args = ( + @_, + ); + + $args{type} ||= $self->res->{type}; + return $self->SUPER::get( %args ); +} + +sub set_password { + my $self = shift; + my $password = shift || return; + + $self->set_settings({ + password => { + hash => $self->user->make_password( $password ), + changed => { + date => now(), + ip => get_user_ip(), + }, + }, + }); +} + +sub get_password { + my $self = shift; + return $self->settings->{password}->{hash}; +} + +sub list_by_user { + my $self = shift; + return $self->list(); +} + +sub add { + my $self = shift; + my %args = ( + login => undef, + type => 'login', + settings => {}, + @_, + ); + + $args{login} = lc $args{login}; + $args{type} = 'email' if is_email( $args{login} ); + + if ( $args{type} eq 'email' && !is_email( $args{login} ) ) { + report->status( 400 ); + report->add_error('Incorrect login format (is not email)' ); + return undef; + } + + $args{settings} //= {}; + $args{settings}->{created} = { + date => now(), + ip => get_user_ip, + }; + + my $ret = $self->SUPER::add( %args ); + if ( $ret ) { + if ( $args{primary} ) { + $self->user->set( + login => $args{login}, + ); + } + } + return $ret; +} + +sub api_set { + my $self = shift; + my %args = ( + @_, + ); + + my %ret = $self->SUPER::api_set( %args ); + return undef unless %ret; + + if ( $args{primary} ) { + $self->user->set( + login => $args{login}, + ); + } + + $self->set_password( $args{password} ) if $args{password}; + + return %ret; +} + +sub is_primary { + my $self = shift; + my $res = shift || $self->{res}; + + return 0 unless $res->{user_id}; + my $user = $self->user->id( $res->{user_id} ) || return 0; + + return lc($res->{login}) eq lc($user->get_login) ? 1 : 0; +} + +sub list_for_api { + my $self = shift; + my %args = ( + admin => 0, + filter => {}, + where => {}, + @_, + ); + + my @list = $self->SUPER::list_for_api( %args ); + $self->{_found_rows_cache} = $self->SUPER::found_rows(); + + for ( @list ) { + $_->{primary} = $self->is_primary( $_ ); + } + + return @list; +} + +sub found_rows { + my $self = shift; + return exists $self->{_found_rows_cache} + ? delete( $self->{_found_rows_cache} ) + : $self->SUPER::found_rows(); +} + +sub api_delete { + my $self = shift; + my %args = ( + login => undef, + type => undef, + @_, + ); + + return { error => 1 } unless $args{login} && $args{type}; + + $self->delete( + where => { + login => $args{login}, + type => $args{type}, + }, + ); + + $self->user->set( login => '' ); + + return { success => 1 }; +} + +1; diff --git a/app/lib/Core/User/Passwd.pm b/app/lib/Core/User/Passwd.pm index e205f276..2753da2e 100644 --- a/app/lib/Core/User/Passwd.pm +++ b/app/lib/Core/User/Passwd.pm @@ -6,6 +6,226 @@ use Core::Base; use Digest::SHA qw(sha1_hex sha256_hex hmac_sha512); use Core::Utils qw(random_bytes); +sub passwd { + my $self = shift; + my %args = ( + password => undef, + @_, + ); + + my $report = get_service('report'); + unless ( $args{password} ) { + $report->add_error('Password is empty'); + return undef; + } + + my $user = $self; + + if ( $args{admin} && $args{user_id} ) { + $user = get_service('user', _id => $args{user_id} ); + } + + my $password = $user->make_password( $args{password} ); + + get_service('sessions')->delete_user_sessions( user_id => $self->user_id ); + + $user->set( password => $password ); + return scalar $user->get; +} + +sub set_new_passwd { + my $self = shift; + my %args = ( + len => 10, + admin => 0, + @_, + ); + + return undef if $self->is_admin && !$args{admin}; + + my $new_password = passgen( $args{len} ); + $self->passwd( password => $new_password ); + + return $new_password; +} + +sub passwd_reset_request { + my $self = shift; + my %args = ( + email => undef, + login => undef, + @_, + ); + + my $email; + if ( is_email($args{email}) ) { + $email = $args{email}; + } + + my $existing_user = $self->check_exists_logins( login => $args{login} || $email ); + my $user_id = $existing_user ? $existing_user->{user_id} : undef; + + if ( !$user_id && $email ) { + my $profile = get_service("profile"); + my ( $profile_data ) = $profile->_list( + where => { + sprintf('%s->>"$.%s"', 'data', 'email') => $email, + }, + limit => 1, + ); + $user_id = $profile_data->{user_id} if $profile_data; + } + + return { msg => 'User not found' } unless $user_id; + + $self = $self->id( $user_id ); + if ( $self->is_blocked ) { + return { msg => 'User is blocked' }; + } + + unless ( cfg('cli')->{use_for_reset_password} ) { + $self->make_event( 'user_password_reset' ); + return { msg => 'Successful' }; + } + + my $token = passgen( 35 ); + my $expires = time() + 3600; + + $self->user->set_settings({ + reset_password_verify_token => $token, + reset_password_verify_expires => $expires, + }); + + my $project_name = cfg('company')->{name} || 'SHM'; + my $url = cfg('cli')->{url}; + my $link = $url ? "$url?token=$token" : undef; + my %mail_vars = ( + token => $token, + link => $link || '', + url => $url || '', + email => $args{email} || '', + project_name => $project_name, + ); + + my $subject = $self->render_mail_text( + text => cfg('mail')->{reset_password}->{subject} || "$project_name - Сброс пароля", + vars => \%mail_vars, + ); + + my $message = $self->render_mail_text( + text => cfg('mail')->{reset_password}->{message} || "Ваша ссылка для сброса пароля: {{ link }}\n\nСсылка действительна в течение часа.", + vars => \%mail_vars, + ); + + $self->send_mail_message( + to => $args{email}, + subject => $subject, + message => $message, + ); + + return { msg => 'Successful' }; +} + +sub is_password_auth_disabled { + my $self = shift; + return $self->get_settings->{password_auth_disabled} || 0; +} + +sub api_disable_password_auth { + my $self = shift; + + my $report = get_service('report'); + + my $passkey = get_service('User::Passkey'); + unless ($passkey->get_enabled($self)) { + $report->add_error('PASSKEY_REQUIRED'); + return undef; + } + + my $settings = $self->get_settings; + $settings->{password_auth_disabled} = 1; + + delete $settings->{otp}; + + $self->set(settings => $settings); + + return { + success => 1, + password_auth_disabled => 1, + }; +} + +sub api_enable_password_auth { + my $self = shift; + + my $settings = $self->get_settings; + delete $settings->{password_auth_disabled}; + $self->set(settings => $settings); + + return { + success => 1, + password_auth_disabled => 0, + }; +} + +sub api_password_auth_status { + my $self = shift; + + my $passkey = get_service('User::Passkey'); + my $otp = get_service('User::OTP'); + + return { + password_auth_disabled => $self->is_password_auth_disabled ? 1 : 0, + passkey_enabled => $passkey->get_enabled($self) ? 1 : 0, + otp_enabled => $otp->get_enabled($self) ? 1 : 0, + }; +} + +sub passwd_reset_verify { + my $self = shift; + my %args = ( + token => undef, + password => undef, + @_, + ); + + my $token = $args{token}; + + my ( $user ) = $self->_list( + where => { + sprintf('%s->>"$.%s"', 'settings', 'reset_password_verify_token') => $token, + }, + limit => 1, + ); + + unless ( $user ) { + return { msg => 'Invalid token' }; + } + + $self = $self->id( $user->{user_id} ); + + my $settings = $self->get_settings; + unless ( $settings->{reset_password_verify_token} && $settings->{reset_password_verify_token} eq $token ) { + return { msg => 'Invalid token' }; + } + + if ( $settings->{reset_password_verify_expires} && $settings->{reset_password_verify_expires} < time() ) { + return { msg => 'Token expired' }; + } + + unless ( $args{password} ) { + return { msg => 'Successful' }; + } + + delete $settings->{reset_password_verify_token}; + delete $settings->{reset_password_verify_expires}; + $self->set( settings => $settings ); + + $self->passwd( password => $args{password} ); + + return { msg => 'Password reset successful' }; +} + # PBKDF2-HMAC-SHA512 (RFC 2898). # Args: ($password, $salt_bytes, $iterations, $dklen) # Returns: $dklen raw bytes of derived key. diff --git a/app/lib/Core/Utils.pm b/app/lib/Core/Utils.pm index 489d4e96..cac1f3bc 100644 --- a/app/lib/Core/Utils.pm +++ b/app/lib/Core/Utils.pm @@ -78,6 +78,7 @@ our @EXPORT_OK = qw( notall none uniq + pluck uniq_by_key format_time_diff diff --git a/app/public_html/shm/v1.cgi b/app/public_html/shm/v1.cgi index b2b82e36..8cdf1a64 100755 --- a/app/public_html/shm/v1.cgi +++ b/app/public_html/shm/v1.cgi @@ -294,13 +294,29 @@ state $routes //= { swagger => { summary => 'Сменить пароль пользователя по токену сброса' }, }, }, +'/user/accounts' => { + swagger => { tags => 'Пользователи' }, + GET => { + controller => 'User::Logins', + swagger => { summary => 'Список аккаунтов пользователя' }, + }, + PUT => { + controller => 'User::Logins', + }, + POST => { + controller => 'User::Logins', + }, + DELETE => { + controller => 'User::Logins', + }, +}, '/user/email' => { swagger => { tags => 'Пользователи' }, PUT => { controller => 'User', method => 'set_email', required => ['email'], - swagger => { summary => 'Установить email пользователя' }, + swagger => { summary => 'Привязать email пользователя' }, }, GET => { controller => 'User', @@ -310,24 +326,17 @@ state $routes //= { POST => { controller => 'User', method => 'verify_email', - optional => ['email', 'code'], + required => ['email'], + optional => ['code'], swagger => { summary => 'Верифицировать email пользователя' }, }, DELETE => { controller => 'User', method => 'delete_email', + required => ['email'], swagger => { summary => 'Удалить email пользователя' }, }, }, -'/user/email/verify' => { - swagger => { tags => 'Пользователи' }, - POST => { - controller => 'User', - method => 'verify_email', - optional => ['email', 'code'], - swagger => { summary => 'Верификация email пользователя' }, - }, -}, '/user/service' => { swagger => { tags => 'Услуги пользователей' }, GET => { @@ -701,7 +710,7 @@ state $routes //= { }, PUT => { controller => 'User', - required => ['login','password'], + required => ['full_name'], swagger => { summary => 'Создать клиента' }, }, POST => { @@ -723,6 +732,29 @@ state $routes //= { swagger => { summary => 'Поиск клиентов' }, }, }, +'/admin/user/accounts' => { + swagger => { tags => 'Пользователи' }, + GET => { + controller => 'User::Logins', + swagger => { summary => 'Список аккаунтов' }, + }, + PUT => { + controller => 'User::Logins', + required => ['user_id','login','type'], + swagger => { summary => 'Добавить аккаунт' }, + }, + POST => { + controller => 'User::Logins', + required => ['user_id','login','type'], + swagger => { summary => 'Изменить аккаунт' }, + }, + DELETE => { + controller => 'User::Logins', + method => 'api_delete', + required => ['user_id','login','type'], + swagger => { summary => 'Удалить аккаунт' }, + }, +}, '/admin/user/passwd' => { swagger => { tags => 'Пользователи' }, POST => { @@ -1928,7 +1960,14 @@ sub get_service_id { print_json( { status => 400, error => sprintf("`%s` not present", $service->get_table_key ) } ); exit 0; } - return $service_id; + + my @ids = ( $service_id ); + + if ( my $key2 = $service->get_table_key2 ) { + push @ids, $args{ $key2 }; + } + + return @ids; } # exit 0; diff --git a/app/sql/shm/shm_dev_cleanup.sql b/app/sql/shm/shm_dev_cleanup.sql index b9696f62..7f71c9c9 100644 --- a/app/sql/shm/shm_dev_cleanup.sql +++ b/app/sql/shm/shm_dev_cleanup.sql @@ -21,6 +21,7 @@ DROP TABLE IF EXISTS `withdraw_history`; DROP TABLE IF EXISTS `zones`; DROP TABLE IF EXISTS `console`; DROP TABLE IF EXISTS `profiles`; +DROP TABLE IF EXISTS `accounts`; SET FOREIGN_KEY_CHECKS = 1; COMMIT; diff --git a/app/sql/shm/shm_dev_test_data.sql b/app/sql/shm/shm_dev_test_data.sql index f7c5a58b..dbd329d6 100644 --- a/app/sql/shm/shm_dev_test_data.sql +++ b/app/sql/shm/shm_dev_test_data.sql @@ -233,11 +233,20 @@ INSERT IGNORE INTO `user_services` VALUES INSERT IGNORE INTO `users` VALUES -(1,0,'admin',NULL,'0df78fa86a30eca0a918fdd21a94e238133ce7ab',0,NOW(),NULL,0,0,0.00,NULL,NULL,0,1,0,'Admin',0,0.00,NULL,NULL,NULL,NULL), -(108,0,'',NULL,'',0,'2014-09-30 14:17:37',NULL,0,0,0.00,NULL,NULL,0,0,0,'Платеж',0,0.00,NULL,NULL,NULL,NULL), -(40092,0,'danuk',NULL,'d8923baf143645690cc89db46e4611fb1066e1f0',0,'2014-09-30 14:17:37',NULL,0,-21.56,100000.00,NULL,NULL,0,0,0,'Фирсов Даниил Андреевич',0,0.00,NULL,NULL,NULL,'{\"telegram\": {\"chat_id\": 298002190}}'), -(40093,0,'blocked',NULL,'',0,'2025-08-19 12:47:37',NULL,0,0,0,NULL,NULL,1,0,0,'Заблокированный',0,0.00,NULL,NULL,NULL,NULL), -(40094,0,'ya',NULL,'1ad777afc152c9eaa13abb53283f8d47e8d453bb',0,'2014-10-02 14:45:43',NULL,0,30,100.00,NULL,NULL,0,0,NULL,'Пронин Дмитрий Борисович',0,0.00,NULL,NULL,NULL,NULL); +(1,0,'admin','0df78fa86a30eca0a918fdd21a94e238133ce7ab',0,NOW(),NULL,0,0,0.00,NULL,NULL,0,1,0,'Admin',0,0.00,NULL,NULL,NULL,NULL), +(108,0,'','',0,'2014-09-30 14:17:37',NULL,0,0,0.00,NULL,NULL,0,0,0,'Платеж',0,0.00,NULL,NULL,NULL,NULL), +(40092,0,'danuk','d8923baf143645690cc89db46e4611fb1066e1f0',0,'2014-09-30 14:17:37',NULL,0,-21.56,100000.00,NULL,NULL,0,0,0,'Фирсов Даниил Андреевич',0,0.00,NULL,NULL,NULL,'{\"telegram\": {\"chat_id\": 298002190}}'), +(40093,0,'blocked','',0,'2025-08-19 12:47:37',NULL,0,0,0,NULL,NULL,1,0,0,'Заблокированный',0,0.00,NULL,NULL,NULL,NULL), +(40094,0,'ya','1ad777afc152c9eaa13abb53283f8d47e8d453bb',0,'2014-10-02 14:45:43',NULL,0,30,100.00,NULL,NULL,0,0,NULL,'Пронин Дмитрий Борисович',0,0.00,NULL,NULL,NULL,NULL); + +INSERT IGNORE INTO `accounts` VALUES +('admin','login',1,NULL), +('danuk','login',40092,NULL), +('danuk@domain.ru','email',40092,'{"email":{"verified":1}}'), +('298002190','telegram',40092,'{"telegram":{"chat_id":298002190}}'), +('blocked','login',40093,NULL), +('ya','login',40094,NULL), +('ya@domain.ru','email',40094,'{"email":{"verified":0}}'); INSERT IGNORE INTO `withdraw_history` VALUES (3519,40092,'2016-07-29 12:39:08','2016-07-29 12:39:47','2017-07-29 12:39:46',590.00,0,11.80,12,578.20,11,1,2949), diff --git a/app/sql/shm/shm_structure.sql b/app/sql/shm/shm_structure.sql index 4f2d4c25..5b69aaf2 100644 --- a/app/sql/shm/shm_structure.sql +++ b/app/sql/shm/shm_structure.sql @@ -212,7 +212,6 @@ CREATE TABLE IF NOT EXISTS `users` ( `user_id` int(11) NOT NULL AUTO_INCREMENT, `partner_id` int(11) DEFAULT NULL, `login` varchar(128) NOT NULL, - `login2` varchar(128) DEFAULT NULL, `password` varchar(128) DEFAULT NULL, `type` tinyint(4) DEFAULT NULL, `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -232,11 +231,29 @@ CREATE TABLE IF NOT EXISTS `users` ( `verified` int(11) DEFAULT NULL, `create_act` tinyint(4) DEFAULT NULL, `settings` json DEFAULT NULL, - PRIMARY KEY (`user_id`), - UNIQUE KEY `users_uniq` (`login`), - UNIQUE KEY `users_uniq_login2` (`login2`) + PRIMARY KEY (`user_id`) ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `accounts` ( + `login` varchar(128) NOT NULL, + `type` char(16) NOT NULL DEFAULT 'login', + `user_id` int(11) NOT NULL, + `settings` json DEFAULT NULL, + PRIMARY KEY (`login`, `type`), + KEY `idx_accounts_user_id` (`user_id`), + CONSTRAINT `fk_accounts_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `accounts` ( + `login` varchar(128) NOT NULL, + `type` char(16) NOT NULL DEFAULT 'login', + `user_id` int(11) NOT NULL, + `settings` json DEFAULT NULL, + PRIMARY KEY (`login`, `type`), + KEY `idx_accounts_user_id` (`user_id`), + CONSTRAINT `fk_accounts_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS `withdraw_history` ( `withdraw_id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, diff --git a/app/t/integration/billing/full_test.t b/app/t/integration/billing/full_test.t index 168087da..f9858bcd 100644 --- a/app/t/integration/billing/full_test.t +++ b/app/t/integration/billing/full_test.t @@ -95,7 +95,12 @@ subtest 'Check create service' => sub { $spool->process_all(); my $mysql_server_id = $us->child_by_category('mysql')->settings->{server_id}; - is( $mysql_server_id == 1 || $mysql_server_id == 2, 1 ); + my $mysql_group_id = 1; + my @group_servers = get_service('server')->servers_by_group_id( gid => $mysql_group_id ); + my %group_server_ids = map { $_->{server_id} => 1 } @group_servers; + + ok( defined $mysql_server_id, 'Check mysql server_id is defined after create' ); + ok( $group_server_ids{$mysql_server_id}, 'Check mysql server_id belongs to mysql server group' ); is( $us->get_status, STATUS_ACTIVE, 'Check status of service after the creation childs' ); }; diff --git a/app/t/integration/spool/make_task.t b/app/t/integration/spool/make_task.t index 4193059b..43380d3e 100644 --- a/app/t/integration/spool/make_task.t +++ b/app/t/integration/spool/make_task.t @@ -40,9 +40,11 @@ subtest 'Get server_id by event settings' => sub { is ( $task{id}, $task_id ); my ( $info ) = $spool->process_one( \%task ); + my $server_id = $info->get_settings->{server_id}; + my %server = get_service('server', _id => $server_id )->get; is( $info->get_status, 'MOCK' ); - is( $info->get_settings->{server_id} =~ /^1|2$/, 1 ); + is( $server{server_gid}, 1, 'server_id belongs to group 1' ); }; subtest 'Get server_id by server_gid' => sub { @@ -62,9 +64,11 @@ subtest 'Get server_id by server_gid' => sub { is ( $task{id}, $task_id ); my ( $info ) = $spool->process_one( \%task ); + my $server_id = $info->get_settings->{server_id}; + my %server = get_service('server', _id => $server_id )->get; is( $info->get_status, 'MOCK' ); - is( $info->get_settings->{server_id}, 25 ); + is( $server{server_gid}, 5, 'server_id belongs to group 5' ); }; done_testing(); diff --git a/app/t/integration/template/parser.t b/app/t/integration/template/parser.t index 6b3b6c2e..27689187 100644 --- a/app/t/integration/template/parser.t +++ b/app/t/integration/template/parser.t @@ -55,12 +55,12 @@ subtest 'Check EVAL_PERL' => sub { data => ' {{ PERL }} use v5.14; - say "My login is: {{ user.login }}"; + say "My user_id is: {{ user.id }}"; {{ END -}} ', ); - is( $perl, 'My login is: danuk' ); + is( $perl, "My user_id is: 40092" ); }; subtest 'Check template trim' => sub { diff --git a/app/t/integration/transport/telegram_oidc_flow.t b/app/t/integration/transport/telegram_oidc_flow.t index 716079d2..82c7de04 100644 --- a/app/t/integration/transport/telegram_oidc_flow.t +++ b/app/t/integration/transport/telegram_oidc_flow.t @@ -167,41 +167,57 @@ subtest 'Callback redirects with tg_status=already_exists' => sub { is( $ret->{error}, 'Telegram account already exists', 'bind returns already_exists error' ); }; -subtest 'Bind-to-profile stores login2 as @telegram_user_id' => sub { +subtest 'Bind-to-profile stores telegram settings' => sub { + package Local::FakeLogins; + + sub new { + my $class = shift; + return bless { + add_calls => [], + }, $class; + } + + sub id { + return undef; + } + + sub add { + my ( $self, %args ) = @_; + push @{ $self->{add_calls} }, { %args }; + return 1; + } + package Local::BindUser; sub new { my $class = shift; return bless { - login2 => undef, settings => { telegram => {} }, - set_calls => [], set_json_calls => [], + logins => Local::FakeLogins->new, }, $class; } sub id { my ( $self, $uid ) = @_; - return $uid ? 1 : 0; + return $uid ? $self : undef; + } + + sub settings { + my $self = shift; + return $self->{settings}; } - sub get_login2 { + sub logins { my $self = shift; - return $self->{login2}; + return $self->{logins}; } sub set { my ( $self, %args ) = @_; - push @{ $self->{set_calls} }, { %args }; - $self->{login2} = $args{login2} if exists $args{login2}; return 1; } - sub settings { - my $self = shift; - return $self->{settings}; - } - sub set_json { my ( $self, $key, $value ) = @_; push @{ $self->{set_json_calls} }, { @@ -217,6 +233,7 @@ subtest 'Bind-to-profile stores login2 as @telegram_user_id' => sub { local *Core::Transport::Telegram::user = sub { return $fake_user; }; local *Core::Utils::switch_user = sub { return 1; }; + local *Core::Transport::Telegram::find_user_by_tg = sub { return undef; }; local *Core::Transport::Telegram::verify_telegram_oidc_id_token = sub { return { id => 123456, @@ -235,8 +252,10 @@ subtest 'Bind-to-profile stores login2 as @telegram_user_id' => sub { ); is( $ret->{msg}, 'Successfully bound to Telegram', 'bind returns success message' ); - is( $fake_user->{login2}, '@123456', 'login2 saved as @telegram_user_id' ); - isnt( $fake_user->{login2}, 'telegram_login', 'login2 is not Telegram username' ); + is( scalar @{ $fake_user->{logins}->{add_calls} }, 1, 'telegram alias login created once' ); + is( $fake_user->{logins}->{add_calls}[0]->{login}, '123456', 'telegram alias login value is user_id' ); + is( scalar @{ $fake_user->{set_json_calls} }, 1, 'telegram settings stored once' ); + is( $fake_user->{set_json_calls}[0]->{value}->{telegram}->{user_id}, 123456, 'telegram settings include user_id' ); }; subtest 'Bind-only-if-new rejects binding existing Telegram account' => sub { @@ -245,35 +264,32 @@ subtest 'Bind-only-if-new rejects binding existing Telegram account' => sub { sub new { my $class = shift; return bless { - login2 => undef, settings => { telegram => {} }, - set_calls => [], set_json_calls => [], + logins => Local::FakeLogins->new, }, $class; } sub id { my ( $self, $uid ) = @_; - return $uid ? 1 : 0; + return $uid ? $self : undef; } - sub get_login2 { + sub settings { + my $self = shift; + return $self->{settings}; + } + + sub logins { my $self = shift; - return $self->{login2}; + return $self->{logins}; } sub set { my ( $self, %args ) = @_; - push @{ $self->{set_calls} }, { %args }; - $self->{login2} = $args{login2} if exists $args{login2}; return 1; } - sub settings { - my $self = shift; - return $self->{settings}; - } - sub set_json { my ( $self, $key, $value ) = @_; push @{ $self->{set_json_calls} }, { @@ -310,25 +326,39 @@ subtest 'Bind-only-if-new rejects binding existing Telegram account' => sub { id_token => 'stub-token', ); - is( $ret->{error}, 'Telegram account already exists', 'bind returns already_exists error' ); - is( scalar @{ $fake_user->{set_calls} }, 0, 'does not update user login2' ); - is( scalar @{ $fake_user->{set_json_calls} }, 0, 'does not bind telegram settings' ); + is( $ret->{error}, 'Telegram account already exists', 'bind_only_if_new rejects existing telegram binding' ); + is( scalar @{ $fake_user->{logins}->{add_calls} }, 0, 'telegram alias login is not created on reject' ); + is( scalar @{ $fake_user->{set_json_calls} }, 0, 'telegram settings are not changed on reject' ); }; -subtest 'Unbind clears login2 when it contains telegram username' => sub { +subtest 'Unbind clears telegram settings' => sub { + package Local::DeleteTgLogins; + + sub new { + my $class = shift; + return bless { + delete_calls => [], + }, $class; + } + + sub delete { + my ( $self, %args ) = @_; + push @{ $self->{delete_calls} }, { %args }; + return 1; + } + package Local::DeleteTgUser; sub new { my $class = shift; return bless { - login2 => 'telegram_login', settings => { telegram => { username => 'telegram_login', user_id => 123456, }, }, - set_calls => [], + logins => Local::DeleteTgLogins->new, set_settings_calls => [], }, $class; } @@ -338,15 +368,13 @@ subtest 'Unbind clears login2 when it contains telegram username' => sub { return $self->{settings}; } - sub get_login2 { + sub logins { my $self = shift; - return $self->{login2}; + return $self->{logins}; } sub set { my ( $self, %args ) = @_; - push @{ $self->{set_calls} }, { %args }; - $self->{login2} = $args{login2} if exists $args{login2}; return 1; } @@ -365,8 +393,8 @@ subtest 'Unbind clears login2 when it contains telegram username' => sub { my $ret = $tg->api_delete_user_tg_settings(); is( $ret->{msg}, 'Telegram settings deleted successfully', 'delete method returns success' ); - ok( scalar @{ $fake_user->{set_calls} } >= 1, 'user->set was called to clear login2' ); - is( $fake_user->{set_calls}[0]->{login2}, undef, 'login2 cleared when it matched telegram username' ); + is( scalar @{ $fake_user->{logins}->{delete_calls} }, 1, 'telegram alias login is deleted on unbind' ); + is( $fake_user->{logins}->{delete_calls}[0]->{where}->{login}, '123456', 'deleted telegram alias login matches user_id' ); is_deeply( $fake_user->{settings}{telegram}, {}, 'telegram settings removed' ); }; diff --git a/app/t/integration/user/functions.t b/app/t/integration/user/functions.t index 94081b4e..620c1f1b 100644 --- a/app/t/integration/user/functions.t +++ b/app/t/integration/user/functions.t @@ -89,19 +89,16 @@ subtest 'Make payment with partner (personal income percent)' => sub { my %profile = $user->profile; is $profile{email}, 'email@domain.ru', 'Check user profile'; -is $user->email, 'email@domain.ru', 'Check user email'; +is $user->email, 'danuk@domain.ru', 'Check user email'; subtest 'Check user email by login' => sub { my $email = 'test@domain.ru'; - my $new_user_id = $user->reg( + my $new_user = $user->reg( login => $email, password => 'testpassword', - )->{user_id}; - - my $new_user = $user->id( $new_user_id ); + ); is( $new_user->login, $email ); - is( $new_user->email, $email ); }; subtest 'Check refferals count' => sub { diff --git a/app/t/unit/services/user_emails.t b/app/t/unit/services/user_emails.t new file mode 100644 index 00000000..5234c087 --- /dev/null +++ b/app/t/unit/services/user_emails.t @@ -0,0 +1,210 @@ +use v5.14; +use utf8; + +use Test::More; +use Test::Deep; +use Core::User; + +{ + package Test::MockLoginObj; + sub new { bless { login => $_[1], verified => ($_[2] // 1) }, $_[0] } + sub get_login { $_[0]->{login} } +} + +subtest 'emails - list context returns email list' => sub { + my $user = bless { res => {} }, 'Core::User'; + + no warnings 'redefine'; + + local *Core::User::logins = sub { + return bless {}, 'Test::MockLogins'; + }; + + local *Test::MockLogins::filter = sub { + my ( $self, %args ) = @_; + + my $type = $args{type}; + if ( ref $type eq 'SCALAR' ) { + is( $$type, 'eq:email', 'filters by email type using eq:email marker' ); + } else { + is( $type, 'email', 'filters by email type' ); + } + ok( ref $args{settings} eq 'HASH', 'settings filter is hashref' ); + is( ref $args{settings}->{'email.verified'}, 'SCALAR', 'email.verified is scalar ref marker' ); + is( ${ $args{settings}->{'email.verified'} }, 'isTrue', 'uses isTrue marker for verified emails' ); + + return $self; + }; + + local *Test::MockLogins::items = sub { + return [ + Test::MockLoginObj->new('a@example.com'), + Test::MockLoginObj->new('b@example.com'), + ]; + }; + + my @emails = $user->emails; + + cmp_deeply( + \@emails, + [ 'a@example.com', 'b@example.com' ], + 'returns list of verified emails in list context', + ); +}; + +subtest 'emails - scalar context returns arrayref' => sub { + my $user = bless { res => {} }, 'Core::User'; + + no warnings 'redefine'; + + local *Core::User::logins = sub { + return bless {}, 'Test::MockLogins2'; + }; + + local *Test::MockLogins2::filter = sub { + my ( $self, %args ) = @_; + return $self; + }; + + local *Test::MockLogins2::items = sub { + return [ + Test::MockLoginObj->new('only@example.com'), + ]; + }; + + my $emails = $user->emails; + + is( ref $emails, 'ARRAY', 'returns arrayref in scalar context' ); + cmp_deeply( + $emails, + [ 'only@example.com' ], + 'arrayref contains expected email', + ); +}; + +subtest 'email - returns first verified email' => sub { + my $user = bless { res => {} }, 'Core::User'; + + no warnings 'redefine'; + + local *Core::User::logins = sub { + return bless {}, 'Test::MockLogins3'; + }; + + local *Test::MockLogins3::filter = sub { + my ( $self, %args ) = @_; + return $self; + }; + + local *Test::MockLogins3::items = sub { + return [ + Test::MockLoginObj->new('first@example.com'), + Test::MockLoginObj->new('second@example.com'), + ]; + }; + + is( $user->email, 'first@example.com', 'email() returns first item from emails()' ); +}; + +subtest 'emails - works with blessed login objects' => sub { + my $user = bless { res => {} }, 'Core::User'; + + no warnings 'redefine'; + + local *Core::User::logins = sub { + return bless {}, 'Test::MockLogins4'; + }; + + local *Test::MockLogins4::filter = sub { + my ( $self, %args ) = @_; + return $self; + }; + + local *Test::MockLogins4::items = sub { + return [ + Test::MockLoginObj->new('obj@example.com'), + ]; + }; + + my @emails = $user->emails; + cmp_deeply( \@emails, [ 'obj@example.com' ], 'extracts login from blessed object via get_login' ); +}; + +subtest 'emails - unverified email is excluded' => sub { + my $user = bless { res => {} }, 'Core::User'; + + no warnings 'redefine'; + + local *Core::User::logins = sub { + return bless {}, 'Test::MockLogins5'; + }; + + local *Test::MockLogins5::filter = sub { + my ( $self, %args ) = @_; + + my @all = ( + Test::MockLoginObj->new('verified@example.com', 1), + Test::MockLoginObj->new('unverified@example.com', 0), + ); + + my $marker = $args{settings}->{'email.verified'}; + if ( ref $marker eq 'SCALAR' && ${$marker} eq 'isTrue' ) { + $self->{items} = [ grep { $_->{verified} } @all ]; + } else { + $self->{items} = \@all; + } + + return $self; + }; + + local *Test::MockLogins5::items = sub { + my ($self) = @_; + return $self->{items} || []; + }; + + my @emails = $user->emails; + cmp_deeply( \@emails, [ 'verified@example.com' ], 'unverified email does not get into result' ); +}; + +subtest 'emails - verified accepts both 1 and true' => sub { + my $user = bless { res => {} }, 'Core::User'; + + no warnings 'redefine'; + + local *Core::User::logins = sub { + return bless {}, 'Test::MockLogins6'; + }; + + local *Test::MockLogins6::filter = sub { + my ( $self, %args ) = @_; + + my @all = ( + Test::MockLoginObj->new('verified-int@example.com', 1), + Test::MockLoginObj->new('verified-true@example.com', 'true'), + Test::MockLoginObj->new('unverified@example.com', 0), + ); + + my $marker = $args{settings}->{'email.verified'}; + if ( ref $marker eq 'SCALAR' && ${$marker} eq 'isTrue' ) { + $self->{items} = [ grep { defined $_->{verified} && ( $_->{verified} eq '1' || lc($_->{verified}) eq 'true' ) } @all ]; + } else { + $self->{items} = \@all; + } + + return $self; + }; + + local *Test::MockLogins6::items = sub { + my ($self) = @_; + return $self->{items} || []; + }; + + my @emails = $user->emails; + cmp_deeply( + \@emails, + [ 'verified-int@example.com', 'verified-true@example.com' ], + 'both 1 and true are treated as verified', + ); +}; + +done_testing(); diff --git a/app/t/unit/sql/clean_query_args.t b/app/t/unit/sql/clean_query_args.t index 0a605bd7..8f13fdee 100644 --- a/app/t/unit/sql/clean_query_args.t +++ b/app/t/unit/sql/clean_query_args.t @@ -18,6 +18,7 @@ sub structure { }; } sub get_table_key { return 'id' } +sub get_table_key2 { return 'id2' } sub table_allow_insert_key { return 0 } package main; diff --git a/app/version.json b/app/version.json index f9ea0785..1510cad6 100644 --- a/app/version.json +++ b/app/version.json @@ -1,5 +1,5 @@ { - "version": "0.0.1", - "commitSha": "", + "version": "3.0.0", + "commitSha": "beta", "releaseUrl": "" } From bd66807c22c623d7c7f3cf15fcef7c6f72673e52 Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Mon, 13 Apr 2026 12:14:24 +0300 Subject: [PATCH 05/13] dnk: auto-reload SHM in dev mode --- app/bin/shm-server.pl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/bin/shm-server.pl b/app/bin/shm-server.pl index 7670f521..d42e6924 100755 --- a/app/bin/shm-server.pl +++ b/app/bin/shm-server.pl @@ -116,7 +116,7 @@ sub log_msg { # Save clean environment before any request processing my %CLEAN_ENV = %ENV; -my $max_requests = 1000; +my $max_requests = $ENV{DEV} ? 1 : 1000; my $request_count = 0; while ($request_count < $max_requests && $request->Accept() >= 0) { From 450e445c23e5c2ffaedefc84efa063425dd3e2ab Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Thu, 2 Apr 2026 17:35:53 +0300 Subject: [PATCH 06/13] =?UTF-8?q?dnk:=20SEC=20-=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B0=D1=80=D0=B3=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20API=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/Core/Service.pm | 7 +- app/lib/Core/Swagger.pm | 124 +++-- app/lib/Core/Test.pm | 8 +- app/lib/Core/User.pm | 14 +- app/lib/Core/User/Passwd.pm | 1 + app/public_html/shm/v1.cgi | 888 +++++++++++++++++++++++++++--------- 6 files changed, 806 insertions(+), 236 deletions(-) diff --git a/app/lib/Core/Service.pm b/app/lib/Core/Service.pm index 3fc193f4..e2127103 100644 --- a/app/lib/Core/Service.pm +++ b/app/lib/Core/Service.pm @@ -236,12 +236,17 @@ sub list_for_api { @_, ); + if ( $args{service_id} && !$args{admin} ) { + my $service = $self->id( $args{service_id } ) || return undef; + return undef unless $args{admin} || $service->price_list_check_allow_to_order; + } + unless ( $args{filter} && $args{filter}->{deleted} ) { $args{where} = { deleted => 0 }; } if ( $args{admin} && $args{parent} ) { - if ( my $service = get_service('service', _id => $args{parent} ) ) { + if ( my $service = $self->id( $args{parent} ) ) { $args{where} = { service_id => { -in => [ map $_->{service_id}, @{ $service->subservices } ] } }; } } diff --git a/app/lib/Core/Swagger.pm b/app/lib/Core/Swagger.pm index 82674e3b..31c8acd2 100644 --- a/app/lib/Core/Swagger.pm +++ b/app/lib/Core/Swagger.pm @@ -7,6 +7,20 @@ use Core::Utils qw( uniq_by_key ); +# Common query parameters automatically injected into Swagger docs for GET +# list endpoints (no explicit method set => defaults to list_for_api), or for +# any route that sets common_params => 1. Route-level params take precedence. +my %COMMON_LIST_PARAMS = ( + start => { type => 'string', pattern => '^\d{4}-\d{2}-\d{2}' }, + stop => { type => 'string', pattern => '^\d{4}-\d{2}-\d{2}' }, + field => { type => 'string', pattern => '^\w+' }, + sort_field => {type => 'string', pattern => '^\w+' }, + sort_direction => { type => 'enum', enum => ['desc','asc'] }, + limit => { type => 'integer', min => 1, max => 10000, default => 25 }, + offset => { type => 'integer', min => 0, default => 0 }, + filter => { type => 'string', default => '{}' }, +); + sub gen_swagger_json { my $self = shift; my %args = ( @@ -105,44 +119,55 @@ sub gen_swagger_json { push @request_params, get_request_params( $splat_id, $structure, in => 'path', required => 1 ); } - my @required = @{ $info->{$method}->{required} || [] }; + my %route_params = %{ $info->{$method}->{params} || {} }; # Request section if ( $method eq 'GET' || $method eq 'DELETE' ) { - for ( @required ) { - push @request_params, get_request_params( $_, $structure, required => 1 ); - }; - - for ( @{ $info->{$method}->{optional} || [] } ) { - push @request_params, get_request_params( $_, $structure ); - } - - if ( $args{admin_mode} && !$info->{$method}->{method} ) { - for ('user_id') { - next unless exists $structure->{$_}; - push @request_params, get_request_params( $_, $structure ); + # Inject common list params when: no explicit method (defaults to + # list_for_api) OR route has common_params => 1 flag. + my $inject_common = $method eq 'GET' && ( + !$info->{$method}->{method} || + $info->{$method}->{common_params} + ); + + my %effective_params = %route_params; + if ( $inject_common ) { + for my $p ( keys %COMMON_LIST_PARAMS ) { + $effective_params{$p} //= $COMMON_LIST_PARAMS{$p}; + } + # user_id is a common filter param for admin list endpoints + if ( $args{admin_mode} ) { + $effective_params{user_id} //= { type => 'integer', min => 1 }; } } - push @request_params, get_pagination_params() if $method ne 'DELETE'; - - } elsif ( exists $info->{$method}->{required} && # required может быть и пустым - $info->{$method}->{method} && - !$info->{$method}->{only_text_plain} - ) { - # add required fields to requestBody - my $schema = {}; - for ( @required ) { - $schema->{ $_ } = get_swagger_properties( $_, $structure ); - delete $schema->{ $_ }->{readOnly}; # always show required field + for my $param_name ( sort keys %effective_params ) { + my $required = $effective_params{$param_name}->{required} ? 1 : 0; + my $schema = get_swagger_properties_by_route_params( $param_name, $effective_params{$param_name}, $structure ); + push @request_params, { + name => $param_name, + in => 'query', + required => $required, + schema => $schema, + }; + }; + + } elsif ( exists $info->{$method}->{params} ) { + # add fields from route params to requestBody + my %schema; + my @required; + for my $param_name ( sort keys %route_params ) { + $schema{$param_name} = get_swagger_properties_by_route_params( $param_name, $route_params{$param_name}, $structure ); + push @required, $param_name if $route_params{$param_name}->{required}; } + $json{paths}{$route}{ lc $method}{requestBody} = { content => { 'application/json' => { schema => { type => 'object', - required => [$_], - properties => $schema, + @required ? ( required => \@required ) : (), + properties => \%schema, } }, } @@ -258,6 +283,14 @@ sub get_swagger_field_type { if ( $field eq 'number' ) { $type = 'number'; + } elsif ( $field eq 'integer' ) { + $type = 'integer'; + } elsif ( $field eq 'boolean' ) { + $type = 'boolean'; + } elsif ( $field eq 'string' ) { + $type = 'string'; + } elsif ( $field eq 'email' ) { + $type = 'string'; } elsif ( $field eq 'json' ) { $type = 'object'; } elsif ( $field eq 'text' ) { @@ -267,6 +300,45 @@ sub get_swagger_field_type { return $type; } +sub get_swagger_properties_by_route_params { + my $field = shift; + my $rule = shift || {}; + my $structure = shift || {}; + + my %schema = ( + $structure->{$field} ? %{ get_swagger_properties( $field, $structure ) } : (), + ); + + # request schemas should not include read/write access flags from response model + delete $schema{readOnly}; + delete $schema{writeOnly}; + # remove model-inherited example to avoid leaking internal defaults into request docs + delete $schema{example}; + + if ( exists $rule->{type} ) { + $schema{type} = get_swagger_field_type( $rule->{type} ) || $rule->{type} || 'string'; + } + $schema{type} ||= 'string'; + $schema{format} = 'email' if ( $rule->{type} || '' ) eq 'email'; + + $schema{minimum} = $rule->{min} if exists $rule->{min}; + $schema{maximum} = $rule->{max} if exists $rule->{max}; + $schema{minLength} = $rule->{min_length} if exists $rule->{min_length}; + $schema{maxLength} = $rule->{max_length} if exists $rule->{max_length}; + $schema{pattern} = $rule->{pattern} if exists $rule->{pattern}; + $schema{enum} = $rule->{enum} if exists $rule->{enum}; + $schema{description} = $rule->{description} if exists $rule->{description}; + $schema{default} = $rule->{default} if exists $rule->{default}; + + # Prevent Swagger UI from auto-generating misleading "string" / "stringstri" + # placeholder values for string fields that have no explicit example or enum. + unless ( exists $schema{example} || exists $schema{enum} ) { + $schema{example} = '' if ( $schema{type} // '' ) eq 'string'; + } + + return \%schema; +} + sub gen_swagger_def { my %json; my %responses = ( diff --git a/app/lib/Core/Test.pm b/app/lib/Core/Test.pm index 724eddcc..44e94143 100644 --- a/app/lib/Core/Test.pm +++ b/app/lib/Core/Test.pm @@ -4,6 +4,10 @@ use v5.14; use parent 'Core::Base'; use Core::System::ServiceManager qw( %PROTECTED_SERVICES get_service logger ); +use Core::Utils qw( + parse_args +); + sub healthcheck { my $self = shift; my $result = 'FAIL'; @@ -38,9 +42,11 @@ sub list_for_api { sub http_echo { my $self = shift; my %args = @_; + my %in = parse_args(); return { - payload => \%args, + args => \%args, + in => \%in, method => $ENV{REQUEST_METHOD}, }; } diff --git a/app/lib/Core/User.pm b/app/lib/Core/User.pm index 2660d962..3bfbdc30 100644 --- a/app/lib/Core/User.pm +++ b/app/lib/Core/User.pm @@ -44,12 +44,13 @@ sub structure { type => 'text', title => 'логин', default => '', + description => 'логин для авторизации', }, password => { type => 'text', hide_for_user => 1, title => 'пароль', - description => 'пароль в зашифровнном виде', + description => 'пароль пользователя', }, type => { type => 'number', @@ -587,7 +588,8 @@ sub reg_api_safe { my $self = shift; my %args = ( login => undef, - password => undef, + login_type => 'login', + password => passgen(16), partner_id => undef, @_, ); @@ -620,6 +622,7 @@ sub reg_api_safe { return $self->reg( $args{login} ? ( login => $args{login} ) : (), + $args{login_type} ? ( login_type => $args{login_type} ) : (), $args{password} ? ( password => $args{password} ) : (), $args{partner_id} ? ( partner_id => $args{partner_id} ) : (), ); @@ -652,6 +655,13 @@ sub reg { } if ( $args{login} ) { + if ( $args{login_type} eq 'email' ) { + unless ( is_email( $args{login} ) ) { + get_service('report')->add_error("Incorrect email address"); + return undef; + } + } + my $ret = $user->logins->add( login => $args{login}, type => $args{login_type} ); unless ( $ret ) { get_service('report')->add_error("Can't create login"); diff --git a/app/lib/Core/User/Passwd.pm b/app/lib/Core/User/Passwd.pm index 2753da2e..82f27f0b 100644 --- a/app/lib/Core/User/Passwd.pm +++ b/app/lib/Core/User/Passwd.pm @@ -191,6 +191,7 @@ sub passwd_reset_verify { my $token = $args{token}; + # TODO my ( $user ) = $self->_list( where => { sprintf('%s->>"$.%s"', 'settings', 'reset_password_verify_token') => $token, diff --git a/app/public_html/shm/v1.cgi b/app/public_html/shm/v1.cgi index 8cdf1a64..ff0e7bf3 100755 --- a/app/public_html/shm/v1.cgi +++ b/app/public_html/shm/v1.cgi @@ -10,6 +10,7 @@ use Core::Utils qw( parse_args encode_json decode_json + is_email switch_user blessed print_header @@ -24,6 +25,7 @@ use Data::Dumper; state $routes //= { '/healthcheck' => { GET => { + params => {}, controller => 'Test', method => 'healthcheck', skip_check_auth => 1, @@ -31,6 +33,7 @@ state $routes //= { }, '/test' => { GET => { + params => {}, controller => 'Test', skip_check_auth => 1, }, @@ -59,6 +62,7 @@ state $routes //= { }, '/company' => { GET => { + params => {}, controller => 'Config', method => 'api_data_by_company', skip_check_auth => 1, @@ -67,6 +71,7 @@ state $routes //= { '/user/captcha' => { swagger => { tags => 'Капча' }, GET => { + params => {}, controller => 'User', method => 'gen_captcha', skip_check_auth => 1, @@ -77,23 +82,29 @@ state $routes //= { swagger => { tags => 'Пользователи' }, GET => { controller => 'User', + method => 'list_for_api', # hide COMMON_LIST_PARAMS swagger => { summary => 'Получение пользователя' }, + params => {}, }, PUT => { controller => 'User', method => 'reg_api_safe', skip_check_auth => 1, - required => ['login','password'], + params => { + login => { type => 'string', required => 1, min_length => 1, max_length => 64 }, + login_type => { type => 'string', required => 0, enum => ['login','email'] }, + password => { type => 'string', required => 1, min_length => 10, max_length => 128 }, + full_name => { type => 'string', required => 0, min_length => 1, max_length => 64 }, + phone => { type => 'string', required => 0, min_length => 1, max_length => 16 }, + partner_id => { type => 'integer', min => 2 }, + }, swagger => { summary => 'Регистрация пользователя' }, }, - POST => { - controller => 'User', - swagger => { summary => 'Изменить пользователя' }, - }, }, '/user/referrals' => { swagger => { tags => 'Пользователи' }, GET => { + params => {}, controller => 'User', method => 'api_referrals', swagger => { summary => 'Получение количества рефералов' }, @@ -105,7 +116,11 @@ state $routes //= { controller => 'User', method => 'auth_api_safe', skip_check_auth => 1, - required => ['login','password'], + params => { + login => { type => 'string', required => 1, min_length => 1, max_length => 64 }, + password => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + otp_token => { type => 'string', required => 0, min_length => 1, max_length => 128 }, + }, args => { format => 'json', }, @@ -133,16 +148,19 @@ state $routes //= { '/user/password-auth' => { swagger => { tags => 'Пользователи' }, GET => { + params => {}, controller => 'User', method => 'api_password_auth_status', swagger => { summary => 'Статус входа по паролю' }, }, POST => { + params => {}, controller => 'User', method => 'api_enable_password_auth', swagger => { summary => 'Включить вход по паролю' }, }, DELETE => { + params => {}, controller => 'User', method => 'api_disable_password_auth', swagger => { summary => 'Отключить вход по паролю' }, @@ -151,32 +169,40 @@ state $routes //= { '/user/otp' => { swagger => { tags => 'OTP' }, GET => { + params => {}, controller => 'User::OTP', method => 'api_status', swagger => { summary => 'Статус OTP' }, }, POST => { + params => { + token => { type => 'string', required => 1, min_length => 1, max_length => 16 }, + }, controller => 'User::OTP', method => 'api_verify', - required => ['token'], swagger => { summary => 'Проверка OTP' }, }, PUT => { + params => { + token => { type => 'string', required => 1, min_length => 1, max_length => 16 }, + }, controller => 'User::OTP', method => 'api_enable', - required => ['token'], swagger => { summary => 'Включение OTP' }, }, DELETE => { + params => { + token => { type => 'string', required => 1, min_length => 1, max_length => 16 }, + }, controller => 'User::OTP', method => 'api_disable', - required => ['token'], swagger => { summary => 'Отключение OTP' }, }, }, '/user/otp/setup' => { swagger => { tags => 'OTP' }, POST => { + params => {}, controller => 'User::OTP', method => 'api_setup', swagger => { @@ -212,50 +238,64 @@ state $routes //= { '/user/passkey/register' => { swagger => { tags => 'Passkey Регистрация' }, GET => { + params => {}, controller => 'User::Passkey', method => 'api_register_options', swagger => { summary => 'Получить параметры регистрации Passkey' }, }, POST => { + params => { + credential_id => { type => 'string', required => 1, min_length => 1, max_length => 512 }, + response => { type => 'string', required => 1, min_length => 1, max_length => 8192 }, + }, controller => 'User::Passkey', method => 'api_register_complete', - required => ['credential_id', 'response'], swagger => { summary => 'Завершить регистрацию Passkey' }, }, }, '/user/passkey' => { swagger => { tags => 'Passkey Настройки' }, GET => { + params => {}, controller => 'User::Passkey', method => 'api_list', swagger => { summary => 'Список зарегистрированных Passkey' }, }, POST => { + params => { + credential_id => { type => 'string', required => 1, min_length => 1, max_length => 512 }, + name => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + }, controller => 'User::Passkey', method => 'api_rename', - required => ['credential_id', 'name'], swagger => { summary => 'Переименовать зарегистрированный Passkey по идентификатору' }, }, DELETE => { + params => { + credential_id => { type => 'string', required => 1, min_length => 1, max_length => 512 }, + }, controller => 'User::Passkey', method => 'api_delete', - required => ['credential_id'], swagger => { summary => 'Удалить зарегистрированный Passkey по идентификатору' }, }, }, '/user/auth/passkey' => { swagger => { tags => 'Passkey Аутентификация' }, GET => { + params => {}, controller => 'User::Passkey', method => 'api_auth_options_public', skip_check_auth => 1, swagger => { summary => 'Получить параметры публичной аутентификации Passkey' }, }, POST => { + params => { + credential_id => { type => 'string', required => 1, min_length => 1, max_length => 512 }, + response => { type => 'string', required => 1, min_length => 1, max_length => 8192 }, + }, controller => 'User::Passkey', method => 'api_auth_public', skip_check_auth => 1, - required => ['credential_id', 'response'], swagger => { summary => 'Аутентификация пользователя с помощью Passkey' }, }, }, @@ -265,12 +305,18 @@ state $routes //= { swagger => { summary => 'Сменить пароль пользователя' }, controller => 'User', method => 'passwd', - required => ['password'], + params => { + password => { type => 'string', required => 1, min_length => 6, max_length => 128 }, + }, }, }, '/user/passwd/reset' => { swagger => { tags => 'Пользователи' }, POST => { + params => { + login => { type => 'string', min_length => 1, max_length => 64 }, + email => { type => 'email', max_length => 254 }, + }, controller => 'User', method => 'passwd_reset_request', skip_check_auth => 1, @@ -282,15 +328,20 @@ state $routes //= { GET => { controller => 'User', method => 'passwd_reset_verify', - required => ['token'], skip_check_auth => 1, + params => { + token => { type => 'string', required => 1, min_length => 8, max_length => 256 }, + }, swagger => { summary => 'Проверка токена сброса пароля пользователя перед сменой пароля' }, }, POST => { controller => 'User', method => 'passwd_reset_verify', - required => ['password', 'token'], skip_check_auth => 1, + params => { + password => { type => 'string', required => 1, min_length => 6, max_length => 128 }, + token => { type => 'string', required => 1, min_length => 8, max_length => 256 }, + }, swagger => { summary => 'Сменить пароль пользователя по токену сброса' }, }, }, @@ -300,13 +351,10 @@ state $routes //= { controller => 'User::Logins', swagger => { summary => 'Список аккаунтов пользователя' }, }, - PUT => { - controller => 'User::Logins', - }, - POST => { - controller => 'User::Logins', - }, DELETE => { + params => { + login => { type => "string", min_length => 1, max_length => 128 }, + }, controller => 'User::Logins', }, }, @@ -315,25 +363,32 @@ state $routes //= { PUT => { controller => 'User', method => 'set_email', - required => ['email'], + params => { + email => { type => 'email', required => 1, max_length => 254 }, + }, swagger => { summary => 'Привязать email пользователя' }, }, GET => { + params => {}, controller => 'User', method => 'get_email', swagger => { summary => 'Получить email пользователя' }, }, POST => { + params => { + email => { type => 'email', required => 1, max_length => 254 }, + code => { type => 'string', min_length => 1, max_length => 16 }, + }, controller => 'User', method => 'verify_email', - required => ['email'], - optional => ['code'], swagger => { summary => 'Верифицировать email пользователя' }, }, DELETE => { + params => { + email => { type => 'email', required => 1, max_length => 254 }, + }, controller => 'User', method => 'delete_email', - required => ['email'], swagger => { summary => 'Удалить email пользователя' }, }, }, @@ -341,12 +396,16 @@ state $routes //= { swagger => { tags => 'Услуги пользователей' }, GET => { controller => 'USObject', - optional => ['user_service_id'], + params => { + user_service_id => { type => 'integer', min => 1 }, + }, swagger => { summary => 'Список услуг пользователя' }, }, DELETE => { controller => 'USObject', - required => ['user_service_id'], + params => { + user_service_id => { type => 'integer', required => 1, min => 1 }, + }, swagger => { summary => 'Удалить услугу пользователя' }, }, }, @@ -355,7 +414,9 @@ state $routes //= { POST => { controller => 'USObject', method => 'block_force', - required => ['user_service_id'], + params => { + user_service_id => { type => 'integer', required => 1, min => 1 }, + }, swagger => { summary => 'Остановить услугу пользователя' }, }, }, @@ -364,7 +425,10 @@ state $routes //= { POST => { controller => 'USObject', method => 'change', - required => ['user_service_id','service_id'], + params => { + user_service_id => { type => 'integer', required => 1, min => 1 }, + service_id => { type => 'integer', required => 1, min => 1 }, + }, swagger => { summary => 'Сменить тариф' }, }, }, @@ -378,14 +442,15 @@ state $routes //= { '/user/autopayment' => { swagger => { tags => 'Пользователи' }, GET => { + params => {}, controller => 'User', method => 'list_autopayments', swagger => { summary => 'Список автоплатежей пользователя' }, }, DELETE => { + params => {}, controller => 'User', method => 'delete_autopayment', - required => [], args => { format => 'json', }, @@ -402,6 +467,11 @@ state $routes //= { '/user/pay/forecast' => { swagger => { tags => 'Платежи' }, GET => { + params => { + days => { type => 'integer', min => 1, max => 90 }, + consider_today => { type => 'boolean' }, + blocked => { type => 'boolean' }, + }, controller => 'Pay', method => 'forecast', swagger => { summary => 'Прогноз оплаты' }, @@ -410,6 +480,11 @@ state $routes //= { '/user/pay/paysystems' => { swagger => { tags => 'Платежи' }, GET => { + params => { + amount => { type => 'number', min => 0 }, + paysystem => { type => 'string', max_length => 64 }, + pp => { type => 'boolean' }, + }, controller => 'Pay', method => 'api_paysystems', swagger => { summary => 'Платежные системы' }, @@ -418,6 +493,7 @@ state $routes //= { '/service/order' => { swagger => { tags => 'Услуги' }, GET => { + params => {}, controller => 'Service', method => 'api_price_list', swagger => { summary => 'Список услуг для заказа' }, @@ -425,7 +501,9 @@ state $routes //= { PUT => { controller => 'USObject', method => 'create_for_api_safe', - required => ['service_id'], + params => { + service_id => { type => 'integer', required => 1, min => 1 }, + }, swagger => { summary => 'Регистрация услуги' }, }, }, @@ -434,12 +512,16 @@ state $routes //= { GET => { swagger => { summary => 'Информация об услуге' }, controller => 'Service', - required => ['service_id'], + params => { + service_id => { type => 'integer', required => 1, min => 1 }, + }, }, }, '/template/*' => { swagger => { tags => 'Шаблоны' }, + splat_to => 'id', GET => { + params => {}, controller => 'Template', method => 'parse_for_api', args => { @@ -448,6 +530,7 @@ state $routes //= { swagger => { summary => 'Выполнить шаблон' }, }, POST => { + params => {}, controller => 'Template', method => 'parse_for_api', skip_auto_parse_json => 1, @@ -459,7 +542,9 @@ state $routes //= { }, '/public/*' => { swagger => { tags => 'Шаблоны' }, + splat_to => 'id', GET => { + params => {}, user_id => 1, controller => 'Template', method => 'parse_for_public', @@ -469,6 +554,7 @@ state $routes //= { swagger => { summary => 'Выполнить публичный шаблон' }, }, POST => { + params => {}, user_id => 1, controller => 'Template', method => 'parse_for_public', @@ -483,20 +569,23 @@ state $routes //= { '/storage/manage' => { swagger => { tags => 'Хранилище' }, GET => { + params => {}, controller => 'Storage', swagger => { summary => 'Список данных' }, }, - PUT => { + PUT => { #TODO + params => {}, controller => 'Storage', swagger => { summary => 'Создать данные в хранилище' }, }, - POST => { + POST => { #TODO + params => {}, controller => 'Storage', swagger => { summary => 'Изменить данные в хранилище' }, }, - DELETE => { + DELETE => { #TODO + params => {}, controller => 'Storage', - required => ['name'], swagger => { summary => 'Удалить данные в хранилище' }, }, }, @@ -504,6 +593,7 @@ state $routes //= { swagger => { tags => 'Хранилище' }, splat_to => 'name', GET => { + params => {}, controller => 'Storage', method => 'read', args => { @@ -512,31 +602,31 @@ state $routes //= { swagger => { summary => 'Прочитать данные из хранилища' }, }, PUT => { + params => {}, controller => 'Storage', method => 'add', skip_auto_parse_json => 1, allow_text_plain => 1, - required => [], args => { format => 'plain', }, swagger => { summary => 'Создать данные в хранилище' }, }, POST => { + params => {}, controller => 'Storage', method => 'replace', skip_auto_parse_json => 1, allow_text_plain => 1, - required => [], args => { format => 'plain', }, swagger => { summary => 'Изменить данные в хранилище' }, }, DELETE => { + params => {}, controller => 'Storage', method => 'delete', - required => [], swagger => { summary => 'Удалить данные из хранилища' }, }, }, @@ -544,6 +634,7 @@ state $routes //= { swagger => { tags => 'Хранилище' }, splat_to => 'name', GET => { + params => {}, controller => 'Storage', method => 'download', args => { @@ -555,6 +646,7 @@ state $routes //= { '/promo' => { swagger => { tags => 'Промокоды' }, GET => { + params => {}, controller => 'Promo', method => 'api_get', swagger => { @@ -612,18 +704,18 @@ state $routes //= { swagger => { tags => 'Промокоды' }, splat_to => 'code', GET => { + params => {}, controller => 'Promo', method => 'api_apply', - required => ['code'], args => { format => 'json', }, swagger => { summary => 'Применить промокод' }, }, }, - '/admin/system/version' => { GET => { + params => {}, controller => 'Config', method => 'version_info', args => { @@ -634,6 +726,9 @@ state $routes //= { '/admin/service' => { swagger => { tags => 'Услуги' }, GET => { + params => { + service_id => { type => 'integer', min => 1 }, + }, controller => 'Service', swagger => { summary => 'Получить услугу' }, }, @@ -643,45 +738,57 @@ state $routes //= { }, POST => { controller => 'Service', - required => ['service_id'], swagger => { summary => 'Изменить услугу' }, }, DELETE => { + params => { + service_id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Service', - required => ['service_id'], swagger => { summary => 'Удалить услугу' }, }, }, '/admin/service/order' => { swagger => { tags => ['Услуги','Услуги пользователей'] }, GET => { + params => {}, controller => 'Service', method => 'api_price_list', swagger => { summary => 'Список услуг доступных для регистрации' }, }, PUT => { + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + service_id => { type => 'integer', required => 1, min => 1 }, + months => { type => 'number', min => 1 }, + cost => { type => 'number', min => 0 }, + setting => { type => 'object' }, + }, controller => 'USObject', method => 'create_for_api', - required => ['user_id','service_id'], swagger => { summary => 'Зарегистрировать услугу клиенту' }, }, }, '/admin/service/children' => { swagger => { tags => 'Услуги' }, GET => { + params => { + service_id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Service', method => 'api_subservices_list', - required => ['service_id'], swagger => { summary => 'Список дочерних услуг' }, }, POST => { + params => { + service_id => { type => 'integer', required => 1, min => 1 }, + children => { type => 'object', required => 1 }, + }, controller => 'Service', method => 'children', - required => ['service_id', 'children'], swagger => { summary => 'Изменить список дочерних услуг' }, }, }, - '/admin/service/event' => { swagger => { tags => 'События' }, GET => { @@ -697,8 +804,10 @@ state $routes //= { swagger => { summary => 'Изменить событие' }, }, DELETE => { + params => { + id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Events', - required => ['id'], swagger => { summary => 'Удалить событие' }, }, }, @@ -710,23 +819,28 @@ state $routes //= { }, PUT => { controller => 'User', - required => ['full_name'], + method => 'reg', swagger => { summary => 'Создать клиента' }, }, POST => { controller => 'User', - required => ['user_id'], swagger => { summary => 'Изменить клиента' }, }, DELETE => { controller => 'User', - required => ['user_id'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + force => { type => 'boolean' }, + }, swagger => { summary => 'Удалить клиента' }, }, }, '/admin/user/search' => { swagger => { tags => 'Пользователи' }, GET => { + params => { + text => { type => 'string', min_length => 1, max_length => 128 }, + }, controller => 'User', method => 'api_search_for_admins', swagger => { summary => 'Поиск клиентов' }, @@ -740,18 +854,20 @@ state $routes //= { }, PUT => { controller => 'User::Logins', - required => ['user_id','login','type'], swagger => { summary => 'Добавить аккаунт' }, }, POST => { controller => 'User::Logins', - required => ['user_id','login','type'], swagger => { summary => 'Изменить аккаунт' }, }, DELETE => { + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + login => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + type => { type => 'string', required => 1, min_length => 1, max_length => 32 }, + }, controller => 'User::Logins', method => 'api_delete', - required => ['user_id','login','type'], swagger => { summary => 'Удалить аккаунт' }, }, }, @@ -760,7 +876,10 @@ state $routes //= { POST => { controller => 'User', method => 'passwd', - required => ['user_id','password'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + password => { type => 'string', required => 1, min_length => 6, max_length => 128 }, + }, swagger => { summary => 'Сменить пароль клиенту' }, }, }, @@ -769,12 +888,18 @@ state $routes //= { PUT => { controller => 'User', method => 'payment', - required => ['user_id','money'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + money => { type => 'number', required => 1 }, + pay_system_id => { type => 'string' }, + comment => { type => 'object' }, + }, swagger => { summary => 'Зачислить деньги клиенту' }, }, }, '/admin/user/profile' => { GET => { + params => {}, controller => 'Profile', }, PUT => { @@ -784,6 +909,10 @@ state $routes //= { controller => 'Profile', }, DELETE => { + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Profile', }, }, @@ -794,8 +923,11 @@ state $routes //= { swagger => { summary => 'Список платежей клиентов' }, }, DELETE => { + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Pay', - required => ['user_id', 'id'], swagger => { summary => 'Удалить платеж клиента' }, }, }, @@ -814,14 +946,22 @@ state $routes //= { swagger => { summary => 'Изменить бонус' }, }, DELETE => { + params => { + id => { type => 'integer', required => 1, min => 1 }, + user_id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Bonus', - required => ['id','user_id'], swagger => { summary => 'Удалить бонус' }, }, }, '/admin/user/service' => { swagger => { tags => 'Услуги пользователей' }, GET => { + params => { + user_id => { type => 'integer', min => 1 }, + service_id => { type => 'integer', min => 1 }, + user_service_id => { type => 'integer', min => 1 }, + }, controller => 'UserService', swagger => { summary => 'Список услуг клиентов' }, }, @@ -830,18 +970,21 @@ state $routes //= { }, POST => { controller => 'USObject', - required => ['user_id', 'user_service_id'], swagger => { summary => 'Изменить услугу клиента' }, }, DELETE => { + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + user_service_id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'USObject', - required => ['user_id', 'user_service_id'], swagger => { summary => 'Удалить услугу клиента' }, }, }, '/admin/user/service/categories' => { swagger => { tags => 'Услуги пользователей' }, GET => { + params => {}, controller => 'Service', method => 'categories', swagger => { summary => 'Получить список категорий услуг' }, @@ -850,6 +993,10 @@ state $routes //= { '/admin/user/service/withdraw' => { swagger => { tags => 'Списания' }, GET => { + params => { + user_id => { type => 'integer', min => 1 }, + withdraw_id => { type => 'integer', min => 1 }, + }, controller => 'Withdraw', swagger => { summary => 'Получить список списаний клиентов' }, }, @@ -859,12 +1006,14 @@ state $routes //= { }, POST => { controller => 'Withdraw', - required => ['user_id', 'withdraw_id'], swagger => { summary => 'Изменить списание клиента' }, }, DELETE => { + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + withdraw_id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Withdraw', - required => ['user_id', 'withdraw_id'], swagger => { summary => 'Удалить списание клиента' }, }, }, @@ -873,7 +1022,11 @@ state $routes //= { POST => { controller => 'USObject', method => 'set_status_manual', - required => ['user_id','user_service_id','status'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + user_service_id => { type => 'integer', required => 1, min => 1 }, + status => { type => 'string', required => 1, enum => ['ACTIVE','BLOCK'] }, + }, swagger => { summary => 'Сменить статус услуги клиента' }, }, }, @@ -882,7 +1035,10 @@ state $routes //= { POST => { controller => 'USObject', method => 'block_force', - required => ['user_id','user_service_id'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + user_service_id => { type => 'integer', required => 1, min => 1 }, + }, swagger => { summary => 'Остановить услугу клиента' }, }, }, @@ -891,7 +1047,10 @@ state $routes //= { POST => { controller => 'USObject', method => 'activate_force', - required => ['user_id','user_service_id'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + user_service_id => { type => 'integer', required => 1, min => 1 }, + }, swagger => { summary => 'Возобновить услугу клиента' }, }, }, @@ -900,7 +1059,10 @@ state $routes //= { POST => { controller => 'USObject', method => 'touch_api', - required => ['user_id','user_service_id'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + user_service_id => { type => 'integer', required => 1, min => 1 }, + }, swagger => { summary => 'Обработать услугу' }, }, }, @@ -909,7 +1071,11 @@ state $routes //= { POST => { controller => 'USObject', method => 'change', - required => ['user_id','user_service_id','service_id'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + user_service_id => { type => 'integer', required => 1, min => 1 }, + service_id => { type => 'integer', required => 1, min => 1 }, + }, swagger => { summary => 'Сменить тариф услуги клиента' }, }, }, @@ -918,7 +1084,10 @@ state $routes //= { GET => { controller => 'USObject', method => 'api_spool_commands', - required => ['user_id','user_service_id'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + user_service_id => { type => 'integer', required => 1, min => 1 }, + }, swagger => { summary => 'Получить список текущих задач для услуги клиента' }, }, }, @@ -927,7 +1096,9 @@ state $routes //= { PUT => { controller => 'User', method => 'gen_session', - required => ['user_id'], + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + }, args => { format => 'json', }, @@ -955,6 +1126,9 @@ state $routes //= { '/admin/server' => { swagger => { tags => 'Сервера' }, GET => { + params => { + server_id => { type => 'integer', min => 1 }, + }, controller => 'Server', swagger => { summary => 'Получить список серверов' }, }, @@ -964,18 +1138,22 @@ state $routes //= { }, POST => { controller => 'Server', - required => ['server_id'], swagger => { summary => 'Изменить сервер' }, }, DELETE => { + params => { + server_id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Server', - required => ['server_id'], swagger => { summary => 'Удалить сервер' }, }, }, '/admin/server/group' => { swagger => { tags => 'Группы серверов' }, GET => { + params => { + group_id => { type => 'integer', min => 1 }, + }, controller => 'ServerGroups', swagger => { summary => 'Получить список групп серверов' }, }, @@ -988,14 +1166,19 @@ state $routes //= { swagger => { summary => 'Изменить группу серверов' }, }, DELETE => { + params => { + group_id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'ServerGroups', - required => ['group_id'], swagger => { summary => 'Удалить группу серверов' }, }, }, '/admin/server/identity' => { swagger => { tags => 'Ключи SSH' }, GET => { + params => { + id => { type => 'integer', min => 1 }, + }, controller => 'Identities', swagger => { summary => 'Список SSH ключей' }, }, @@ -1008,14 +1191,19 @@ state $routes //= { swagger => { summary => 'Изменить SSH ключ' }, }, DELETE => { + params => { + id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Identities', - required => ['id'], swagger => { summary => 'Удалить SSH ключ' }, }, }, '/admin/server/identity/generate' => { swagger => { tags => 'Ключи SSH' }, GET => { + params => { + type => { type => 'string', enum => ['rsa','dsa','ecdsa','ed25519'] }, + }, controller => 'Identities', method => 'generate_key_pair', swagger => { summary => 'Сгенерировать SSH ключи' }, @@ -1024,6 +1212,12 @@ state $routes //= { '/admin/spool' => { swagger => { tags => 'Задачи' }, GET => { + params => { + id => { type => 'integer', min => 1 }, + user_id => { type => 'integer', min => 1 }, + user_service_id => { type => 'integer', min => 1 }, + status => { type => 'string' }, + }, controller => 'Spool', swagger => { summary => 'Список текущих задач' }, }, @@ -1036,14 +1230,17 @@ state $routes //= { swagger => { summary => 'Изменить задачу' }, }, DELETE => { + params => { + id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Spool', - required => ['id'], swagger => { summary => 'Удалить задачу' }, }, }, '/admin/spool/statuses' => { swagger => { tags => 'Задачи' }, GET => { + params => {}, controller => 'Spool', method => 'statuses', swagger => { summary => 'Статусы задач' }, @@ -1060,15 +1257,21 @@ state $routes //= { swagger => { tags => 'Задачи' }, splat_to => 'action', POST => { + params => { + id => { type => 'integer', required => 1, min => 1 }, + action => { type => 'string', enum => ['success','pause','retry'] }, + }, controller => 'Spool', method => 'api_manual_action', - required => ['id'], swagger => { summary => 'Изменить статус задачи вручную' }, }, }, '/admin/template' => { swagger => { tags => 'Шаблоны' }, GET => { + params => { + id => { type => 'string', min => 1 }, + }, controller => 'Template', method => 'list', swagger => { summary => 'Список шаблонов' }, @@ -1090,8 +1293,10 @@ state $routes //= { }, }, DELETE => { + params => { + id => { type => 'string', required => 1, min => 1 }, + }, controller => 'Template', - required => ['id'], swagger => { summary => 'Удалить шаблон' }, }, }, @@ -1099,6 +1304,9 @@ state $routes //= { swagger => { tags => 'Шаблоны' }, splat_to => 'id', GET => { + params => { + id => { type => 'string', required => 1, min => 1 }, + }, controller => 'Template', method => 'parse_for_api', args => { @@ -1120,12 +1328,19 @@ state $routes //= { }, }, DELETE => { + params => { + id => { type => 'string', required => 1, min => 1 }, + }, controller => 'Template', }, }, '/admin/storage/manage' => { swagger => { tags => 'Хранилище' }, GET => { + params => { + user_id => { type => 'integer', min => 1 }, + name => { type => 'string', max_length => 255 }, + }, controller => 'Storage', swagger => { summary => 'Получить список объектов хранилища' }, }, @@ -1139,9 +1354,12 @@ state $routes //= { swagger => { summary => 'Изменить данные в объекте хранилища' }, }, DELETE => { + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + name => { type => 'string', required => 1, min_length => 1, max_length => 255 }, + }, controller => 'Storage', method => 'delete', - required => ['user_id','name'], swagger => { summary => 'Удалить объект из хранилища' }, }, }, @@ -1149,9 +1367,12 @@ state $routes //= { swagger => { tags => 'Хранилище' }, splat_to => 'name', GET => { + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + name => { type => 'string', required => 1, min_length => 1, max_length => 32 }, + }, controller => 'Storage', method => 'read', - required => ['user_id'], args => { format => 'other', }, @@ -1160,17 +1381,22 @@ state $routes //= { POST => { controller => 'Storage', method => 'replace', - required => ['user_id'], }, DELETE => { + params => { + user_id => { type => 'integer', required => 1, min => 1 }, + name => { type => 'string', required => 1, min_length => 1, max_length => 32 }, + }, controller => 'Storage', method => 'delete', - required => ['user_id'], }, }, '/admin/config' => { swagger => { tags => 'Конфигурация' }, GET => { + params => { + key => { type => 'string', max_length => 128 }, + }, controller => 'Config', swagger => { summary => 'Прочитать весь конфиг' }, }, @@ -1183,8 +1409,10 @@ state $routes //= { swagger => { summary => 'Изменить объект в конфиге' }, }, DELETE => { + params => { + key => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + }, controller => 'Config', - required => ['key'], swagger => { summary => 'Удалить объект в конфиге' }, }, }, @@ -1192,20 +1420,24 @@ state $routes //= { swagger => { tags => 'Конфигурация' }, splat_to => 'key', GET => { + params => {}, controller => 'Config', method => 'api_data_by_name', swagger => { summary => 'Получить объект конфига' }, }, POST => { controller => 'Config', + params => {}, method => 'api_set_value', skip_auto_parse_json => 1, swagger => { summary => 'Изменить объект в конфиге' }, }, DELETE => { + params => { + value => { type => 'string', required => 1, min_length => 1, max_length => 256 }, + }, controller => 'Config', method => 'api_delete_value', - required => ['value'], swagger => { summary => 'Удалить значение или объект внутри объекта конфига' }, }, }, @@ -1216,33 +1448,40 @@ state $routes //= { }, '/admin/transport/ssh/test' => { PUT => { + params => { + host => { type => 'string', required => 1, min_length => 1, max_length => 255 }, + key_id => { type => 'integer', required => 1, min => 1 }, + server_id => { type => 'integer', required => 1, min => 1 }, + template_id => { type => 'string', min_length => 1 }, + cmd => { type => 'string', min_length => 1 }, + event_name => { type => 'string', min_length => 1}, + timeout => { type => 'integer', min => 1, default => 600 }, + }, controller => 'Transport::Ssh', method => 'ssh_test', - required => [ - 'host', - 'key_id', - ], }, }, '/admin/transport/ssh/init' => { PUT => { + params => { + host => { type => 'string', required => 1, min_length => 1, max_length => 255 }, + key_id => { type => 'integer', required => 1, min => 1 }, + server_id => { type => 'integer', required => 1, min => 1 }, + template_id => { type => 'string', min_length => 1 }, + cmd => { type => 'string', min_length => 1 }, + event_name => { type => 'string', min_length => 1}, + timeout => { type => 'integer', min => 1, default => 600 }, + }, controller => 'Transport::Ssh', method => 'ssh_init', - required => [ - 'host', - 'key_id', - 'template_id', - ], - }, -}, -'/admin/transport/mail/test' => { - POST => { - }, }, '/admin/promo' => { swagger => { tags => 'Промокоды' }, GET => { + params => { + id => { type => 'integer', min => 1 }, + }, controller => 'Promo', swagger => { summary => 'Список промокодов' }, }, @@ -1257,9 +1496,11 @@ state $routes //= { swagger => { summary => 'Изменить промокод' }, }, DELETE => { + params => { + id => { type => 'integer', required => 1, min => 1 }, + }, controller => 'Promo', method => 'delete', - required => ['id'], swagger => { summary => 'Удалить промокод' }, }, }, @@ -1275,12 +1516,17 @@ state $routes //= { }, '/admin/analytics' => { GET => { + params => { + months => { type => 'integer' }, + no_cache => { type => 'boolean' }, + }, controller => 'Analytics', method => 'api_report', }, }, '/admin/analytics/cache/clear' => { POST => { + params => {}, controller => 'Analytics', method => 'clear_cache', }, @@ -1290,6 +1536,7 @@ state $routes //= { tags => 'Telegram bot', }, GET => { + params => {}, controller => 'Transport::Telegram', method => 'user_tg_settings', args => { @@ -1300,6 +1547,7 @@ state $routes //= { }, }, POST => { + params => {}, controller => 'Transport::Telegram', method => 'api_set_user_tg_settings', skip_auto_parse_json => 1, @@ -1311,6 +1559,7 @@ state $routes //= { }, }, DELETE => { + params => {}, controller => 'Transport::Telegram', method => 'api_delete_user_tg_settings', args => { @@ -1323,6 +1572,36 @@ state $routes //= { }, '/telegram/bot' => { POST => { + params => { + # Telegram Update object fields (https://core.telegram.org/bots/api#update) + tg_profile => { type => 'string' }, + update_id => { type => 'integer', min => 1 }, + message => { type => 'object' }, + edited_message => { type => 'object' }, + channel_post => { type => 'object' }, + edited_channel_post => { type => 'object' }, + business_connection => { type => 'object' }, + business_message => { type => 'object' }, + edited_business_message => { type => 'object' }, + deleted_business_messages => { type => 'object' }, + guest_message => { type => 'object' }, + message_reaction => { type => 'object' }, + message_reaction_count => { type => 'object' }, + inline_query => { type => 'object' }, + chosen_inline_result => { type => 'object' }, + callback_query => { type => 'object' }, + shipping_query => { type => 'object' }, + pre_checkout_query => { type => 'object' }, + purchased_paid_media => { type => 'object' }, + poll => { type => 'object' }, + poll_answer => { type => 'object' }, + my_chat_member => { type => 'object' }, + chat_member => { type => 'object' }, + chat_join_request => { type => 'object' }, + chat_boost => { type => 'object' }, + removed_chat_boost => { type => 'object' }, + managed_bot => { type => 'object' }, + }, skip_check_auth => 1, skip_errors => 1, # do not send report errors to TG API controller => 'Transport::Telegram', @@ -1338,6 +1617,36 @@ state $routes //= { }, splat_to => 'template', POST => { + params => { + # Telegram Update object fields (https://core.telegram.org/bots/api#update) + tg_profile => { type => 'string' }, + update_id => { type => 'integer', min => 1 }, + message => { type => 'object' }, + edited_message => { type => 'object' }, + channel_post => { type => 'object' }, + edited_channel_post => { type => 'object' }, + business_connection => { type => 'object' }, + business_message => { type => 'object' }, + edited_business_message => { type => 'object' }, + deleted_business_messages => { type => 'object' }, + guest_message => { type => 'object' }, + message_reaction => { type => 'object' }, + message_reaction_count => { type => 'object' }, + inline_query => { type => 'object' }, + chosen_inline_result => { type => 'object' }, + callback_query => { type => 'object' }, + shipping_query => { type => 'object' }, + pre_checkout_query => { type => 'object' }, + purchased_paid_media => { type => 'object' }, + poll => { type => 'object' }, + poll_answer => { type => 'object' }, + my_chat_member => { type => 'object' }, + chat_member => { type => 'object' }, + chat_join_request => { type => 'object' }, + chat_boost => { type => 'object' }, + removed_chat_boost => { type => 'object' }, + managed_bot => { type => 'object' }, + }, skip_check_auth => 1, skip_errors => 1, # do not send report errors to TG API controller => 'Transport::Telegram', @@ -1352,15 +1661,17 @@ state $routes //= { }, '/telegram/set_webhook' => { POST => { + params => { + url => { type => 'string', required => 1, min_length => 1, max_length => 2048 }, + token => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + secret => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + template_id => { type => 'string', required => 1, min_length => 1 }, + tg_profile => { type => 'string', required => 1, min_length => 1 }, + allowed_updates => { type => 'object' }, + }, skip_check_auth => 1, controller => 'Transport::Telegram', method => 'set_webhook', - required => [ - 'url', - 'token', - 'secret', - 'template_id', - ], args => { format => 'json', }, @@ -1374,10 +1685,13 @@ state $routes //= { tags => 'Telegram bot', }, GET => { + params => { + initData => { type => 'string', required => 1, min_length => 1, max_length => 4096 }, + profile => { type => 'string', required => 1, min_length => 1, max_length => 32 }, + }, skip_check_auth => 1, controller => 'Transport::Telegram', method => 'webapp_auth', - required => ['initData'], args => { format => 'json', }, @@ -1407,34 +1721,35 @@ state $routes //= { tags => 'Telegram bot', }, POST => { + params => { + profile => { type => 'string' }, + register_if_not_exists => { type => 'boolean' }, + bind_to_profile => { type => 'boolean' }, + bind_only_if_new => { type => 'boolean' }, + uid => { type => 'integer', min => 1 }, + # OIDC code flow + code => { type => 'string' }, + redirect_uri => { type => 'string' }, + code_verifier => { type => 'string' }, + client_id => { type => 'string' }, + client_secret => { type => 'string' }, + state => { type => 'string' }, + expected_state => { type => 'string' }, + id_token => { type => 'string' }, + nonce => { type => 'string' }, + # Legacy widget fields + id => { type => 'string' }, + first_name => { type => 'string' }, + last_name => { type => 'string' }, + username => { type => 'string' }, + photo_url => { type => 'string' }, + auth_date => { type => 'string' }, + hash => { type => 'string' }, + query => { type => 'string' }, + }, skip_check_auth => 1, controller => 'Transport::Telegram', method => 'web_auth', - args => { - format => 'json', - code => undef, - redirect_uri => undef, - code_verifier => undef, - state => undef, - expected_state => undef, - client_id => undef, - client_secret => undef, - id_token => undef, - nonce => undef, - profile => 'telegram_bot', - register_if_not_exists => 0, - bind_to_profile => 0, - bind_only_if_new => 0, - uid => undef, - query => undef, - id => undef, - first_name => undef, - last_name => undef, - username => undef, - photo_url => undef, - auth_date => undef, - hash => undef, - }, swagger => { summary => 'Авторизация через Telegram Login (OIDC id_token или legacy Widget)', responses => { @@ -1461,21 +1776,20 @@ state $routes //= { tags => 'Telegram bot', }, GET => { + params => { + profile => { type => 'string' }, + redirect_uri => { type => 'string' }, + return_url => { type => 'string' }, + scope => { type => 'string' }, + register_if_not_exists => { type => 'boolean' }, + bind_to_profile => { type => 'boolean' }, + bind_only_if_new => { type => 'boolean' }, + uid => { type => 'integer', min => 1 }, + ttl => { type => 'integer', min => 1 }, + }, skip_check_auth => 1, controller => 'Transport::Telegram', method => 'telegram_oidc_init', - args => { - format => 'json', - profile => 'telegram_bot', - redirect_uri => undef, - return_url => undef, - scope => 'openid profile', - register_if_not_exists => 0, - bind_to_profile => 0, - bind_only_if_new => 0, - uid => undef, - ttl => 600, - }, swagger => { summary => 'Инициализация Telegram Login OIDC (state, nonce, PKCE)', responses => { @@ -1506,21 +1820,20 @@ state $routes //= { tags => 'Telegram bot', }, GET => { + params => { + profile => { type => 'string' }, + redirect_uri => { type => 'string' }, + return_url => { type => 'string' }, + scope => { type => 'string' }, + register_if_not_exists => { type => 'boolean' }, + bind_to_profile => { type => 'boolean' }, + bind_only_if_new => { type => 'boolean' }, + uid => { type => 'integer', min => 1 }, + ttl => { type => 'integer', min => 1 }, + }, skip_check_auth => 1, controller => 'Transport::Telegram', method => 'telegram_oidc_start_redirect', - args => { - format => 'json', - profile => 'telegram_bot', - redirect_uri => undef, - return_url => undef, - scope => 'openid profile', - register_if_not_exists => 0, - bind_to_profile => 0, - bind_only_if_new => 0, - uid => undef, - ttl => 600, - }, swagger => { summary => 'Старт Telegram Login OIDC с HTTP redirect на Telegram OAuth', responses => { @@ -1536,27 +1849,27 @@ state $routes //= { tags => 'Telegram bot', }, GET => { + params => { + profile => { type => 'string' }, + register_if_not_exists => { type => 'boolean' }, + bind_to_profile => { type => 'boolean' }, + bind_only_if_new => { type => 'boolean' }, + uid => { type => 'integer', min => 1 }, + # OIDC code flow + code => { type => 'string' }, + redirect_uri => { type => 'string' }, + return_url => { type => 'string' }, + code_verifier => { type => 'string' }, + state => { type => 'string' }, + expected_state => { type => 'string' }, + client_id => { type => 'string' }, + client_secret => { type => 'string' }, + id_token => { type => 'string' }, + nonce => { type => 'string' }, + }, skip_check_auth => 1, controller => 'Transport::Telegram', method => 'web_auth_callback', - args => { - format => 'json', - code => undef, - redirect_uri => undef, - return_url => undef, - code_verifier => undef, - state => undef, - expected_state => undef, - client_id => undef, - client_secret => undef, - id_token => undef, - nonce => undef, - profile => 'telegram_bot', - register_if_not_exists => 0, - bind_to_profile => 0, - bind_only_if_new => 0, - uid => undef, - }, swagger => { summary => 'Callback endpoint для Telegram Login (OIDC code flow)', responses => { @@ -1580,10 +1893,17 @@ state $routes //= { }, '/admin/cloud/user' => { GET => { + params => {}, controller => 'Cloud', method => 'get_user', }, PUT => { + params => { + login => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + password => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + captcha_token => { type => 'string' }, + captcha_answer => { type => 'string' }, + }, controller => 'Cloud', method => 'reg_user', }, @@ -1593,35 +1913,46 @@ state $routes //= { tags => 'Cloud SHM', }, POST => { + params => { + login => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + password => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + }, controller => 'Cloud', - required => ['login','password'], method => 'login_user', }, DELETE => { + params => {}, controller => 'Cloud', method => 'logout_user', }, }, '/admin/cloud/paysystems' => { GET => { + params => {}, controller => 'Cloud', method => 'paysystems', }, }, '/admin/cloud/currencies' => { GET => { + params => { + no_cache => { type => 'boolean' }, + }, controller => 'Cloud::Currency', method => 'currencies', }, POST => { + params => { + currencies => { type => 'object', required => 1 }, + }, controller => 'Cloud::Currency', method => 'save', - required => ['currencies'], }, }, '/admin/cloud/proxy/*' => { splat_to => 'uri', GET => { + params => {}, controller => 'Cloud', method => 'proxy', args => { @@ -1629,6 +1960,7 @@ state $routes //= { }, }, POST => { + params => {}, controller => 'Cloud', method => 'proxy', args => { @@ -1636,6 +1968,7 @@ state $routes //= { }, }, PUT => { + params => {}, controller => 'Cloud', method => 'proxy', args => { @@ -1643,6 +1976,7 @@ state $routes //= { }, }, DELETE => { + params => {}, controller => 'Cloud', method => 'proxy', args => { @@ -1653,8 +1987,9 @@ state $routes //= { }; -$routes->{'/swagger.json'} = { +$routes->{'/swagger.json'} //= { GET => { + params => {}, controller => 'Swagger', method => 'gen_swagger_json', skip_check_auth => 1, @@ -1665,8 +2000,9 @@ $routes->{'/swagger.json'} = { }, }; -$routes->{'/swagger_admin.json'} = { +$routes->{'/swagger_admin.json'} //= { GET => { + params => {}, controller => 'Swagger', method => 'gen_swagger_json', skip_check_auth => 1, @@ -1732,30 +2068,85 @@ if ( my $p = $router->match( sprintf("%s:%s", $ENV{REQUEST_METHOD}, $uri )) ) { delete $args{user_id}; } - if ( my $r_args = $p->{required} ) { - for ( @{ $r_args } ) { - $args{ $_ } = $p->{ $_ } if exists $p->{ $_ }; - unless ( exists $args{ $_ } ) { - print_header( status => 400 ); - print_json( { status => 400, error => sprintf("Field required: %s", $_) } ); - exit 0; - } - } - } - my $service = get_service( $p->{controller} ); unless ( $service ) { - print_header( status => 500 ); - print_json( { error => 'Недоступно в данной версии'} ); + print_header( status => 404 ); + print_json( { error => 'Ресурс не найден'} ); exit 0; } + # If params is explicitly defined in route (even as empty {}), use it as-is. + # If params is not defined at all, auto-populate from controller structure. + my %schema; + if ( exists $p->{params} ) { + %schema = %{ $p->{params} }; + } elsif ( $service->can('structure') ) { + my $structure = $service->structure; + for my $field ( keys %{ $structure } ) { + my $type = $structure->{ $field }->{type} // 'string'; + $type = 'object' if $type eq 'json'; + $type = 'string' if $type =~ /^(?:text|now|date)$/; + $schema{ $field } = { type => $type }; + } + } + + # For GET list_for_api endpoints, inject common list params into schema so + # they pass validation and are forwarded to the controller. + if ( $ENV{REQUEST_METHOD} eq 'GET' && ( !$p->{method} || $p->{common_params} ) ) { + my %_common = ( + field => { type => 'string', pattern => '^\w+' }, + sort_field => {type => 'string', pattern => '^\w+' }, + sort_direction => { type => 'enum', enum => ['desc','asc'] }, + start => { type => 'string', pattern => '^\d{4}-\d{2}-\d{2}' }, + stop => { type => 'string', pattern => '^\d{4}-\d{2}-\d{2}' }, + limit => { type => 'integer', min => 0, max => 10000 }, + offset => { type => 'integer', min => 0 }, + filter => { type => 'string' }, + ); + + for my $f ( keys %_common ) { + $schema{$f} //= $_common{$f}; + } + } + my $method = $p->{method}; $method ||= 'list_for_api' if $ENV{REQUEST_METHOD} eq 'GET'; $method ||= 'api_set' if $ENV{REQUEST_METHOD} eq 'POST'; $method ||= 'api_add' if $ENV{REQUEST_METHOD} eq 'PUT'; $method ||= 'delete' if $ENV{REQUEST_METHOD} eq 'DELETE'; + my %allowed_input_fields = map { $_ => 1 } ( + keys %schema, + 'dry_run', + 'POSTDATA', + 'PUTDATA', + ( $p->{splat_to} ? $p->{splat_to} : () ), + ); + if ( my $err = validate_params( \%schema, \%args, \%allowed_input_fields ) ) { + print_header( status => 400 ); + print_json( { status => 400, error => $err } ); + get_service('logger')->warning( sprintf("API validation error: %s %s::%s => %s", + $ENV{REQUEST_METHOD}, + ref $service, + $method, + $err + )); + exit 0; + } + + # Build safe_args: route-level defaults + only declared/validated input fields. + # Any %in field not listed in params or optional is silently dropped. + # Use %args (not %in) so that type-coerced values (int, number) are used. + my %safe_args = ( + %{ $p->{args} || {} }, + $admin_mode ? ( admin => $admin_mode ) : (), + ); + for my $field ( keys %in ) { + $safe_args{$field} = $args{$field} if $allowed_input_fields{$field}; + } + # Preserve user_id context if admin switched user (already processed above) + $safe_args{user_id} = $args{user_id} if $admin_mode && exists $args{user_id}; + unless ( $service->can( $method ) ) { print_header( status => 500 ); print_json( { status => 500, error => 'Method not exists'} ); @@ -1798,15 +2189,15 @@ if ( my $p = $router->match( sprintf("%s:%s", $ENV{REQUEST_METHOD}, $uri )) ) { my %info; if ( $ENV{REQUEST_METHOD} eq 'GET' ) { - @data = $service->$method( %args ); + @data = $service->$method( %safe_args ); %info = ( items => $service->found_rows(), limit => $in{limit} || 25, offset => $in{offset} || 0, - $args{filter} ? (filter => $args{filter}) : (), + $safe_args{filter} ? (filter => $safe_args{filter}) : (), ); } elsif ( $ENV{REQUEST_METHOD} eq 'PUT' ) { - my $ret = $service->$method( %args ); + my $ret = $service->$method( %safe_args ); if ( length $ret ) { if ( ref $ret eq 'HASH' ) { push @data, $ret; @@ -1829,13 +2220,13 @@ if ( my $p = $router->match( sprintf("%s:%s", $ENV{REQUEST_METHOD}, $uri )) ) { } } elsif ( $ENV{REQUEST_METHOD} eq 'POST' || $ENV{REQUEST_METHOD} eq 'DELETE' ) { if ( $user->id && $service->can('structure') ) { - if ( $service = $service->id( get_service_id( $service, %args ) ) ) { + if ( $service = $service->id( get_service_id( $service, %safe_args ) ) ) { if ( !$admin_mode && $user->id != $service->user_id ) { $headers{status} = 404; $info{error} = "Object not found. Check the ID."; } else { if ( $service->lock( timeout => 3 )) { - push @data, $service->$method( %args ); + push @data, $service->$method( %safe_args ); } else { $headers{status} = 408; $info{error} = "The service is locked. Try again later."; @@ -1846,7 +2237,7 @@ if ( my $p = $router->match( sprintf("%s:%s", $ENV{REQUEST_METHOD}, $uri )) ) { $info{error} = "Object not found. Check the ID."; } } elsif ( $service->can( $method ) ) { - push @data, $service->$method( %args ); + push @data, $service->$method( %safe_args ); } else { $headers{status} = 400; $info{error} = "Unknown error"; @@ -1870,8 +2261,8 @@ if ( my $p = $router->match( sprintf("%s:%s", $ENV{REQUEST_METHOD}, $uri )) ) { $service->remove_protected_fields( \@data, admin => $user->is_admin ); } - if ( $args{format} eq 'plain' || $args{format} eq 'html' ) { - print_header( %headers, type => "text/$args{format}" ); + if ( $safe_args{format} eq 'plain' || $safe_args{format} eq 'html' ) { + print_header( %headers, type => "text/$safe_args{format}" ); for ( @data ) { unless ( ref ) { utf8::encode($_); @@ -1880,7 +2271,7 @@ if ( my $p = $router->match( sprintf("%s:%s", $ENV{REQUEST_METHOD}, $uri )) ) { print encode_json( $_ ); } } - } elsif ( $args{format} eq 'json' ) { + } elsif ( $safe_args{format} eq 'json' ) { print_header( %headers, type => "application/json" ); for ( @data ) { unless ( ref ) { @@ -1890,10 +2281,10 @@ if ( my $p = $router->match( sprintf("%s:%s", $ENV{REQUEST_METHOD}, $uri )) ) { print encode_json( $_ ); } } - } elsif ( $args{format} eq 'other' ) { + } elsif ( $safe_args{format} eq 'other' ) { print_header( %headers, type => 'application/octet-stream', - $args{filename} ? ('Content-Disposition' => "attachment; filename=$args{filename}") : (), + $safe_args{filename} ? ('Content-Disposition' => "attachment; filename=$safe_args{filename}") : (), ); for ( @data ) { unless ( ref ) { @@ -1903,14 +2294,14 @@ if ( my $p = $router->match( sprintf("%s:%s", $ENV{REQUEST_METHOD}, $uri )) ) { print encode_json( $_ ); } } - } elsif ( $args{format} eq 'qrcode' ) { + } elsif ( $safe_args{format} eq 'qrcode' ) { print_header( %headers, type => 'image/svg+xml', ); my $data = join('', @data); my $result = qrencode($data, format => 'SVG'); print $result->{data} if $result->{success}; - } elsif ( $args{format} eq 'qrcode_png' ) { + } elsif ( $safe_args{format} eq 'qrcode_png' ) { print_header( %headers, type => 'image/png', ); @@ -1960,14 +2351,99 @@ sub get_service_id { print_json( { status => 400, error => sprintf("`%s` not present", $service->get_table_key ) } ); exit 0; } + return $service_id; +} + +# Validate %args against a params schema defined in the route. +# Schema format (per field): +# type => 'integer' | 'number' | 'string' | 'email' | 'boolean' +# required => 1 (field must be present and non-empty) +# min / max => numeric bounds (for integer/number) +# min_length => minimum string length +# max_length => maximum string length +# pattern => regex the value must match (string) +# enum => arrayref of allowed values +# +# Returns undef on success, or an error string on the first failing field. +sub validate_params { + my ( $schema, $args, $allowed_input_fields ) = @_; + + for my $field ( sort keys %{ $schema } ) { + my $rule = $schema->{ $field }; + my $value = $args->{ $field }; + my $type = $rule->{type} // 'string'; - my @ids = ( $service_id ); + # presence check + if ( $rule->{required} && ( !defined $value || $value eq '' ) ) { + return sprintf( "Field required: %s", $field ); + } + + # Skip remaining checks only when field is truly absent. + # Empty string is treated as provided value and must be validated. + next unless defined $value; - if ( my $key2 = $service->get_table_key2 ) { - push @ids, $args{ $key2 }; + # type coercion / check + if ( $type eq 'integer' ) { + return sprintf( "Field '%s' must be an integer", $field ) + unless $value =~ /^-?\d+$/; + $value = int($value); + $args->{ $field } = $value; + return sprintf( "Field '%s' must be >= %s", $field, $rule->{min} ) + if defined $rule->{min} && $value < $rule->{min}; + return sprintf( "Field '%s' must be <= %s", $field, $rule->{max} ) + if defined $rule->{max} && $value > $rule->{max}; + } + elsif ( $type eq 'number' ) { + return sprintf( "Field '%s' must be a number", $field ) + unless $value =~ /^-?(?:\d+\.?\d*|\.\d+)$/; + $value = $value + 0; + $args->{ $field } = $value; + return sprintf( "Field '%s' must be >= %s", $field, $rule->{min} ) + if defined $rule->{min} && $value < $rule->{min}; + return sprintf( "Field '%s' must be <= %s", $field, $rule->{max} ) + if defined $rule->{max} && $value > $rule->{max}; + } + elsif ( $type eq 'boolean' ) { + # Normalize JSON booleans (true → 1, false → 0) and string variants + if ( ref $value ) { + $value = $value ? 1 : 0; + $args->{ $field } = $value; + } elsif ( $value eq 'true' || $value eq 'false' ) { + $value = $value eq 'true' ? 1 : 0; + $args->{ $field } = $value; + } + return sprintf( "Field '%s' must be 0 or 1", $field ) + unless $value =~ /^[01]$/; + } + elsif ( $type eq 'email' ) { + return sprintf( "Field '%s' must be a valid email address", $field ) + unless is_email($value) && length($value) <= 254; + } + elsif ( $type eq 'object' ) { + return sprintf( "Field '%s' must be an object", $field ) + unless ref($value) eq 'HASH'; + } + elsif ( $type eq 'string' ) { + if ( defined $rule->{min_length} && length($value) < $rule->{min_length} ) { + return sprintf( "Field '%s' must be at least %d characters", $field, $rule->{min_length} ); + } + if ( defined $rule->{max_length} && length($value) > $rule->{max_length} ) { + return sprintf( "Field '%s' must be at most %d characters", $field, $rule->{max_length} ); + } + if ( defined $rule->{pattern} && $value !~ /$rule->{pattern}/ ) { + return sprintf( "Field '%s' has an invalid format", $field ); + } + } + + # enum check (applicable to any type) + if ( my $enum = $rule->{enum} ) { + my %allowed = map { $_ => 1 } @{ $enum }; + return sprintf( "Field '%s' must be one of: %s", $field, join(', ', @{ $enum }) ) + unless $allowed{ $value }; + } } - return @ids; + return undef; } # exit 0; From dbf84bc2816ef99d8029c8573fcc9cfe8e07e588 Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Sat, 4 Jul 2026 16:21:45 +0300 Subject: [PATCH 07/13] dnk: SEC: fix v1 params --- app/public_html/shm/v1.cgi | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/public_html/shm/v1.cgi b/app/public_html/shm/v1.cgi index ff0e7bf3..b23460b4 100755 --- a/app/public_html/shm/v1.cgi +++ b/app/public_html/shm/v1.cgi @@ -857,6 +857,13 @@ state $routes //= { swagger => { summary => 'Добавить аккаунт' }, }, POST => { + params => { + login => { type => 'string', required => 1, min_length => 1, max_length => 128 }, + type => { type => 'string', required => 1, min_length => 1, max_length => 32 }, + user_id => { type => 'integer', required => 1, min => 1 }, + setting => { type => 'object' }, + primary => { type => 'boolean' }, + }, controller => 'User::Logins', swagger => { summary => 'Изменить аккаунт' }, }, @@ -1269,11 +1276,9 @@ state $routes //= { '/admin/template' => { swagger => { tags => 'Шаблоны' }, GET => { - params => { - id => { type => 'string', min => 1 }, - }, controller => 'Template', method => 'list', + common_params => 1, swagger => { summary => 'Список шаблонов' }, }, PUT => { @@ -1304,11 +1309,9 @@ state $routes //= { swagger => { tags => 'Шаблоны' }, splat_to => 'id', GET => { - params => { - id => { type => 'string', required => 1, min => 1 }, - }, controller => 'Template', method => 'parse_for_api', + common_params => 1, args => { format => 'plain', do_not_parse => 1, From 6877491f2ea3eb360f4d810ea0b48c23702e3880 Mon Sep 17 00:00:00 2001 From: Bkeenke Date: Sat, 4 Jul 2026 18:59:44 +0300 Subject: [PATCH 08/13] =?UTF-8?q?bk:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20OAuth2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/Core/Auth/Base.pm | 137 +++++++ app/lib/Core/Auth/OAuth2.pm | 618 +++++++++++++++++++++++++++++ app/lib/Core/Auth/OAuth2/GitHub.pm | 51 +++ app/lib/Core/Auth/OAuth2/Google.pm | 35 ++ app/lib/Core/Auth/OAuth2/Yandex.pm | 52 +++ app/lib/Core/Transport/Telegram.pm | 26 +- app/public_html/shm/v1.cgi | 109 ++++- 7 files changed, 1017 insertions(+), 11 deletions(-) create mode 100644 app/lib/Core/Auth/Base.pm create mode 100644 app/lib/Core/Auth/OAuth2.pm create mode 100644 app/lib/Core/Auth/OAuth2/GitHub.pm create mode 100644 app/lib/Core/Auth/OAuth2/Google.pm create mode 100644 app/lib/Core/Auth/OAuth2/Yandex.pm diff --git a/app/lib/Core/Auth/Base.pm b/app/lib/Core/Auth/Base.pm new file mode 100644 index 00000000..fe6c373f --- /dev/null +++ b/app/lib/Core/Auth/Base.pm @@ -0,0 +1,137 @@ +package Core::Auth::Base; + +use v5.14; +use parent 'Core::Base'; +use Core::Base; +use Core::System::ServiceManager qw( get_service ); +use URI::Escape qw( uri_escape ); + +sub provider_names { + my $self = shift; + my @providers = @_ ? @_ : qw(google yandex github); + return sort grep { $self->can("${_}_config") } @providers; +} + +sub provider_config { + my $self = shift; + my $provider = shift || return undef; + my $method = "${provider}_config"; + return $self->can($method) ? $self->$method() : undef; +} + +sub normalize_claims { + my $self = shift; + my $provider = shift; + my $raw = shift || {}; + my $method = "${provider}_normalize_claims"; + return $self->can($method) ? $self->$method($raw) : {}; +} + +sub provider_fields { + my $self = shift; + my $provider = shift; + my $cfg = $self->can('oauth2_config') ? $self->oauth2_config($provider) : {}; + $cfg = $self->can('oidc_config') ? $self->oidc_config($provider) : $cfg if ref($cfg) ne 'HASH'; + + my $fields = $cfg->{fields}; + return ref $fields eq 'ARRAY' && @$fields ? $fields : ['email']; +} + +sub provider_scope { + my $self = shift; + my $provider = shift; + + my $pcfg = $self->provider_config($provider) || return ''; + my %requested = map { $_ => 1 } @{ $self->provider_fields($provider) }; + $requested{email} = 1; # email always required + + my @scopes = ( $pcfg->{base_scope} || () ); + for my $field ( sort keys %requested ) { + push @scopes, $pcfg->{field_scopes}{$field} if $pcfg->{field_scopes}{$field}; + } + return join(' ', grep { length } @scopes); +} + +sub http_transport { + my $self = shift; + return $self->{http_transport} ||= get_service('Transport::Http'); +} + +sub callback_url { + my $self = shift; + my $provider = shift; + my $prefix = shift || 'oauth2'; + + my $scheme = $ENV{HTTP_X_FORWARDED_PROTO} + || ( ($ENV{HTTPS} || '') =~ /^(1|on)$/i ? 'https' : 'http' ); + my $host = $ENV{HTTP_X_FORWARDED_HOST} || $ENV{HTTP_HOST} || 'localhost'; + + return sprintf('%s://%s/shm/v1/%s/callback/%s', $scheme, $host, $prefix, $provider); +} + +sub auth_state_cache_key { + my $self = shift; + my $state = shift; + my $prefix = shift || 'auth_state'; + return sprintf('%s_%s', $prefix, $state || ''); +} + +sub resolve_callback_state { + my $self = shift; + my $args = shift || {}; + my $prefix = shift || 'auth_state'; + + my $state = $args->{state}; + return $args unless $state; + + my $ctx = cache->get_json( $self->auth_state_cache_key( $state, $prefix ) ); + return $args unless ref $ctx eq 'HASH'; + + $args->{expected_state} //= $ctx->{state}; + $args->{code_verifier} //= $ctx->{code_verifier}; + $args->{nonce} //= $ctx->{nonce}; + $args->{provider} //= $ctx->{provider}; + $args->{redirect_uri} //= $ctx->{redirect_uri}; + $args->{return_url} //= $ctx->{return_url} if defined $ctx->{return_url}; + $args->{register_if_not_exists} = $ctx->{register_if_not_exists} + if !$args->{register_if_not_exists} && defined $ctx->{register_if_not_exists}; + $args->{bind_to_profile} = $ctx->{bind_to_profile} + if !$args->{bind_to_profile} && defined $ctx->{bind_to_profile}; + $args->{bind_only_if_new} = $ctx->{bind_only_if_new} + if !$args->{bind_only_if_new} && defined $ctx->{bind_only_if_new}; + $args->{session_id} //= $ctx->{session_id} if defined $ctx->{session_id}; + $args->{device_id} //= $ctx->{device_id} if defined $ctx->{device_id}; + + cache->delete( $self->auth_state_cache_key( $state, $prefix ) ); + return $args; +} + +sub build_callback_redirect_url { + my $self = shift; + my $result = shift; + my $return_url = shift; + my $default_error = shift || 'Authentication failed'; + my $status_key = shift || 'oidc_status'; + + return undef unless $return_url; + + my %query; + if ( ref $result eq 'HASH' && $result->{session_id} ) { + %query = ( $status_key => 'success', session_id => $result->{session_id} ); + } elsif ( ref $result eq 'HASH' && ( $result->{error} || '' ) =~ /already linked/i ) { + %query = ( $status_key => 'already_exists', error => $result->{error} ); + } elsif ( ref $result eq 'HASH' && ( $result->{error} || '' ) =~ /Already\s+bound/i ) { + %query = ( $status_key => 'already_bound', error => $result->{error} ); + } elsif ( ref $result eq 'HASH' && ( $result->{msg} || '' ) =~ /Successfully\s+bound/i ) { + %query = ( $status_key => 'success', msg => $result->{msg} ); + } elsif ( ref $result eq 'HASH' && $result->{error} ) { + %query = ( $status_key => 'error', error => $result->{error} ); + } else { + %query = ( $status_key => 'error', error => $default_error ); + } + + my $sep = ( $return_url =~ /\?/ ) ? '&' : '?'; + return $return_url . $sep . join('&', map { uri_escape($_) . '=' . uri_escape($query{$_}) } sort keys %query); +} + +1; diff --git a/app/lib/Core/Auth/OAuth2.pm b/app/lib/Core/Auth/OAuth2.pm new file mode 100644 index 00000000..6428ea82 --- /dev/null +++ b/app/lib/Core/Auth/OAuth2.pm @@ -0,0 +1,618 @@ +package Core::Auth::OAuth2; + +use v5.14; +use parent 'Core::Auth::Base', 'Core::Auth::OAuth2::Google', 'Core::Auth::OAuth2::Yandex', 'Core::Auth::OAuth2::GitHub'; +use Core::Base; +use SHM qw( validate_session ); +use Core::System::ServiceManager qw( get_service logger ); +use Core::Utils qw( + switch_user + encode_json + decode_json + encode_base64url + decode_base64url + random_base64url + jwt_decode + passgen + sha256 + print_header + print_json + hash_merge +); +use URI::Escape qw( uri_escape ); + +sub oauth2_config { + my $self = shift; + my $provider = shift; + my $cfg = cfg('oauth2') || {}; + return $cfg->{$provider} || {}; +} + +sub client_id { + my $self = shift; + my $provider = shift; + return $self->oauth2_config( $provider )->{client_id}; +} + +sub client_secret { + my $self = shift; + my $provider = shift; + return $self->oauth2_config( $provider )->{client_secret}; +} + +sub oauth2_callback_url { + my $self = shift; + my $provider = shift; + return $self->callback_url( $provider, 'oauth2' ); +} + +sub oauth2_state_cache_key { + my $self = shift; + my $state = shift; + return $self->auth_state_cache_key( $state, 'oauth2_state' ); +} + +sub oauth2_init { + my $self = shift; + my %args = ( + provider => undef, + redirect_uri => undef, + return_url => undef, + bind_to_profile => 0, + bind_only_if_new => 0, + session_id => undef, + ttl => 600, + @_, + ); + + my $provider = $args{provider}; + my $pcfg = $self->provider_config( $provider ); + unless ( $pcfg ) { + report->status( 400 ); + report->error("Unknown OAuth2 provider: " . ( $provider // '' )); + return undef; + } + + my $client_id = $self->client_id( $provider ); + unless ( $client_id ) { + report->status( 400 ); + report->error("OAuth2 client_id is not configured for provider: $provider"); + return undef; + } + + my $redirect_uri = $args{redirect_uri} || $self->oauth2_callback_url( $provider ); + + my $state = random_base64url(32); + my $nonce = random_base64url(32); + my $code_verifier = random_base64url(32); + my $code_challenge = encode_base64url( sha256($code_verifier) ); + my $device_id = random_base64url(16); + + my $ctx = { + state => $state, + nonce => $nonce, + code_verifier => $code_verifier, + device_id => $device_id, + provider => $provider, + redirect_uri => $redirect_uri, + ( defined $args{return_url} ? ( return_url => $args{return_url} ) : () ), + bind_to_profile => $args{bind_to_profile} ? 1 : 0, + bind_only_if_new => $args{bind_only_if_new} ? 1 : 0, + ( defined $args{session_id} ? ( session_id => $args{session_id} ) : () ), + }; + + cache->set_json( $self->oauth2_state_cache_key($state), $ctx, $args{ttl} ); + + my $scope = $self->provider_scope( $provider ); + my $auth_url = sprintf( + '%s?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=S256', + $pcfg->{authorize_endpoint}, + uri_escape($client_id), + uri_escape($redirect_uri), + uri_escape($scope), + uri_escape($state), + uri_escape($nonce), + uri_escape($code_challenge), + ); + $auth_url .= '&device_id=' . uri_escape($device_id) if $pcfg->{requires_device_id}; + + return { + auth_url => $auth_url, + state => $state, + nonce => $nonce, + code_challenge => $code_challenge, + code_challenge_method => 'S256', + redirect_uri => $redirect_uri, + expires_in => int($args{ttl}), + }; +} + +sub oauth2_start_redirect { + my $self = shift; + my %args = @_; + + my $payload = $self->oauth2_init(%args); + return undef unless $payload; + + my $auth_url = $payload->{auth_url}; + unless ( $auth_url ) { + report->error('OAuth2 auth_url is empty'); + return undef; + } + + if ( $ENV{SHM_TEST} ) { + return { + status => 302, + redirect => $auth_url, + %{$payload}, + }; + } + + print_header(status => 302, Location => $auth_url); + print_json({ status => 302, redirect => $auth_url }); + exit 0; +} + +sub oauth2_exchange_code { + my $self = shift; + my %args = ( + provider => undef, + code => undef, + redirect_uri => undef, + code_verifier => undef, + device_id => undef, + @_, + ); + + my $provider = $args{provider}; + my $pcfg = $self->provider_config( $provider ) || return undef; + + for my $required ( qw(code redirect_uri) ) { + unless ( defined $args{$required} && $args{$required} ne '' ) { + report->error("OAuth2 $required is required"); + return undef; + } + } + + my $client_id = $self->client_id( $provider ); + my $client_secret = $self->client_secret( $provider ); + unless ( $client_id && $client_secret ) { + report->error("OAuth2 client_id/client_secret is not configured for provider: $provider"); + return undef; + } + + my %form = ( + grant_type => 'authorization_code', + code => $args{code}, + redirect_uri => $args{redirect_uri}, + client_id => $client_id, + client_secret => $client_secret, + ); + $form{code_verifier} = $args{code_verifier} if defined $args{code_verifier} && $args{code_verifier} ne ''; + if ( $pcfg->{requires_device_id} ) { + unless ( defined $args{device_id} && $args{device_id} ne '' ) { + report->error("OAuth2 device_id is required ($provider)"); + return undef; + } + $form{device_id} = $args{device_id}; + } + + my $content = join '&', map { + uri_escape($_) . '=' . uri_escape( defined $form{$_} ? $form{$_} : '' ) + } sort keys %form; + + my $response = $self->http_transport->http( + method => 'post', + url => $pcfg->{token_endpoint}, + content_type => 'application/x-www-form-urlencoded', + headers => { Accept => 'application/json' }, + content => $content, + ); + + my $body = $response->decoded_content; + my $json = eval { decode_json($body) }; + + my $has_error = ( ref $json eq 'HASH' && $json->{error} ); + if ( !$response->is_success || $has_error ) { + my $error = ref $json eq 'HASH' ? ( $json->{error_description} || $json->{error} || $body ) : $body; + report->error("OAuth2 token exchange failed ($provider): $error"); + return undef; + } + + unless ( ref $json eq 'HASH' && ( $json->{access_token} || $json->{id_token} ) ) { + report->error("OAuth2 token response is invalid ($provider)"); + return undef; + } + + return $json; +} + +sub oauth2_jwks { + my $self = shift; + my $provider = shift; + my $pcfg = $self->provider_config( $provider ) || return []; + + state %cache_by_provider; + my $c = $cache_by_provider{$provider} ||= { fetched_at => 0, keys => [] }; + + my $ttl = 3600; + if ( time - $c->{fetched_at} < $ttl && ref $c->{keys} eq 'ARRAY' && @{ $c->{keys} } ) { + return $c->{keys}; + } + + my $response = $self->http_transport->http( + method => 'get', + url => $pcfg->{jwks_uri}, + ); + unless ( $response->is_success ) { + report->error("OAuth2 jwks request failed ($provider)"); + return []; + } + + my $json = decode_json( $response->decoded_content ) || {}; + my $keys = $json->{keys}; + unless ( ref $keys eq 'ARRAY' && @$keys ) { + report->error("OAuth2 jwks response is invalid ($provider)"); + return []; + } + + $c->{fetched_at} = time; + $c->{keys} = $keys; + + return $c->{keys}; +} + +sub oauth2_verify_id_token { + my $self = shift; + my %args = ( + provider => undef, + id_token => undef, + nonce => undef, + @_, + ); + + my $provider = $args{provider}; + my $pcfg = $self->provider_config( $provider ) || return undef; + my $id_token = $args{id_token}; + return undef unless $id_token; + + my ($header_b64) = split /\./, $id_token; + my $header = decode_json( decode_base64url( $header_b64 ) ) || {}; + my $kid = $header->{kid}; + + my $client_id = $self->client_id( $provider ); + unless ( $client_id ) { + report->error("OAuth2 client_id is not configured for provider: $provider"); + return undef; + } + + my $jwks = $self->oauth2_jwks( $provider ); + my @keys = grep { ref $_ eq 'HASH' && ( !$kid || ( $_->{kid} || '' ) eq $kid ) } @{ $jwks || [] }; + @keys = @{ $jwks || [] } unless @keys; + + my $claims; + for my $jwk ( @keys ) { + next unless ref $jwk eq 'HASH'; + + my $decoded; + my $ok = eval { + $decoded = jwt_decode( + token => $id_token, + key => $jwk, + accepted_alg => ['RS256'], + verify_exp => 0, + verify_iat => 0, + verify_nbf => 0, + ); + 1; + }; + + if ( $ok ) { + $claims = $decoded; + last; + } + } + + unless ( ref $claims eq 'HASH' ) { + report->error("OAuth2 id_token signature verification failed ($provider)"); + return undef; + } + + unless ( ( $claims->{iss} || '' ) eq $pcfg->{issuer} ) { + report->error("OAuth2 id_token has invalid iss ($provider)"); + return undef; + } + + my $aud = $claims->{aud}; + my $aud_ok = ref $aud eq 'ARRAY' + ? scalar( grep { defined $_ && "$_" eq "$client_id" } @$aud ) + : ( defined $aud && "$aud" eq "$client_id" ); + unless ( $aud_ok ) { + report->error("OAuth2 id_token has invalid aud ($provider)"); + return undef; + } + + my $now = time; + if ( !$claims->{exp} || $claims->{exp} < $now ) { + report->error("OAuth2 id_token is expired ($provider)"); + return undef; + } + if ( $claims->{iat} && $claims->{iat} > $now + 60 ) { + report->error("OAuth2 id_token has invalid iat ($provider)"); + return undef; + } + if ( defined $args{nonce} && $args{nonce} ne '' ) { + unless ( defined $claims->{nonce} && $claims->{nonce} eq $args{nonce} ) { + report->error("OAuth2 id_token has invalid nonce ($provider)"); + return undef; + } + } + + return $claims; +} + +sub oauth2_fetch_userinfo { + my $self = shift; + my %args = ( + provider => undef, + access_token => undef, + @_, + ); + + my $provider = $args{provider}; + my $pcfg = $self->provider_config( $provider ) || return undef; + return undef unless $args{access_token}; + + my $response; + if ( ( $pcfg->{userinfo_auth} || '' ) eq 'client_params' ) { + my $client_id = $self->client_id( $provider ); + my $content = join '&', + 'client_id=' . uri_escape($client_id||''), + 'access_token=' . uri_escape($args{access_token}); + $response = $self->http_transport->http( + method => 'post', + url => $pcfg->{userinfo_endpoint}, + content_type => 'application/x-www-form-urlencoded', + content => $content, + ); + } elsif ( ( $pcfg->{userinfo_auth} || '' ) eq 'bearer' ) { + $response = $self->http_transport->http( + method => 'get', + url => $pcfg->{userinfo_endpoint}, + headers => { + Authorization => "Bearer $args{access_token}", + Accept => 'application/json', + ( $pcfg->{user_agent} ? ( 'User-Agent' => $pcfg->{user_agent} ) : () ), + }, + ); + } else { + $response = $self->http_transport->http( + method => 'get', + url => $pcfg->{userinfo_endpoint} . '?format=json', + headers => { Authorization => "OAuth $args{access_token}" }, + ); + } + + my $json = eval { decode_json( $response->decoded_content ) }; + + if ( !$response->is_success || ( ref $json eq 'HASH' && $json->{error} ) ) { + my $error = ref $json eq 'HASH' ? ( $json->{error_description} || $json->{error} || '' ) : ''; + report->error("OAuth2 userinfo request failed ($provider): $error"); + return undef; + } + + unless ( ref $json eq 'HASH' ) { + report->error("OAuth2 userinfo response is invalid ($provider)"); + return undef; + } + + if ( !$json->{email} && $pcfg->{emails_endpoint} ) { + my $emails_response = $self->http_transport->http( + method => 'get', + url => $pcfg->{emails_endpoint}, + headers => { + Authorization => "Bearer $args{access_token}", + Accept => 'application/json', + ( $pcfg->{user_agent} ? ( 'User-Agent' => $pcfg->{user_agent} ) : () ), + }, + ); + my $emails = eval { decode_json( $emails_response->decoded_content ) }; + $json->{__emails} = $emails if ref $emails eq 'ARRAY'; + } + + return $json; +} + +sub oauth2_callback { + my $self = shift; + my %args = ( + provider => undef, + bind_to_profile => 0, + bind_only_if_new => 0, + session_id => undef, + device_id => undef, + @_, + ); + + my $provider = $args{provider}; + my $pcfg = $self->provider_config( $provider ); + unless ( $pcfg ) { + report->status( 400 ); + report->error("Unknown OAuth2 provider: " . ( $provider // '' )); + return undef; + } + + my $uid; + if ( $args{bind_to_profile} ) { + my $session = validate_session( session_id => $args{session_id} ); + unless ( $session ) { + report->status( 401 ); + report->error('A valid session is required to bind an account'); + return undef; + } + $uid = $session->user_id; + } + + if ( $args{expected_state} && defined $args{state} && $args{state} ne $args{expected_state} ) { + report->error('OAuth2 state mismatch'); + $self->set_user_fail_attempt( 'oauth2_callback', 3600 ); + return undef; + } + + my $claims_raw; + my $access_token; + + if ( $args{code} ) { + my $tokens = $self->oauth2_exchange_code( + provider => $provider, + code => $args{code}, + redirect_uri => $args{redirect_uri}, + code_verifier => $args{code_verifier}, + device_id => $args{device_id}, + ); + return undef unless $tokens; + + $access_token = $tokens->{access_token}; + + if ( $pcfg->{verify_mode} eq 'id_token' ) { + return undef unless $tokens->{id_token}; + $claims_raw = $self->oauth2_verify_id_token( + provider => $provider, + id_token => $tokens->{id_token}, + nonce => $args{nonce}, + ); + return undef unless $claims_raw; + } else { + return undef unless $access_token; + $claims_raw = $self->oauth2_fetch_userinfo( + provider => $provider, + access_token => $access_token, + ); + return undef unless $claims_raw; + } + } else { + report->error('OAuth2 code is required'); + return undef; + } + + my $claims = $self->normalize_claims( $provider, $claims_raw ); + my $email = $claims->{email}{address}; + unless ( $email ) { + report->error("OAuth2 claims have no email ($provider)"); + return undef; + } + + if ( $uid && $self->user->id($uid) ) { + switch_user( $uid ); + + my $existing = $self->user->logins->id( $email, ['email', 'login'] ); + if ( $existing && $existing->get_user_id ne $uid ) { + return { error => 'Email already linked to another user' }; + } + my $existing_provider = $self->user->logins->id( $email, [$self->oauth2_login_type($provider)] ); + my $already_bound = $existing_provider && $self->oauth2_account_is_linked( $existing_provider->get_settings || {} ); + + $self->save_oauth2_account( $self->user, $provider, $claims ); + return { error => "Already bound to $provider" } if $already_bound; + return { msg => "Successfully bound to $provider" }; + } + + my $login_obj = $self->user->logins->id( $email, ['email', 'login'] ); + my $provider_login_obj = $self->user->logins->id( $email, [$self->oauth2_login_type($provider)] ); + + my $user; + if ( $login_obj ) { + my $settings = $provider_login_obj ? ( $provider_login_obj->get_settings || {} ) : {}; + unless ( $self->oauth2_account_is_linked( $settings ) ) { + return { error => "Account with this email exists but is not linked to $provider yet. Log in and link your $provider account from your profile first." }; + } + $user = $self->user->id( $login_obj->get_user_id ); + $self->save_oauth2_account( $user, $provider, $claims ); + } else { + $user = $self->user->reg( + login => $email, + login_type => 'email', + password => passgen(), + full_name => $claims->{name} || '', + ); + if ( $user && $claims->{email}{verified} ) { + my $email_login = $user->logins->id( $email, ['email'] ); + $email_login->set_settings( hash_merge( $email_login->get_settings || {}, { email => { verified => 1 } } ) ) if $email_login; + } + $self->save_oauth2_account( $user, $provider, $claims ) if $user; + } + + unless ( $user ) { + logger->error("OAuth2 auth error ($provider): user not found"); + return undef; + } + + return { + session_id => $user->srv('sessions')->add(), + }; +} + +sub oauth2_login_type { + my $self = shift; + my $provider = shift; + return "${provider}_oauth2"; +} + +sub oauth2_account_is_linked { + my $self = shift; + my $settings = shift || {}; + return 1 if ref($settings) eq 'HASH' && $settings->{email}; + return 0; +} + +sub save_oauth2_account { + my $self = shift; + my $user = shift; + my $provider = shift; + my $claims = shift; + my $email = $claims->{email}{address}; + return unless $user && $email; + + my $login_obj = $user->logins->id( $email, [$self->oauth2_login_type($provider)] ); + + if ( $login_obj ) { + $login_obj->set_settings( hash_merge( $login_obj->get_settings || {}, $claims ) ); + } else { + $user->logins->add( login => $email, type => $self->oauth2_login_type($provider), settings => $claims ); + } +} + +sub oauth2_callback_redirect { + my $self = shift; + my %args = ( + provider => undef, + bind_to_profile => 0, + bind_only_if_new => 0, + @_, + ); + + %args = %{ $self->resolve_callback_state( \%args, 'oauth2_state' ) }; + $args{redirect_uri} ||= $self->oauth2_callback_url( $args{provider} ); + + my $result = $self->oauth2_callback( %args ); + + my $return_url = $args{return_url}; + return $result unless $return_url; + + my $redirect_url = $self->build_callback_redirect_url( + $result, + $return_url, + 'OAuth2 authentication failed', + 'oauth2_status', + ); + + if ( $ENV{SHM_TEST} ) { + return { status => 302, redirect => $redirect_url }; + } + + print_header( status => 302, Location => $redirect_url ); + print_json({ status => 302, redirect => $redirect_url }); + exit 0; +} + +1; diff --git a/app/lib/Core/Auth/OAuth2/GitHub.pm b/app/lib/Core/Auth/OAuth2/GitHub.pm new file mode 100644 index 00000000..818c42fa --- /dev/null +++ b/app/lib/Core/Auth/OAuth2/GitHub.pm @@ -0,0 +1,51 @@ +package Core::Auth::OAuth2::GitHub; + +use v5.14; +use Core::Base; + +sub github_config { + return { + authorize_endpoint => 'https://github.com/login/oauth/authorize', + token_endpoint => 'https://github.com/login/oauth/access_token', + userinfo_endpoint => 'https://api.github.com/user', + emails_endpoint => 'https://api.github.com/user/emails', + verify_mode => 'userinfo', + userinfo_auth => 'bearer', + user_agent => 'SHM', + base_scope => 'read:user', + field_scopes => { email => 'user:email', profile => 'read:user' }, + }; +} + +sub github_normalize_claims { + my $self = shift; + my $raw = shift || {}; + + my $email = $raw->{email}; + my $verified = $email ? 1 : 0; + + if ( !$email && ref $raw->{__emails} eq 'ARRAY' ) { + my ($primary) = grep { ref($_) eq 'HASH' && $_->{primary} } @{ $raw->{__emails} }; + $primary ||= $raw->{__emails}[0]; + if ( ref $primary eq 'HASH' ) { + $email = $primary->{email}; + $verified = $primary->{verified} ? 1 : 0; + } + } + + my ( $first_name, $last_name ) = split /\s+/, ( $raw->{name} || '' ), 2; + + return { + subject => $raw->{id}, + email => { + address => $email, + verified => $verified, + }, + name => $raw->{name} || $raw->{login}, + first_name => $first_name, + last_name => $last_name, + picture => $raw->{avatar_url}, + }; +} + +1; diff --git a/app/lib/Core/Auth/OAuth2/Google.pm b/app/lib/Core/Auth/OAuth2/Google.pm new file mode 100644 index 00000000..8665a495 --- /dev/null +++ b/app/lib/Core/Auth/OAuth2/Google.pm @@ -0,0 +1,35 @@ +package Core::Auth::OAuth2::Google; + +use v5.14; +use Core::Base; + +sub google_config { + return { + issuer => 'https://accounts.google.com', + authorize_endpoint => 'https://accounts.google.com/o/oauth2/v2/auth', + token_endpoint => 'https://oauth2.googleapis.com/token', + jwks_uri => 'https://www.googleapis.com/oauth2/v3/certs', + verify_mode => 'id_token', + base_scope => 'openid', + field_scopes => { email => 'email', profile => 'profile' }, + }; +} + +sub google_normalize_claims { + my $self = shift; + my $raw = shift || {}; + + return { + subject => $raw->{sub}, + email => { + address => $raw->{email}, + verified => $raw->{email_verified} ? 1 : 0, + }, + name => $raw->{name}, + first_name => $raw->{given_name}, + last_name => $raw->{family_name}, + picture => $raw->{picture}, + }; +} + +1; diff --git a/app/lib/Core/Auth/OAuth2/Yandex.pm b/app/lib/Core/Auth/OAuth2/Yandex.pm new file mode 100644 index 00000000..ac1248d1 --- /dev/null +++ b/app/lib/Core/Auth/OAuth2/Yandex.pm @@ -0,0 +1,52 @@ +package Core::Auth::OAuth2::Yandex; + +use v5.14; +use Core::Base; + +sub yandex_config { + return { + authorize_endpoint => 'https://oauth.yandex.ru/authorize', + token_endpoint => 'https://oauth.yandex.ru/token', + userinfo_endpoint => 'https://login.yandex.ru/info', + verify_mode => 'userinfo', + base_scope => 'login:email', + field_scopes => { + email => 'login:email', + profile => 'login:info', + avatar => 'login:avatar', + birthday => 'login:birthday', + phone => 'login:default_phone', + }, + }; +} + +sub yandex_normalize_claims { + my $self = shift; + my $raw = shift || {}; + + my $email = $raw->{default_email} + || $raw->{email} + || ( ref $raw->{emails} eq 'ARRAY' ? $raw->{emails}[0] : undef ) + || $raw->{defaultEmail}; + + my $avatar = ( !$raw->{is_avatar_empty} && $raw->{default_avatar_id} ) + ? sprintf('https://avatars.yandex.net/get-yapic/%s/islands-200', $raw->{default_avatar_id}) + : undef; + my $phone = ref $raw->{default_phone} eq 'HASH' ? $raw->{default_phone}{number} : undef; + + return { + subject => $raw->{id} || $raw->{uid}, + email => { + address => $email, + verified => 1, + }, + name => $raw->{real_name} || $raw->{display_name} || $raw->{first_name} || $raw->{last_name}, + first_name => $raw->{first_name}, + last_name => $raw->{last_name}, + picture => $avatar, + phone => $phone, + birthday => $raw->{birthday}, + }; +} + +1; diff --git a/app/lib/Core/Transport/Telegram.pm b/app/lib/Core/Transport/Telegram.pm index 107a30eb..99035833 100644 --- a/app/lib/Core/Transport/Telegram.pm +++ b/app/lib/Core/Transport/Telegram.pm @@ -6,6 +6,7 @@ use v5.14; use utf8; use Core::Base; use Core::Const; +use SHM qw( validate_session ); use Core::System::ServiceManager qw( get_service logger ); use Core::Utils qw( switch_user @@ -742,7 +743,7 @@ sub telegram_oidc_init { register_if_not_exists => 0, bind_to_profile => 0, bind_only_if_new => 0, - uid => undef, + session_id => undef, ttl => 600, @_, ); @@ -770,7 +771,7 @@ sub telegram_oidc_init { register_if_not_exists => $args{register_if_not_exists} ? 1 : 0, bind_to_profile => $args{bind_to_profile} ? 1 : 0, bind_only_if_new => $args{bind_only_if_new} ? 1 : 0, - ( defined $args{uid} ? ( uid => $args{uid} ) : () ), + ( defined $args{session_id} ? ( session_id => $args{session_id} ) : () ), }; cache->set_json( $self->telegram_oidc_state_cache_key($state), $ctx, $args{ttl} ); @@ -1669,10 +1670,21 @@ sub web_auth { register_if_not_exists => 0, bind_to_profile => 0, bind_only_if_new => 0, - uid => undef, + session_id => undef, @_, ); + my $uid; + if ( $args{bind_to_profile} ) { + my $session = validate_session( session_id => $args{session_id} ); + unless ( $session ) { + report->status( 401 ); + report->error('A valid session is required to bind a Telegram account'); + return undef; + } + $uid = $session->user_id; + } + my $profile = $args{profile}; my @parameters = qw( id first_name last_name username photo_url auth_date hash ); @@ -1760,12 +1772,12 @@ sub web_auth { } } - if ( $args{uid} && $self->user->id($args{uid}) ) { - switch_user( $args{uid} ); + if ( $uid && $self->user->id($uid) ) { + switch_user( $uid ); if ( $args{bind_to_profile} ) { if ( $args{bind_only_if_new} ) { my $existing_user = $self->find_user_by_tg( \%in ); - if ( $existing_user && $existing_user->{user_id} ne $args{uid} ) { + if ( $existing_user && $existing_user->{user_id} ne $uid ) { return { error => 'Telegram account already exists' }; } } @@ -1860,7 +1872,7 @@ sub web_auth_callback { $args{register_if_not_exists} = $ctx->{register_if_not_exists} if !$args{register_if_not_exists} && defined $ctx->{register_if_not_exists}; $args{bind_to_profile} = $ctx->{bind_to_profile} if !$args{bind_to_profile} && defined $ctx->{bind_to_profile}; $args{bind_only_if_new} = $ctx->{bind_only_if_new} if !$args{bind_only_if_new} && defined $ctx->{bind_only_if_new}; - $args{uid} //= $ctx->{uid} if defined $ctx->{uid}; + $args{session_id} //= $ctx->{session_id} if defined $ctx->{session_id}; cache->delete( $self->telegram_oidc_state_cache_key( $args{state} ) ); } diff --git a/app/public_html/shm/v1.cgi b/app/public_html/shm/v1.cgi index b23460b4..fa36130c 100755 --- a/app/public_html/shm/v1.cgi +++ b/app/public_html/shm/v1.cgi @@ -1729,7 +1729,7 @@ state $routes //= { register_if_not_exists => { type => 'boolean' }, bind_to_profile => { type => 'boolean' }, bind_only_if_new => { type => 'boolean' }, - uid => { type => 'integer', min => 1 }, + session_id => { type => 'integer', min => 1 }, # OIDC code flow code => { type => 'string' }, redirect_uri => { type => 'string' }, @@ -1787,7 +1787,7 @@ state $routes //= { register_if_not_exists => { type => 'boolean' }, bind_to_profile => { type => 'boolean' }, bind_only_if_new => { type => 'boolean' }, - uid => { type => 'integer', min => 1 }, + session_id => { type => 'integer', min => 1 }, ttl => { type => 'integer', min => 1 }, }, skip_check_auth => 1, @@ -1831,7 +1831,7 @@ state $routes //= { register_if_not_exists => { type => 'boolean' }, bind_to_profile => { type => 'boolean' }, bind_only_if_new => { type => 'boolean' }, - uid => { type => 'integer', min => 1 }, + session_id => { type => 'integer', min => 1 }, ttl => { type => 'integer', min => 1 }, }, skip_check_auth => 1, @@ -1857,7 +1857,7 @@ state $routes //= { register_if_not_exists => { type => 'boolean' }, bind_to_profile => { type => 'boolean' }, bind_only_if_new => { type => 'boolean' }, - uid => { type => 'integer', min => 1 }, + session_id => { type => 'integer', min => 1 }, # OIDC code flow code => { type => 'string' }, redirect_uri => { type => 'string' }, @@ -1894,6 +1894,107 @@ state $routes //= { }, }, }, +'/oauth2/init/*' => { + swagger => { tags => 'Пользователи' }, + splat_to => 'provider', + GET => { + params => { + redirect_uri => { type => 'string' }, + return_url => { type => 'string' }, + bind_to_profile => { type => 'boolean' }, + bind_only_if_new => { type => 'boolean' }, + session_id => { type => 'string' }, + ttl => { type => 'integer', min => 1 }, + }, + skip_check_auth => 1, + controller => 'Auth::OAuth2', + method => 'oauth2_init', + swagger => { + summary => 'Инициализация OAuth2-входа/привязки (Google/Yandex/VK/GitHub): state, nonce, PKCE', + responses => { + '200' => { + content => { + 'application/json' => { + schema => { + type => 'object', + properties => { + auth_url => { type => 'string' }, + state => { type => 'string' }, + nonce => { type => 'string' }, + code_challenge => { type => 'string' }, + code_challenge_method => { type => 'string' }, + redirect_uri => { type => 'string' }, + expires_in => { type => 'number' }, + }, + }, + }, + }, + }, + }, + }, + }, +}, +'/oauth2/start/*' => { + swagger => { tags => 'Пользователи' }, + splat_to => 'provider', + GET => { + params => { + redirect_uri => { type => 'string' }, + return_url => { type => 'string' }, + bind_to_profile => { type => 'boolean' }, + bind_only_if_new => { type => 'boolean' }, + session_id => { type => 'string' }, + ttl => { type => 'integer', min => 1 }, + }, + skip_check_auth => 1, + controller => 'Auth::OAuth2', + method => 'oauth2_start_redirect', + swagger => { + summary => 'Старт OAuth2-входа/привязки с HTTP redirect на провайдера', + responses => { + '302' => { description => 'Redirect to provider OAuth' }, + }, + }, + }, +}, +'/oauth2/callback/*' => { + swagger => { tags => 'Пользователи' }, + splat_to => 'provider', + GET => { + params => { + bind_to_profile => { type => 'boolean' }, + bind_only_if_new => { type => 'boolean' }, + # SECURITY: нет клиентского uid — см. комментарий в /oauth2/init/*. + session_id => { type => 'string' }, + code => { type => 'string' }, + redirect_uri => { type => 'string' }, + return_url => { type => 'string' }, + code_verifier => { type => 'string' }, + state => { type => 'string' }, + expected_state => { type => 'string' }, + }, + skip_check_auth => 1, + controller => 'Auth::OAuth2', + method => 'oauth2_callback_redirect', + swagger => { + summary => 'Callback endpoint для OAuth2-входа/привязки (Google/Yandex/VK/GitHub)', + responses => { + '200' => { + content => { + 'application/json' => { + schema => { + type => 'object', + properties => { + session_id => { type => 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, +}, '/admin/cloud/user' => { GET => { params => {}, From b3bee40930e7ce609e808787e8d2d31ccd0a5e5b Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Sun, 5 Jul 2026 01:49:23 +0300 Subject: [PATCH 09/13] =?UTF-8?q?dnk:=20Telegram=20-=20send()=20=D1=88?= =?UTF-8?q?=D0=BB=D0=B5=D1=82=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20?= =?UTF-8?q?=D1=83=D0=BA=D0=B0=D0=B7=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/Core/Transport/Telegram.pm | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/lib/Core/Transport/Telegram.pm b/app/lib/Core/Transport/Telegram.pm index 99035833..2ff87ae4 100644 --- a/app/lib/Core/Transport/Telegram.pm +++ b/app/lib/Core/Transport/Telegram.pm @@ -162,6 +162,12 @@ sub profile { my $name = shift; return $self->profile_name unless $name; + if ( my $profile_name = $self->profile_name ) { + return $profile_name if + $profile_name eq $name && + $self->{profile} eq $name && + $self->{token}; + } my $config = $self->config; @@ -298,7 +304,9 @@ sub send { ); my @ret; - my @profiles = $self->user_profiles(); + my @profiles = $self->profile_name + ? ( $self->profile_name ) + : $self->user_profiles(); for my $profile ( @profiles ) { $self->profile( $profile ); From 45daa767110ec5234621f01c2270d29dc95a311a Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Mon, 6 Jul 2026 12:28:22 +0300 Subject: [PATCH 10/13] =?UTF-8?q?dnk:=20Swagger=20-=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D1=81=20oauth=20=D0=B2=20=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/public_html/shm/v1.cgi | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/public_html/shm/v1.cgi b/app/public_html/shm/v1.cgi index fa36130c..b5d02fd5 100755 --- a/app/public_html/shm/v1.cgi +++ b/app/public_html/shm/v1.cgi @@ -1895,7 +1895,7 @@ state $routes //= { }, }, '/oauth2/init/*' => { - swagger => { tags => 'Пользователи' }, + swagger => { tags => 'oauth2' }, splat_to => 'provider', GET => { params => { @@ -1935,7 +1935,7 @@ state $routes //= { }, }, '/oauth2/start/*' => { - swagger => { tags => 'Пользователи' }, + swagger => { tags => 'oauth2' }, splat_to => 'provider', GET => { params => { @@ -1958,7 +1958,7 @@ state $routes //= { }, }, '/oauth2/callback/*' => { - swagger => { tags => 'Пользователи' }, + swagger => { tags => 'oauth2' }, splat_to => 'provider', GET => { params => { From 151edb7e005536037fd251b7086f4f3b18823ea9 Mon Sep 17 00:00:00 2001 From: Daniil Firsov Date: Mon, 6 Jul 2026 16:04:29 +0300 Subject: [PATCH 11/13] =?UTF-8?q?dnk:=20SEC=20-=20=D1=80=D0=B0=D0=B7=D1=80?= =?UTF-8?q?=D0=B0=D1=88=D0=B0=D0=B5=D0=BC=20=D0=BF=D0=BE=D0=BBe=20`format`?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20v1/template/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/public_html/shm/v1.cgi | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/public_html/shm/v1.cgi b/app/public_html/shm/v1.cgi index b5d02fd5..6d8932cf 100755 --- a/app/public_html/shm/v1.cgi +++ b/app/public_html/shm/v1.cgi @@ -521,7 +521,10 @@ state $routes //= { swagger => { tags => 'Шаблоны' }, splat_to => 'id', GET => { - params => {}, + params => { + dry_run => { type => 'boolean' }, + format => { type => 'string', enum => ['default','plain','html','json','other','qrcode','qrcode_png'] }, + }, controller => 'Template', method => 'parse_for_api', args => { @@ -544,7 +547,9 @@ state $routes //= { swagger => { tags => 'Шаблоны' }, splat_to => 'id', GET => { - params => {}, + params => { + format => { type => 'string', enum => ['default','plain','html','json','other','qrcode','qrcode_png'] }, + }, user_id => 1, controller => 'Template', method => 'parse_for_public', From 22126e6a73f73ffbca26979428945c2e85e7abf4 Mon Sep 17 00:00:00 2001 From: Bkeenke Date: Sun, 5 Jul 2026 23:46:05 +0300 Subject: [PATCH 12/13] =?UTF-8?q?bk:=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B8=D0=BD=D1=84=D1=80=D0=BE=D0=BC=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BF=D0=BE=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/Core/Config.pm | 45 ++++++++++++++++++++++++++++++++++++++ app/public_html/shm/v1.cgi | 14 ++++++++++++ 2 files changed, 59 insertions(+) diff --git a/app/lib/Core/Config.pm b/app/lib/Core/Config.pm index a7285cea..e3f97e30 100644 --- a/app/lib/Core/Config.pm +++ b/app/lib/Core/Config.pm @@ -11,6 +11,7 @@ require 'shm.conf'; use Core::Utils qw( decode_json ); +use JSON; sub table { return 'config' }; @@ -243,3 +244,47 @@ sub version_info { return $version_data; } + +sub api_data_by_auth { + my $self = shift; + my $billing = $self->data_by_name('billing'); + my $oauth2 = $self->data_by_name('oauth2') || {}; + my $telegram = $self->data_by_name('telegram') || {}; + + my %providers; + for my $provider ( keys %$oauth2 ) { + $providers{$provider} = { + enabled => $oauth2->{$provider}{enabled} ? JSON::true : JSON::false, + }; + } + + my $telegram_enabled = 0; + for my $bot ( values %$telegram ) { + next unless ref $bot eq 'HASH'; + # если хотябы у одного бота заполнены client_id, client_secret и oidc_enabled + # то считаем что авторизация по oidc включена + if ( $bot->{client_id} && $bot->{client_secret} && $bot->{oidc_enabled} ) { + $telegram_enabled = 1; + last; + } + } + + return { + auth => { + enabled => $billing->{allow_user_auth_api}, + }, + register => { + enabled => $billing->{allow_user_register_api}, + }, + captcha => { + enabled => $billing->{allow_user_register_captcha}, + }, + oauth2 => { + providers => \%providers, + }, + telegram => { + enabled => $telegram_enabled ? JSON::true : JSON::false, + }, + } + +}; diff --git a/app/public_html/shm/v1.cgi b/app/public_html/shm/v1.cgi index 6d8932cf..bda19bba 100755 --- a/app/public_html/shm/v1.cgi +++ b/app/public_html/shm/v1.cgi @@ -68,6 +68,20 @@ state $routes //= { skip_check_auth => 1, }, }, +'/system/auth' => { + swagger => { + tags => 'Авторизация', + }, + GET => { + params => {}, + skip_check_auth => 1, + controller => 'Config', + method => 'api_data_by_auth', + swagger => { + summary => 'Доступные способы входа (OAuth2-провайдеры, Telegram) и разрешения на регистрацию/вход/капчу через API', + }, + }, +}, '/user/captcha' => { swagger => { tags => 'Капча' }, GET => { From 3ed8b4aec37186d216e8f1918e1cfecd6403ccf3 Mon Sep 17 00:00:00 2001 From: Bkeenke Date: Mon, 6 Jul 2026 03:40:15 +0300 Subject: [PATCH 13/13] =?UTF-8?q?bk:=20=D1=8F=D0=B2=D0=BD=D0=BE=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=B5=D1=89=D0=B0=D0=B5=D0=BC=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20=D1=81=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=BD=D1=8B=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20OAuth2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/Core/Auth/Base.pm | 3 ++- app/lib/Core/Auth/OAuth2.pm | 32 ++++++++++++++++++++++---------- app/lib/Core/Config.pm | 9 +++++---- app/lib/Core/Const.pm | 4 ++++ 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/app/lib/Core/Auth/Base.pm b/app/lib/Core/Auth/Base.pm index fe6c373f..e26fd750 100644 --- a/app/lib/Core/Auth/Base.pm +++ b/app/lib/Core/Auth/Base.pm @@ -3,12 +3,13 @@ package Core::Auth::Base; use v5.14; use parent 'Core::Base'; use Core::Base; +use Core::Const; use Core::System::ServiceManager qw( get_service ); use URI::Escape qw( uri_escape ); sub provider_names { my $self = shift; - my @providers = @_ ? @_ : qw(google yandex github); + my @providers = @_ ? @_ : ( OAUTH2_PROVIDERS ); return sort grep { $self->can("${_}_config") } @providers; } diff --git a/app/lib/Core/Auth/OAuth2.pm b/app/lib/Core/Auth/OAuth2.pm index 6428ea82..9cec463e 100644 --- a/app/lib/Core/Auth/OAuth2.pm +++ b/app/lib/Core/Auth/OAuth2.pm @@ -28,6 +28,26 @@ sub oauth2_config { return $cfg->{$provider} || {}; } +sub require_enabled_provider { + my $self = shift; + my $provider = shift; + + my $pcfg = $self->provider_config( $provider ); + unless ( $pcfg ) { + report->status( 400 ); + report->error("Unknown OAuth2 provider: " . ( $provider // '' )); + return 0; + } + + unless ( $self->oauth2_config( $provider )->{enabled} ) { + report->status( 403 ); + report->error("OAuth2 provider is disabled: $provider"); + return 0; + } + + return 1; +} + sub client_id { my $self = shift; my $provider = shift; @@ -66,12 +86,8 @@ sub oauth2_init { ); my $provider = $args{provider}; + return undef unless $self->require_enabled_provider( $provider ); my $pcfg = $self->provider_config( $provider ); - unless ( $pcfg ) { - report->status( 400 ); - report->error("Unknown OAuth2 provider: " . ( $provider // '' )); - return undef; - } my $client_id = $self->client_id( $provider ); unless ( $client_id ) { @@ -435,12 +451,8 @@ sub oauth2_callback { ); my $provider = $args{provider}; + return undef unless $self->require_enabled_provider( $provider ); my $pcfg = $self->provider_config( $provider ); - unless ( $pcfg ) { - report->status( 400 ); - report->error("Unknown OAuth2 provider: " . ( $provider // '' )); - return undef; - } my $uid; if ( $args{bind_to_profile} ) { diff --git a/app/lib/Core/Config.pm b/app/lib/Core/Config.pm index e3f97e30..44b4d0ce 100644 --- a/app/lib/Core/Config.pm +++ b/app/lib/Core/Config.pm @@ -3,6 +3,7 @@ use v5.14; use parent 'Core::Base'; use Core::Base; +use Core::Const; our $config; our $session_config; @@ -252,7 +253,7 @@ sub api_data_by_auth { my $telegram = $self->data_by_name('telegram') || {}; my %providers; - for my $provider ( keys %$oauth2 ) { + for my $provider ( OAUTH2_PROVIDERS ) { $providers{$provider} = { enabled => $oauth2->{$provider}{enabled} ? JSON::true : JSON::false, }; @@ -271,13 +272,13 @@ sub api_data_by_auth { return { auth => { - enabled => $billing->{allow_user_auth_api}, + enabled => $billing->{allow_user_auth_api} || JSON::false, }, register => { - enabled => $billing->{allow_user_register_api}, + enabled => $billing->{allow_user_register_api} || JSON::false, }, captcha => { - enabled => $billing->{allow_user_register_captcha}, + enabled => $billing->{allow_user_register_captcha} || JSON::false, }, oauth2 => { providers => \%providers, diff --git a/app/lib/Core/Const.pm b/app/lib/Core/Const.pm index 955547a0..0d2012cb 100644 --- a/app/lib/Core/Const.pm +++ b/app/lib/Core/Const.pm @@ -36,6 +36,8 @@ our @EXPORT = qw( GROUP_ID_LOCAL GROUP_ID_MAIL + + OAUTH2_PROVIDERS ); use constant { @@ -88,5 +90,7 @@ use constant { GROUP_ID_MAIL => 2, }; +use constant OAUTH2_PROVIDERS => qw( google yandex github ); + 1;