diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b431507334eb0..95bb617bb4f1b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,7 +21,9 @@ "dynamicfront", "frontend", "incron", - "minion" + "minion", + "redis-listener", + "keycloak" ], "workspaceFolder": "/opt/product-opener", "customizations": { diff --git a/.dockerignore b/.dockerignore index 114ac74dc2483..3c51a569d58f1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ node_modules +deps/ .devcontainer .git .github diff --git a/.env b/.env index 722f745b2c63f..9c7fb782bf138 100644 --- a/.env +++ b/.env @@ -69,6 +69,14 @@ ODOO_CRM_DB= ODOO_CRM_USER= ODOO_CRM_PASSWORD= +KEYCLOAK_BASE_URL=http://auth.openfoodfacts.localhost:5600 +KEYCLOAK_BACKCHANNEL_BASE_URL=http://keycloak:8080 +KEYCLOAK_REALM_NAME=open-products-facts + +PRODUCT_OPENER_OIDC_CLIENT_ID=ProductOpener +PRODUCT_OPENER_OIDC_CLIENT_SECRET=Cf4NdSAjZsNO9HLcuXeuvukzFu00roQa +PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT=http://auth.openfoodfacts.localhost:5600/realms/open-products-facts/.well-known/openid-configuration + BUILD_CACHE_REPO=openfoodfacts/openfoodfacts-build-cache # If you want the rate limiter to block requests (return 429) instead of doing nothing, diff --git a/.gitignore b/.gitignore index e9d5f0fdf1270..135c61097ba0e 100644 --- a/.gitignore +++ b/.gitignore @@ -66,7 +66,6 @@ nytprof*.out # Local databases data/mongodb Lang.open* -users_emails.sto html/data/* html/products_countries.js products_stats_*.html diff --git a/Dockerfile b/Dockerfile index 5202fc15e2d72..54de1a87ce54c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,7 +53,6 @@ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt set -x && \ libcache-memcached-fast-perl \ libjson-pp-perl \ libclone-perl \ - libcrypt-passwdmd5-perl \ libencode-detect-perl \ libgraphics-color-perl \ libbarcode-zbar-perl \ @@ -67,6 +66,7 @@ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt set -x && \ libdbd-pg-perl \ libtemplate-perl \ liburi-escape-xs-perl \ + libanyevent-redis-perl \ # NB: not available in ubuntu 1804 LTS: libmath-random-secure-perl \ libfile-copy-recursive-perl \ @@ -158,6 +158,8 @@ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt set -x && \ libperl-dev \ # needed to build Apache2::Connection::XForwardedFor libapache2-mod-perl2-dev \ + # OpenSSL dev needed by OIDC::Lite + libssl-dev \ # Imager::zxing - build deps cmake \ pkg-config \ diff --git a/Makefile b/Makefile index c33cf08bfe7dd..f57b43e9e8425 100644 --- a/Makefile +++ b/Makefile @@ -54,13 +54,15 @@ DOCKER_COMPOSE_RUN=COMPOSE_FILE="${COMPOSE_FILE};docker/run.yml" ${DOCKER_COMPOS # keep web-default for web contents # we also publish mongodb on a separate port to avoid conflicts # we also enable the possibility to fake services in po_test_runner -DOCKER_COMPOSE_TEST=WEB_RESOURCES_PATH=./web-default ROBOTOFF_URL="http://backend:8881/" GOOGLE_CLOUD_VISION_API_URL="http://backend:8881/" COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}_test COMPOSE_FILE="${COMPOSE_FILE};${DEPS_DIR}/openfoodfacts-shared-services/docker-compose.yml" PO_COMMON_PREFIX=test_ MONGO_EXPOSE_PORT=27027 MONGODB_CACHE_SIZE=4 ODOO_CRM_URL= docker compose --env-file=${ENV_FILE} -# Enable Redis only for integration tests -DOCKER_COMPOSE_INT_TEST=REDIS_URL="redis:6379" ${DOCKER_COMPOSE_TEST} +DOCKER_COMPOSE_TEST_BASE=WEB_RESOURCES_PATH=./web-default ROBOTOFF_URL="http://backend:8881/" GOOGLE_CLOUD_VISION_API_URL="http://backend:8881/" COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}_test PO_COMMON_PREFIX=test_ MONGODB_CACHE_SIZE=4 ODOO_CRM_URL= docker compose --env-file=${ENV_FILE} +DOCKER_COMPOSE_TEST=COMPOSE_FILE="${COMPOSE_FILE};${DEPS_DIR}/openfoodfacts-shared-services/docker-compose.yml" ${DOCKER_COMPOSE_TEST_BASE} +# Enable Redis only for integration tests. TODO: Currently using dev tag for keycloak - need to switch to main +DOCKER_COMPOSE_INT_TEST=COMPOSE_FILE="${COMPOSE_FILE};docker/integration-test.yml" REDIS_URL="redis:6379" KEYCLOAK_BASE_URL=http://keycloak:8080 PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT=http://keycloak:8080/realms/open-products-facts/.well-known/openid-configuration KEYCLOAK_ADMIN=test KEYCLOAK_ADMIN_PASSWORD=test ${DOCKER_COMPOSE_TEST_BASE} + TEST_CMD ?= yath test -PProductOpener::LoadData # Space delimited list of dependant projects -DEPS=openfoodfacts-shared-services +DEPS=openfoodfacts-shared-services openfoodfacts-auth # Set the DEPS_DIR if it hasn't been set already ifeq (${DEPS_DIR},) export DEPS_DIR=${PWD}/deps @@ -128,7 +130,7 @@ build: @echo "🥫 Building containers …" ${DOCKER_COMPOSE} build ${args} ${container} 2>&1 -_up:run_deps +_up: run_deps @echo "🥫 Starting containers …" ${DOCKER_COMPOSE_RUN} up -d 2>&1 @echo "🥫 started service at http://openfoodfacts.localhost" @@ -142,7 +144,7 @@ prod_up: build create_folders down: @echo "🥫 Bringing down containers …" - ${DOCKER_COMPOSE} down + ${DOCKER_COMPOSE} down --remove-orphans hdown: @echo "🥫 Bringing down containers and associated volumes …" @@ -264,20 +266,14 @@ unit_test: create_folders integration_test: create_folders @echo "🥫 Running integration tests …" -# we launch the server and run tests within same container -# we also need dynamicfront for some assets to exists +# we launch the server and run tests within same container. Dependendies are listed in integration-test.yml # this is the place where variables are important - ${DOCKER_COMPOSE_INT_TEST} up -d memcached postgres mongodb backend dynamicfront incron minion redis + ${DOCKER_COMPOSE_INT_TEST} up -d backend frontend # note: we need the -T option for ci (non tty environment) ${DOCKER_COMPOSE_INT_TEST} exec ${COVER_OPTS} -e PO_EAGER_LOAD_DATA=1 -T backend yath -PProductOpener::LoadData tests/integration ${DOCKER_COMPOSE_INT_TEST} stop @echo "🥫 integration tests success" -# stop all tests dockers -test-stop: - @echo "🥫 Stopping test dockers" - ${DOCKER_COMPOSE_TEST} stop - # usage: make test-unit test=test-name.t # you can use TEST_CMD to change test command, like TEST_CMD="perl -d" to debug a test # you can also add args= to pass more options to your test command @@ -292,14 +288,14 @@ test-unit: guard-test create_folders # you can also add args= to pass more options to your test command test-int: guard-test create_folders @echo "🥫 Running test: 'tests/integration/${test}' …" - ${DOCKER_COMPOSE_INT_TEST} up -d memcached postgres mongodb backend dynamicfront incron minion redis + ${DOCKER_COMPOSE_INT_TEST} up -d backend frontend ${DOCKER_COMPOSE_INT_TEST} exec -e PO_EAGER_LOAD_DATA=1 backend ${TEST_CMD} ${args} tests/integration/${test} # better shutdown, for if we do a modification of the code, we need a restart - ${DOCKER_COMPOSE_INT_TEST} stop backend + ${DOCKER_COMPOSE_INT_TEST} stop # stop all docker tests containers stop_tests: - ${DOCKER_COMPOSE_TEST} stop + ${DOCKER_COMPOSE_INT_TEST} stop # clean tests, remove containers and volume (useful if you changed env variables, etc.) clean_tests: @@ -307,11 +303,9 @@ clean_tests: update_tests_results: build_taxonomies_test build_lang_test @echo "🥫 Updated expected test results with actuals for easy Git diff" - ${DOCKER_COMPOSE_TEST} up -d memcached postgres mongodb backend dynamicfront incron - ${DOCKER_COMPOSE_TEST} run --no-deps --rm -e GITHUB_TOKEN=${GITHUB_TOKEN} backend /opt/product-opener/scripts/taxonomies/build_tags_taxonomy.pl ${name} - ${DOCKER_COMPOSE_TEST} run --rm backend perl -I/opt/product-opener/lib -I/opt/perl/local/lib/perl5 /opt/product-opener/scripts/build_lang.pl - ${DOCKER_COMPOSE_TEST} exec -T -w /opt/product-opener/tests backend bash update_tests_results.sh - ${DOCKER_COMPOSE_TEST} stop + ${DOCKER_COMPOSE_INT_TEST} up -d backend frontend + ${DOCKER_COMPOSE_INT_TEST} exec -T -w /opt/product-opener/tests backend bash update_tests_results.sh + ${DOCKER_COMPOSE_INT_TEST} stop bash: @echo "🥫 Open a bash shell in the backend container" @@ -513,4 +507,3 @@ guard-%: # guard clause for targets that require an environment variable (usuall echo "Environment variable '$*' is not set"; \ exit 1; \ fi; - diff --git a/cgi/auth.pl b/cgi/auth.pl index 2b9ff9e7b947c..9faf35500b880 100755 --- a/cgi/auth.pl +++ b/cgi/auth.pl @@ -31,6 +31,7 @@ use ProductOpener::Users qw/$User_id %User is_admin_user/; use ProductOpener::Lang qw/:all/; use ProductOpener::Tags qw/country_to_cc/; +use ProductOpener::Auth qw/write_auth_deprecated_headers/; use Apache2::Const -compile => qw(OK); use CGI qw/:cgi :form escapeHTML/; diff --git a/cgi/change_password.pl b/cgi/change_password.pl index 3b98cee36903a..b172c19150fa6 100644 --- a/cgi/change_password.pl +++ b/cgi/change_password.pl @@ -3,7 +3,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -25,78 +25,17 @@ use CGI::Carp qw(fatalsToBrowser); use ProductOpener::Config qw/:all/; -use ProductOpener::Paths qw/:all/; -use ProductOpener::Store qw/:all/; -use ProductOpener::Display qw/$tt display_page init_request process_template single_param/; -use ProductOpener::Users qw/$User_id check_password_hash create_password_hash retrieve_user store_user/; -use ProductOpener::Lang qw/lang/; +use ProductOpener::Display qw/init_request display_error_and_exit redirect_to_url/; -use Apache2::Const -compile => qw(OK); -use CGI qw/:cgi :form escapeHTML/; -use URI::Escape::XS; -use Encode; -use Log::Any qw($log); +use URI::Escape::XS qw/uri_escape/; my $request_ref = ProductOpener::Display::init_request(); -my $template_data_ref = {method => $ENV{'REQUEST_METHOD'}}; - -$log->info('start') if $log->is_info(); -if (not defined $User_id) { - my $r = shift; - $r->headers_out->set(Location => '/cgi/login.pl?redirect=/cgi/change_password.pl'); - $r->status(307); - return Apache2::Const::OK; +unless ((defined $oidc_options{keycloak_base_url}) and (defined $oidc_options{keycloak_realm_name})) { + display_error_and_exit($request_ref, 'File not found.', 404); } -my @errors = (); - -if ($ENV{'REQUEST_METHOD'} eq 'POST') { - # TODO: This will change for Keycloak - my $user_ref = retrieve_user($User_id); - if (not(defined $user_ref)) { - push @errors, 'undefined user'; - $template_data_ref->{success} = 0; - } - - my $hash_is_correct = check_password_hash(encode_utf8(decode utf8 => single_param('current_password')), - $user_ref->{'encrypted_password'}); - - # We don't have the right password - if (not $hash_is_correct) { - $log->info( - 'bad password - input does not match stored hash', - {encrypted_password => $user_ref->{'encrypted_password'}} - ) if $log->is_info(); - push @errors, lang('error_bad_login_password'); - } - - if (length(single_param('password')) < 6) { - push @errors, lang('error_invalid_password'); - } - - if ((single_param('password')) ne (single_param('confirm_password'))) { - push @errors, lang('error_different_passwords'); - } - - if (scalar(@errors) > 0) { - $template_data_ref->{success} = 0; - } - else { - $user_ref->{encrypted_password} = create_password_hash(encode_utf8(decode utf8 => single_param('password'))); - store_user($user_ref); - $template_data_ref->{success} = 1; - } -} - -$template_data_ref->{errors} = \@errors; - -my $html; -process_template('web/pages/change_password/change_password.tt.html', $template_data_ref, \$html) or $html = ''; -if ($tt->error()) { - $html .= '

' . $tt->error() . '

'; -} +my $redirect + = $oidc_options{keycloak_base_url} . '/admin/realms/' . uri_escape($oidc_options{keycloak_realm_name}) . '/users'; -$request_ref->{title} = lang('change_password'); -$request_ref->{content_ref} = \$html; -display_page($request_ref); +redirect_to_url($request_ref, 302, $redirect); diff --git a/cgi/login.pl b/cgi/login.pl index ba991afe01d50..be582c6e7a4bd 100644 --- a/cgi/login.pl +++ b/cgi/login.pl @@ -3,7 +3,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -28,10 +28,11 @@ use ProductOpener::Paths qw/:all/; use ProductOpener::Store qw/:all/; use ProductOpener::Display qw/:all/; -use ProductOpener::Users qw/$User_id check_password_hash retrieve_user/; +use ProductOpener::Users qw/$User_id retrieve_user/; use ProductOpener::Lang qw/lang/; +use ProductOpener::Auth qw/password_signin access_to_protected_resource/; -use Apache2::Const -compile => qw(OK); +use Apache2::Const -compile => qw/OK :http/; use CGI qw/:cgi :form escapeHTML/; use URI::Escape::XS; use Encode; @@ -39,60 +40,43 @@ my $request_ref = ProductOpener::Display::init_request(); -my $template_data_ref = {}; - $log->info('start') if $log->is_info(); my $r = shift; my $redirect = single_param('redirect'); -$template_data_ref->{redirect} = $redirect; +my $loc = $redirect || $formatted_subdomain . "/cgi/session.pl"; +my $status_code = Apache2::Const::HTTP_BAD_REQUEST; +my $final_status_set = 0; if (defined $User_id) { - my $loc = $redirect || $formatted_subdomain . "/cgi/session.pl"; + # User is already signed in via cookie or similar, as determined by init_request. $r->headers_out->set(Location => $loc); - $r->err_headers_out->add('Set-Cookie' => $request_ref->{cookie}); - $r->status(302); - return Apache2::Const::OK; + $status_code = Apache2::Const::HTTP_MOVED_TEMPORARILY; + $final_status_set = 1; } -my @errors = (); - -if ($ENV{'REQUEST_METHOD'} eq 'POST') { - my $user_ref = retrieve_user($User_id); - if (not(defined $user_ref)) { - push @errors, 'undefined user'; - $template_data_ref->{success} = 0; - } - - my $hash_is_correct - = check_password_hash(encode_utf8(decode utf8 => single_param('password')), $user_ref->{'encrypted_password'}); - - # We don't have the right password - if (not $hash_is_correct) { - $log->info( - 'bad password - input does not match stored hash', - {encrypted_password => $user_ref->{'encrypted_password'}} - ) if $log->is_info(); - push @errors, lang('error_bad_login_password'); - } +if (not($final_status_set) and (not($ENV{'REQUEST_METHOD'} eq 'POST'))) { + # After OIDC/Keycloak integration, the original login form is no longer used. + # However, some external sites (ie. Hunger Games) may still be using it. + $request_ref->{return_url} = single_param('redirect'); + access_to_protected_resource($request_ref); + $final_status_set = 1; +} - if (scalar(@errors) > 0) { - $template_data_ref->{success} = 0; +if (not($final_status_set)) { + my ($oidc_user_id, $refresh_token, $refresh_expires_at, $access_token, $access_expires_at, $id_token) + = password_signin(encode_utf8(decode utf8 => single_param('user_id')), + encode_utf8(decode utf8 => single_param('password')), $request_ref); + if ($oidc_user_id) { + $r->headers_out->set(Location => $loc); + $status_code = Apache2::Const::HTTP_MOVED_TEMPORARILY; } else { - $template_data_ref->{success} = 1; + $status_code = Apache2::Const::HTTP_UNAUTHORIZED; } -} -$template_data_ref->{errors} = \@errors; - -# Display the sign in form -my $html; -process_template('web/pages/session/sign_in_form.tt.html', $template_data_ref, \$html) or $html = ''; -if ($tt->error()) { - $html .= '

' . $tt->error() . '

'; + $final_status_set = 1; } -$request_ref->{title} = lang('login_register_title'); -$request_ref->{content_ref} = \$html; -display_page($request_ref); - +$r->err_headers_out->add('Set-Cookie' => $request_ref->{cookie}); +$r->status($status_code); +return Apache2::Const::OK; diff --git a/scripts/reset_password_for_user.pl b/cgi/oidc_signin.pl old mode 100755 new mode 100644 similarity index 54% rename from scripts/reset_password_for_user.pl rename to cgi/oidc_signin.pl index 9a5deb800075e..1b3e6daf2eaac --- a/scripts/reset_password_for_user.pl +++ b/cgi/oidc_signin.pl @@ -3,7 +3,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -20,27 +20,30 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -use Modern::Perl '2017'; -use utf8; +=head1 DESCRIPTION + +This cgi script initiates sign-in process with the OIDC service (eg. keycloak) + +It redirects to the OIDC service, which will redirect back to oidc_signin_callback.pl + +=cut + +use ProductOpener::PerlStandards; use CGI::Carp qw(fatalsToBrowser); -use ProductOpener::Config qw/:all/; -use ProductOpener::Paths qw/:all/; -use ProductOpener::Store qw/:all/; -use ProductOpener::Index qw/:all/; -use ProductOpener::Display qw/:all/; -use ProductOpener::Images qw/:all/; -use ProductOpener::Users qw/create_password_hash retrieve_user store_user/; -use ProductOpener::Mail qw/:all/; -use ProductOpener::Lang qw/:all/; - -use CGI qw/:cgi :form escapeHTML/; -use URI::Escape::XS; -use Encode; - -my $userid = $ARGV[0]; -# This will need to be fixed for Keycloak -my $user_ref = retrieve_user($userid); -$user_ref->{encrypted_password} = create_password_hash(encode_utf8(decode utf8 => $ARGV[1])); -store_user($user_ref); +use ProductOpener::Auth qw/access_to_protected_resource/; +use ProductOpener::Display qw/init_request single_param/; +use ProductOpener::Routing qw/analyze_request/; + +use Log::Any qw($log); + +$log->info('start') if $log->is_info(); + +my $request_ref = init_request(); +analyze_request($request_ref); + +$request_ref->{return_url} = single_param('return_url'); +access_to_protected_resource($request_ref); + +1; diff --git a/cgi/oidc_signin_callback.pl b/cgi/oidc_signin_callback.pl new file mode 100644 index 0000000000000..6b785d6d41e11 --- /dev/null +++ b/cgi/oidc_signin_callback.pl @@ -0,0 +1,60 @@ +#!/usr/bin/perl -w + +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2024 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +=head1 DESCRIPTION + +This cgi script is called after successful sign-in happens on OIDC service (eg. keycloak) + +It verifies authentication is ok, and redirects to a site url. + +=cut + +use ProductOpener::PerlStandards; + +use CGI::Carp qw(fatalsToBrowser); + +use ProductOpener::Auth qw/signin_callback/; +use ProductOpener::Display qw/init_request display_error_and_exit redirect_to_url/; +use ProductOpener::Routing qw/analyze_request/; +use ProductOpener::URL qw/format_subdomain/; +use ProductOpener::Users qw/$User_id/; + +use Log::Any qw($log); + +$log->info('start') if $log->is_info(); + +my $request_ref = init_request(); +analyze_request($request_ref); + +my $return_url = signin_callback($request_ref); + +unless (defined $User_id) { + display_error_and_exit($request_ref, 'Unauthorized', 401); +} + +unless (defined $return_url) { + $return_url = format_subdomain('world'); +} + +redirect_to_url($request_ref, 302, $return_url); + +1; diff --git a/cgi/oidc_signout.pl b/cgi/oidc_signout.pl new file mode 100644 index 0000000000000..b9d47fd6c9d76 --- /dev/null +++ b/cgi/oidc_signout.pl @@ -0,0 +1,53 @@ +#!/usr/bin/perl -w + +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2024 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +=head1 DESCRIPTION + +This cgi script initiate the sign-out process using OIDC service (eg. keycloak) + +It redirects to the correct OIDC service page. + +=cut + +use ProductOpener::PerlStandards; + +use CGI::Carp qw(fatalsToBrowser); + +use ProductOpener::Auth qw/start_signout/; +use ProductOpener::Display qw/init_request display_error_and_exit/; +use ProductOpener::Routing qw/analyze_request/; +use ProductOpener::URL qw/format_subdomain/; + +use Log::Any qw($log); + +$log->info('start') if $log->is_info(); + +my $request_ref = init_request(); +if (not($ENV{'REQUEST_METHOD'} eq 'POST')) { + display_error_and_exit($request_ref, 'Method Not Allowed.', 405); +} + +analyze_request($request_ref); + +start_signout($request_ref); + +1; diff --git a/cgi/oidc_signout_callback.pl b/cgi/oidc_signout_callback.pl new file mode 100644 index 0000000000000..3035fcc1b52af --- /dev/null +++ b/cgi/oidc_signout_callback.pl @@ -0,0 +1,54 @@ +#!/usr/bin/perl -w + +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2024 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +=head1 DESCRIPTION + +This cgi script is called after successful sign-out happens on OIDC service (eg. keycloak) + +It redirects to a sensible url. + +=cut + +use ProductOpener::PerlStandards; + +use CGI::Carp qw(fatalsToBrowser); + +use ProductOpener::Auth qw/signout_callback/; +use ProductOpener::Display qw/init_request redirect_to_url/; +use ProductOpener::Routing qw/analyze_request/; +use ProductOpener::URL qw/format_subdomain/; + +use Log::Any qw($log); + +$log->info('start') if $log->is_info(); + +my $request_ref = init_request(); +analyze_request($request_ref); + +my $return_url = signout_callback($request_ref); +unless (defined $return_url) { + $return_url = format_subdomain('world'); +} + +redirect_to_url($request_ref, 302, $return_url); + +1; diff --git a/cgi/product_multilingual.pl b/cgi/product_multilingual.pl index 9c8b9f80281db..2f9b66aadc265 100755 --- a/cgi/product_multilingual.pl +++ b/cgi/product_multilingual.pl @@ -3,7 +3,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -24,6 +24,7 @@ use CGI::Carp qw(fatalsToBrowser); +use ProductOpener::Auth qw/access_to_protected_resource/; use ProductOpener::Config qw/:all/; use ProductOpener::Paths qw/%BASE_DIRS/; use ProductOpener::Store qw/get_string_id_for_lang/; @@ -349,7 +350,7 @@ ($product_ref) display_error_and_exit($request_ref, $Lang{error_no_permission}{$lc}, 403); } -if ($User_id eq 'unwanted-bot-id') { +if ((defined $User_id) and ($User_id eq 'unwanted-bot-id')) { my $r = Apache2::RequestUtil->request(); $r->status(500); return 500; @@ -359,9 +360,10 @@ ($product_ref) if (not defined $User_id) { - my $submit_label = "login_and_" . $type . "_product"; - $action = 'login'; - $template_data_ref->{type} = $type; + $request_ref->{return_url} + = $formatted_subdomain . $request_ref->{script_name} . '?' . $request_ref->{original_query_string}; + # Note: This su will either finish without a result if a good user/token is present, or redirect to the login page and stop the script + access_to_protected_resource($request_ref); } } diff --git a/cgi/reset_password.pl b/cgi/reset_password.pl deleted file mode 100755 index 1cfe1a418c451..0000000000000 --- a/cgi/reset_password.pl +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/perl -w - -# This file is part of Product Opener. -# -# Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts -# Contact: contact@openfoodfacts.org -# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France -# -# Product Opener is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -use ProductOpener::PerlStandards; - -use CGI::Carp qw(fatalsToBrowser); - -use ProductOpener::Config qw/:all/; -use ProductOpener::Paths qw/:all/; -use ProductOpener::Store qw/:all/; -use ProductOpener::Index qw/:all/; -use ProductOpener::Display qw/:all/; -use ProductOpener::Images qw/:all/; -use ProductOpener::Users qw/:all/; -use ProductOpener::Mail qw/send_email/; -use ProductOpener::Lang qw/$lc %Lang lang/; -use ProductOpener::URL qw/format_subdomain/; - -use CGI qw/:cgi :form escapeHTML/; -use URI::Escape::XS; -use Encode; -use Log::Any qw($log); - -my $request_ref = ProductOpener::Display::init_request(); - -my $template_data_ref = {lang => \&lang,}; - -my $type = single_param('type') || 'send_email'; -my $action = single_param('action') || 'display'; - -my $id = single_param('userid_or_email'); -my $resetid = single_param('resetid'); - -$log->info("start", {type => $type, action => $action, userid_or_email => $id, resetid => $resetid}) if $log->is_info(); - -my @errors = (); - -my $user_ref = undef; - -my $html = ''; - -if (defined $User_id) { - display_error_and_exit($request_ref, $Lang{error_reset_already_connected}{$lc}, undef); -} - -if ($action eq 'process') { - - if ($type eq 'send_email') { - - # Is it an email? - - if ($id =~ /\@/) { - $user_ref = retrieve_user_by_email($id); - if (not defined $user_ref) { - push @errors, $Lang{error_reset_unknown_email}{$lc}; - } - } - else { - $user_ref = retrieve_user($id); - if (not defined $user_ref) { - push @errors, $Lang{error_reset_unknown_id}{$lc}; - } - } - - } - elsif (($type eq 'reset') and (defined single_param('resetid'))) { - - if (length(single_param('password')) < 6) { - push @errors, $Lang{error_invalid_password}{$lc}; - } - - if (single_param('password') ne single_param('confirm_password')) { - push @errors, $Lang{error_different_passwords}{$lc}; - } - - } - else { - $log->debug("invalid address", {type => $type}) if $log->is_debug(); - display_error_and_exit($request_ref, lang("error_invalid_address"), 404); - } - - if ($#errors >= 0) { - $log->debug("errors", {errors => \@errors}) if $log->is_debug(); - $action = 'display'; - } -} - -$template_data_ref->{action} = $action; -$template_data_ref->{type} = $type; - -if ($action eq 'display') { - push @{$template_data_ref->{errors}}, @errors; - - if ($type eq 'reset') { - $template_data_ref->{token} = single_param('token'); - $template_data_ref->{resetid} = single_param('resetid'); - } -} - -elsif ($action eq 'process') { - - if ($type eq 'send_email') { - $template_data_ref->{status} = "error"; - - if (defined $user_ref) { - - $user_ref->{token_t} = time(); - $user_ref->{token} = generate_token(64); - $user_ref->{token_ip} = remote_addr(); - - store_user_session($user_ref); - my $userid = $user_ref->{userid}; - - my $url - = format_subdomain($subdomain) - . "/cgi/reset_password.pl?type=reset&resetid=$userid&token=" - . $user_ref->{token}; - - my $email = lang("reset_password_email_body"); - $email =~ s//$userid/g; - $email =~ s//$url/g; - send_email($user_ref, lang("reset_password_email_subject"), $email); - - $template_data_ref->{status} = "email_sent"; - } - } - elsif ($type eq 'reset') { - my $userid = single_param('resetid'); - my $user_ref = retrieve_user($userid); - - $log->debug("resetting password", {userid => $userid}) if $log->is_debug(); - - $template_data_ref->{status} = "error"; - - if (defined $user_ref) { - - if ( (defined $user_ref->{token}) - and (defined single_param('token')) - and (single_param('token') eq $user_ref->{token}) - and (time() < ($user_ref->{token_t} + 86400 * 3))) - { - - $log->debug("token is valid, updating password", {userid => $userid}) if $log->is_debug(); - - $template_data_ref->{status} = "password_reset"; - - $user_ref->{encrypted_password} - = create_password_hash(encode_utf8(decode utf8 => single_param('password'))); - - delete $user_ref->{token}; - - store_user($user_ref); - - } - else { - $log->debug("token is invalid", {userid => $userid}) if $log->is_debug(); - display_error_and_exit($request_ref, $Lang{error_reset_invalid_token}{$lc}, undef); - } - } - } -} - -process_template('web/pages/reset_password/reset_password.tt.html', $template_data_ref, \$html) - or $html = "

" . $tt->error() . "

"; - -$request_ref->{title} = $Lang{'reset_password'}{$lc}; -$request_ref->{content_ref} = \$html; -display_page($request_ref); - diff --git a/cgi/session.pl b/cgi/session.pl index 655159ab2189f..63686e5ec5ac0 100755 --- a/cgi/session.pl +++ b/cgi/session.pl @@ -3,7 +3,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -31,6 +31,7 @@ use ProductOpener::HTTP qw/write_cors_headers/; use ProductOpener::Users qw/$User_id %User/; use ProductOpener::Lang qw/lang/; +use ProductOpener::Auth qw/write_auth_deprecated_headers/; use CGI qw/:cgi :form escapeHTML/; use URI::Escape::XS; @@ -75,6 +76,7 @@ $log->info("redirecting after login", {url => $url}) if $log->is_info(); + write_auth_deprecated_headers(); $r->err_headers_out->add('Set-Cookie' => $request_ref->{cookie}); $r->headers_out->set(Location => "$url"); $r->status(302); @@ -95,6 +97,7 @@ my $data = encode_json(\%response); write_cors_headers(); + write_auth_deprecated_headers(); print header(-type => 'application/json', -charset => 'utf-8') . $data; } @@ -119,6 +122,8 @@ $request_ref->{title} = lang('session_title'); $request_ref->{content_ref} = \$html; + + write_auth_deprecated_headers(); display_page($request_ref); } diff --git a/cgi/sso.pl b/cgi/sso.pl index 576241dff8c0e..dacaed7e41caa 100755 --- a/cgi/sso.pl +++ b/cgi/sso.pl @@ -28,6 +28,7 @@ use ProductOpener::Store qw/:all/; use ProductOpener::Users qw/check_session/; use ProductOpener::Display qw/single_param/; +use ProductOpener::Auth qw/write_auth_deprecated_headers/; use CGI qw/:cgi :form escapeHTML/; use URI::Escape::XS; @@ -44,4 +45,5 @@ my $data = encode_json($response_ref); +write_auth_deprecated_headers(); print header(-type => 'application/json', -charset => 'utf-8') . $data; diff --git a/cgi/user.pl b/cgi/user.pl index 098f3bf09394d..074a92798bb51 100644 --- a/cgi/user.pl +++ b/cgi/user.pl @@ -3,7 +3,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -33,6 +33,7 @@ use ProductOpener::Orgs qw/org_name retrieve_org/; use ProductOpener::Text qw/remove_tags_and_quote/; use ProductOpener::CRM qw/get_contact_url/; +use ProductOpener::Keycloak; use CGI qw/:cgi :form escapeHTML charset/; use URI::Escape::XS; @@ -71,9 +72,9 @@ # The userid looks like an e-mail if ($request_ref->{admin} and ($userid =~ /\@/)) { - my $user_by_email = retrieve_user_by_email($userid); - if (defined $user_by_email) { - $userid = $user_by_email->{userid}; + my $mail_based_userid = is_email_has_off_account($userid); + if (defined $mail_based_userid) { + $userid = $mail_based_userid; } } } @@ -105,12 +106,6 @@ if ($action eq 'process') { - if ($type eq 'edit') { - if (single_param('delete') eq 'on') { - $type = 'delete'; - } - } - # change organization if ($type eq 'edit_owner') { # only admin and pro moderators can change organization freely @@ -121,7 +116,7 @@ display_error_and_exit($request_ref, $Lang{error_no_permission}{$lc}, 403); } } - elsif ($type ne 'delete') { + else { ProductOpener::Users::check_user_form($request_ref, $type, $user_ref, \@errors); } @@ -154,12 +149,10 @@ $user_ref->{email} = $user_info; $user_ref->{userid} = $1; $user_ref->{name} = $1; - $user_ref->{password} = $new_user_password; } else { $user_ref->{userid} = $user_info; $user_ref->{name} = $user_info; - $user_ref->{password} = $new_user_password; } } @@ -170,50 +163,13 @@ $template_data_ref->{sections} = []; if ($user_ref) { - my $selected_language = $user_ref->{preferred_language} - // (remove_tags_and_quote(single_param('preferred_language')) || "$lc"); - my $selected_country = $user_ref->{country} // (remove_tags_and_quote(single_param('country')) || $country); - if ($selected_country eq "en:world") { - $selected_country = ""; - } push @{$template_data_ref->{sections}}, { id => "user", fields => [ - { - field => "name" - }, - { - field => "email", - type => "email", - }, { field => "userid", label => "username" }, - { - field => "password", - type => "password", - label => "password" - }, - { - field => "confirm_password", - type => "password", - label => "password_confirm" - }, - { - field => "preferred_language", - type => "select", - label => "preferred_language", - selected => $selected_language, - options => get_languages_options_list($lc), - }, - { - field => "country", - type => "select", - label => "select_country", - selected => $selected_country, - options => get_countries_options_list($lc), - }, { # this is a honeypot to detect scripts, that fills every fields # this one is hidden in a div and user won't see it @@ -403,9 +359,6 @@ if (($type eq 'add') or ($type =~ /^edit/)) { ProductOpener::Users::process_user_form($type, $user_ref, $request_ref); } - elsif ($type eq 'delete') { - ProductOpener::Users::delete_user($user_ref); - } if ($type eq 'add') { diff --git a/conf/apache-2.4/modperl.conf b/conf/apache-2.4/modperl.conf index fab32e5e2cd52..b3fdcaa877f14 100644 --- a/conf/apache-2.4/modperl.conf +++ b/conf/apache-2.4/modperl.conf @@ -30,4 +30,10 @@ PerlPassEnv RATE_LIMITER_BLOCKING_ENABLED PerlPassEnv ODOO_CRM_URL PerlPassEnv ODOO_CRM_DB PerlPassEnv ODOO_CRM_USER -PerlPassEnv ODOO_CRM_PASSWORD \ No newline at end of file +PerlPassEnv ODOO_CRM_PASSWORD +PerlPassEnv KEYCLOAK_BASE_URL +PerlPassEnv KEYCLOAK_BACKCHANNEL_BASE_URL +PerlPassEnv KEYCLOAK_REALM_NAME +PerlPassEnv PRODUCT_OPENER_OIDC_CLIENT_ID +PerlPassEnv PRODUCT_OPENER_OIDC_CLIENT_SECRET +PerlPassEnv PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT \ No newline at end of file diff --git a/conf/keycloak/open-products-facts-realm.json b/conf/keycloak/open-products-facts-realm.json new file mode 100644 index 0000000000000..26a63134ee18f --- /dev/null +++ b/conf/keycloak/open-products-facts-realm.json @@ -0,0 +1,2498 @@ +{ + "id": "793a2761-1af2-44e1-a0b8-cc37a030a2af", + "realm": "open-products-facts", + "displayName": "Open Products Facts", + "displayNameHtml": "", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "ee81baa9-5677-45ff-9c1f-1b24ef49b30e", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "793a2761-1af2-44e1-a0b8-cc37a030a2af", + "attributes": {} + }, + { + "id": "6254728b-ae51-4873-bcb3-3d9854845b00", + "name": "default-roles-open-products-facts", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "view-profile", + "delete-account", + "manage-account" + ] + } + }, + "clientRole": false, + "containerId": "793a2761-1af2-44e1-a0b8-cc37a030a2af", + "attributes": {} + }, + { + "id": "fdff2d5b-16f5-4c12-8b4a-76d033b14d69", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "793a2761-1af2-44e1-a0b8-cc37a030a2af", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "66d72c18-2dfe-4ae1-a7b2-c1304c1160b2", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "3e13d885-1b39-42ac-b9e4-e648ce07092a", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "a1a2686c-4180-4225-a85e-9872c63a11b5", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "88ca8408-13d1-4d92-aa99-bb792e125572", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "3f0ab4f3-03ad-48db-ac07-6ed47b59955a", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "b80ae327-2b86-452b-a3d6-deb6f811beb2", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "efbb7afe-2c7c-454f-bc1e-9caca15222ce", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "b4339138-dbc8-4fc2-8692-fa757a1a5cd4", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "23cfdbd1-32c7-4616-ab20-3eeea3ac462e", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "17c042a0-cd6b-4f64-a6a0-b579828bce32", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "4e719a81-d8e3-419d-aa58-52506b8d78ee", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "6da1af85-2a85-427d-983c-4faead48871a", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "37970203-2be4-478c-a00b-7b35142f7915", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "f2d9eff6-c191-43fd-8e12-54b996a972a0", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "6c39ce91-d0a1-4196-9726-d86bb7c3fc76", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "2e0a9cf2-3a31-42b4-9bee-ad0b9b0421c3", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "299a944e-1152-42be-b812-aa6768345ca5", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "bbbb1ac2-0141-4ae2-9651-e6beeaac082d", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "manage-authorization", + "view-identity-providers", + "view-events", + "view-clients", + "manage-identity-providers", + "create-client", + "view-users", + "view-authorization", + "manage-clients", + "query-groups", + "view-realm", + "manage-realm", + "manage-users", + "query-clients", + "query-realms", + "manage-events", + "impersonation" + ] + } + }, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + }, + { + "id": "fb664e6a-778e-4a85-abb5-9af2eec98ac4", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "attributes": {} + } + ], + "hoppscotch": [ + { + "id": "512f0e38-1503-4090-baf2-bf363ab0e830", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "4aa0f015-3b71-4aef-8cc9-6e9ddd814217", + "attributes": {} + } + ], + "security-admin-console": [], + "${PRODUCT_OPENER_OIDC_CLIENT_ID}": [ + { + "id": "beda95d0-edcf-4387-a431-a642df8b62b8", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "c865387e-1275-47f7-948a-fd1b4b166385", + "attributes": {} + } + ], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "5f7bf7a7-32ee-4b9f-aa6f-98ec731e2f36", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "0fb7edcc-ba9a-44ab-bc39-55a1343f342a", + "attributes": {} + } + ], + "account": [ + { + "id": "46ee03c6-0a79-4dfc-8502-638dfc9f2d61", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652", + "attributes": {} + }, + { + "id": "cf7ce816-056f-4c5c-bcb1-a066a7a6cd36", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652", + "attributes": {} + }, + { + "id": "1dd5dd45-59a6-439a-b971-9b1e70d1b75f", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652", + "attributes": {} + }, + { + "id": "71219f0a-0ba0-4521-a14e-a7d44a0b18ee", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652", + "attributes": {} + }, + { + "id": "af2495f7-4698-4e29-a3c8-d4a5b8ef0a99", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652", + "attributes": {} + }, + { + "id": "c441a16c-59ef-43bc-9526-4e81f09af158", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652", + "attributes": {} + }, + { + "id": "5a4b1f2e-3771-4ad5-8d5f-e79e19e7f2e6", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652", + "attributes": {} + }, + { + "id": "9271fdb4-6c46-43dd-b1bf-288211bf786f", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "6254728b-ae51-4873-bcb3-3d9854845b00", + "name": "default-roles-open-products-facts", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "793a2761-1af2-44e1-a0b8-cc37a030a2af" + }, + "requiredCredentials": [ + "password" + ], + "passwordPolicy": "passwordHistory(2) and notUsername(undefined) and notEmail(undefined) and length(12)", + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA512", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 8, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "Open Products Facts", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "Open Products Facts", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "c3940479-7649-4562-9890-860d04d1d06e", + "createdTimestamp": 1700482310356, + "username": "service-account-productopener", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "${PRODUCT_OPENER_OIDC_CLIENT_ID}", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-open-products-facts" + ], + "clientRoles": { + "realm-management": [ + "manage-users", + "query-users" + ] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + "clients": [ + { + "id": "c865387e-1275-47f7-948a-fd1b4b166385", + "clientId": "${PRODUCT_OPENER_OIDC_CLIENT_ID}", + "name": "${PRODUCT_OPENER_OIDC_CLIENT_ID}", + "description": "Dummy client for local development", + "rootUrl": "http://world.${PRODUCT_OPENER_DOMAIN}/", + "adminUrl": "http://world.${PRODUCT_OPENER_DOMAIN}/", + "baseUrl": "http://world.${PRODUCT_OPENER_DOMAIN}/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": true, + "clientAuthenticatorType": "client-secret", + "secret": "${PRODUCT_OPENER_OIDC_CLIENT_SECRET}", + "redirectUris": [ + "http://world.${PRODUCT_OPENER_DOMAIN}/cgi/oidc_signin_callback.pl" + ], + "webOrigins": [ + "*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1698609487", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "http://world.${PRODUCT_OPENER_DOMAIN}/cgi/oidc_signout_callback.pl", + "oauth2.device.authorization.grant.enabled": "false", + "display.on.consent.screen": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "706932c1-de97-4528-abb0-386632953e06", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "a762ff13-6c41-4bb9-9314-a918a815815d", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "658cf99e-9f09-4294-80f2-6fc2656c2674", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "openid", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "5ca80ba2-8030-4f5f-909c-dab3221dc652", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/open-products-facts/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/open-products-facts/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "9824b957-e826-4d4f-ae3b-da43c77ac7fe", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/open-products-facts/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/open-products-facts/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "5350bfb5-5e53-41e1-81f5-218aa371cc32", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e1a63130-d2cb-4618-a11c-43616af049ae", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "0fb7edcc-ba9a-44ab-bc39-55a1343f342a", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "4aa0f015-3b71-4aef-8cc9-6e9ddd814217", + "clientId": "hoppscotch", + "name": "hoppscotch", + "description": "", + "rootUrl": "https://hoppscotch.io/", + "adminUrl": "https://hoppscotch.io/", + "baseUrl": "https://hoppscotch.io/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "https://hoppscotch.io/oauth" + ], + "webOrigins": [ + "https://hoppscotch.io" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1701004114", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "https://hoppscotch.io/", + "oauth2.device.authorization.grant.enabled": "true", + "display.on.consent.screen": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "6196a19f-9fee-41ce-bc2e-3859ddf460ac", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "406f0512-2b23-44f0-9678-12122bd33740", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "cc918f4f-54d4-4967-a08a-667e92a1658b", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "4753f77d-ecb7-4944-9959-1321e7ef7b9a", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "0d555a34-b029-415f-943c-ac012ffcb130", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/open-products-facts/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/open-products-facts/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "f9eac11f-03bf-4cae-96e9-4a7398018a3f", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "c7499cba-fd75-463a-91bf-2c2c97fc53c4", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "c0ca0cf9-2d7c-4ae1-8106-2027fed1c526", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "2a574392-a877-45ec-9e3d-cb2232e9588a", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "d2959159-b03a-4a92-9c5b-2ff5549a6206", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "767c475c-f091-444a-84f8-c7a5b7958bca", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "a0ff4433-96a0-4ddc-98b4-e08a51d049f0", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "ed584f54-23c5-4337-acd2-78e688bbbfd7", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "a2e9bd68-a189-4cba-a3a5-08e6a989410b", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "d3b567b8-3eef-4bbd-b286-a14b70aa380a", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "09d1bc31-22c4-43f2-82e3-ac122bfb1cb3", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "5081ef31-f3bb-45ff-aa8e-2e828540097e", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "b0dbf5fb-e59b-4872-816e-d175506b0c89", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "e100d014-3f1d-4075-9777-643deeda8811", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "902e4bf6-5162-42a1-b4af-6e37bed27d3b", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "5f94628e-6b46-462c-b730-1fbf315f5135", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "d17ab27c-4402-4073-876e-e7acbea84a2f", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "cc8c509e-4cd5-4eef-ad30-6765b7e7a493", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "d70f5b9c-ceaa-4d9a-bec8-31ddec27280e", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "57df491b-fe9a-41dc-945a-347da30f3599", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "bf0a66f9-33d6-4b77-afd9-1b2a00aeefc1", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "e1dd1206-5607-4e29-92af-bf321eadb31b", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "975562f7-f636-4ee9-92b9-adb62fa89b7e", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "f5bdaafa-8a95-4832-a66f-e86b0b536d6e", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "1c3e5f92-db44-4326-8c4e-1c905efe0fd3", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "fdf2590d-db0f-462f-91bf-97f21e34ab35", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "a5b2c3dc-1cfb-4ba3-ac9b-83d03f0bbdbc", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "bcdd0ae3-7a76-455a-bd75-0b504f09f872", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "79dc30bd-d1e1-4204-9311-1373e521c182", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "b31e7709-4f24-4738-96b6-a5bf2acb89e3", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "1c85b4a1-30c2-4cf3-aed9-1009b170d8cd", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "ba8c1b78-e58f-4fdd-bcbf-45a14fe2ee5d", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "e316fc71-abdb-4cda-b0a6-a6716949a66f", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "e6e22dd5-5636-4682-a865-cf2224ed3d53", + "name": "openid", + "description": "OpenID Connect built-in scope: openid", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + } + }, + { + "id": "4b53d281-e9f0-4eb4-8045-8c6069c90347", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "d1dec85d-aa84-4cbd-9091-afddd4ab9060", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "c03d266a-0c5b-4fab-b1f2-a8b6c1913ee2", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "462cabcf-b519-4c06-b125-a3ff8ada34d2", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "1304cdff-edd6-424a-ba73-a74d37de6c68", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "loginTheme": "off", + "accountTheme": "off", + "eventsEnabled": true, + "eventsExpiration": 7776000, + "eventsListeners": [ + "jboss-logging", + "persist-deleted-user-event-listener", + "redis-event-listener" + ], + "enabledEventTypes": [ + "SEND_RESET_PASSWORD", + "UPDATE_CONSENT_ERROR", + "GRANT_CONSENT", + "VERIFY_PROFILE_ERROR", + "REMOVE_TOTP", + "REVOKE_GRANT", + "UPDATE_TOTP", + "LOGIN_ERROR", + "CLIENT_LOGIN", + "RESET_PASSWORD_ERROR", + "IMPERSONATE_ERROR", + "CODE_TO_TOKEN_ERROR", + "CUSTOM_REQUIRED_ACTION", + "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR", + "RESTART_AUTHENTICATION", + "IMPERSONATE", + "UPDATE_PROFILE_ERROR", + "LOGIN", + "OAUTH2_DEVICE_VERIFY_USER_CODE", + "UPDATE_PASSWORD_ERROR", + "CLIENT_INITIATED_ACCOUNT_LINKING", + "OAUTH2_EXTENSION_GRANT", + "USER_DISABLED_BY_PERMANENT_LOCKOUT", + "TOKEN_EXCHANGE", + "AUTHREQID_TO_TOKEN", + "LOGOUT", + "REGISTER", + "DELETE_ACCOUNT_ERROR", + "CLIENT_REGISTER", + "IDENTITY_PROVIDER_LINK_ACCOUNT", + "USER_DISABLED_BY_TEMPORARY_LOCKOUT", + "DELETE_ACCOUNT", + "UPDATE_PASSWORD", + "CLIENT_DELETE", + "FEDERATED_IDENTITY_LINK_ERROR", + "IDENTITY_PROVIDER_FIRST_LOGIN", + "CLIENT_DELETE_ERROR", + "VERIFY_EMAIL", + "CLIENT_LOGIN_ERROR", + "RESTART_AUTHENTICATION_ERROR", + "EXECUTE_ACTIONS", + "REMOVE_FEDERATED_IDENTITY_ERROR", + "TOKEN_EXCHANGE_ERROR", + "PERMISSION_TOKEN", + "SEND_IDENTITY_PROVIDER_LINK_ERROR", + "EXECUTE_ACTION_TOKEN_ERROR", + "OAUTH2_EXTENSION_GRANT_ERROR", + "SEND_VERIFY_EMAIL", + "OAUTH2_DEVICE_AUTH", + "EXECUTE_ACTIONS_ERROR", + "REMOVE_FEDERATED_IDENTITY", + "OAUTH2_DEVICE_CODE_TO_TOKEN", + "IDENTITY_PROVIDER_POST_LOGIN", + "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR", + "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR", + "UPDATE_EMAIL", + "REGISTER_ERROR", + "REVOKE_GRANT_ERROR", + "EXECUTE_ACTION_TOKEN", + "LOGOUT_ERROR", + "UPDATE_EMAIL_ERROR", + "CLIENT_UPDATE_ERROR", + "AUTHREQID_TO_TOKEN_ERROR", + "UPDATE_PROFILE", + "CLIENT_REGISTER_ERROR", + "FEDERATED_IDENTITY_LINK", + "SEND_IDENTITY_PROVIDER_LINK", + "SEND_VERIFY_EMAIL_ERROR", + "RESET_PASSWORD", + "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR", + "OAUTH2_DEVICE_AUTH_ERROR", + "UPDATE_CONSENT", + "REMOVE_TOTP_ERROR", + "VERIFY_EMAIL_ERROR", + "SEND_RESET_PASSWORD_ERROR", + "CLIENT_UPDATE", + "CUSTOM_REQUIRED_ACTION_ERROR", + "IDENTITY_PROVIDER_POST_LOGIN_ERROR", + "UPDATE_TOTP_ERROR", + "CODE_TO_TOKEN", + "VERIFY_PROFILE", + "GRANT_CONSENT_ERROR", + "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR" + ], + "adminEventsEnabled": true, + "adminEventsDetailsEnabled": true, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "b183dcd5-f711-4c95-9307-c3ac90ede96c", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "oidc-usermodel-property-mapper", + "saml-user-property-mapper", + "saml-role-list-mapper", + "oidc-address-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-attribute-mapper" + ] + } + }, + { + "id": "539c0fae-f869-470d-b219-058562d2d218", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-address-mapper", + "saml-user-attribute-mapper", + "saml-role-list-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-property-mapper" + ] + } + }, + { + "id": "8602688d-3424-4037-a0a0-12405b54f9f8", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "d4a1a071-49de-47cf-b4f5-b70e7941f7a5", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "2175cf38-7265-4e2d-9320-a31c15aef524", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "83c49007-1f60-4fdd-ae7a-5b9a966ded72", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "2a71ac63-e747-4cc3-9018-2a0f02c681f1", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "593a59e1-9911-4eee-9803-f3091194d4e5", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "918f023c-6942-4c7b-8026-a8fe4e037e0a", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{},\"pattern\":{\"pattern\":\"^[a-z0-9]+[a-z0-9\\\\-]*[a-z0-9]+$\",\"error-message\":\"\"},\"length\":{\"min\":\"2\",\"max\":\"20\"}},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"name\",\"displayName\":\"${name}\",\"permissions\":{\"edit\":[\"admin\",\"user\"],\"view\":[\"user\",\"admin\"]},\"annotations\":{},\"validations\":{\"person-name-prohibited-characters\":{\"error-message\":\"\"}}}],\"groups\":[]}" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "f33d1c49-c3c5-4409-b56f-151464f12082", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "48e3e60b-2dc9-4adf-b343-0cb8a58c29d7", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + }, + { + "id": "fa16d8c9-f6fc-4139-a50d-d700ba7520fb", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "3ddadf69-a870-45d0-ada9-937ace566201", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + } + ] + }, + "internationalizationEnabled": true, + "supportedLocales": [ + "de", + "no", + "fi", + "ru", + "lt", + "lv", + "fr", + "hu", + "zh-CN", + "sk", + "ca", + "sv", + "pt-BR", + "el", + "en", + "it", + "es", + "cs", + "ar", + "ja", + "fa", + "pl", + "da", + "nl", + "tr" + ], + "defaultLocale": "en", + "authenticationFlows": [ + { + "id": "837aceb1-a125-4512-9e1e-d9ac5d2e5b74", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "0341e0fb-f68a-4873-8693-5580fb6a5a33", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "38bbee9b-ccc8-4a70-82b9-a557059a3195", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3f93705f-e378-4e0c-9e4f-44bd19fad478", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d2f35a8f-6963-466c-8fa9-e4750b7c33c2", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "19ccacc2-c55b-404e-8cd2-ec05a4433756", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "cd95618e-8090-4310-a370-93cac5735394", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "a07df183-1404-4879-8527-4aca2a4f12b5", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "4e81104d-c074-4978-b9d9-b3775b8db85d", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "8b1079fd-4d74-4dd9-9880-69f12816346e", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "427364f6-1881-4d72-8389-39ddafec8fa8", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "8c506173-2dab-47ee-b5f1-acbdb13e7b1c", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "6dd67a6e-bde9-4000-9fa9-38b985d935d5", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "id": "80eb0f7c-6d8b-43cd-90e5-119d8e44824a", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "9eb4e22d-39e7-48ff-b77e-757805d6987a", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "34456905-10d7-4a57-9998-5df42f05b29a", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "0641d5d0-1d12-45e0-8520-7eb3c1ecb536", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "f194e634-6df2-4b44-a3c6-461e94a3b8c3", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "292fb851-a707-4990-b103-fdf003c89f39", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "4d7896e8-a936-48d0-aff5-9e3746361daa", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": true, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaAuthRequestedUserHint": "login_hint", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "userProfileEnabled": "true", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false", + "cibaExpiresIn": "120", + "oauth2DeviceCodeLifespan": "600", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "frontendUrl": "", + "acr.loa.map": "{}", + "adminEventsExpiration": "5400" + }, + "keycloakVersion": "23.0.0", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/conf/nginx-docker/nginx.conf b/conf/nginx-docker/nginx.conf index 6929179121f5d..ccfbcbbff3567 100644 --- a/conf/nginx-docker/nginx.conf +++ b/conf/nginx-docker/nginx.conf @@ -131,3 +131,27 @@ server { proxy_pass http://$backend; } } + +server { + listen 80; + listen [::]:80; + + server_name auth.${PRODUCT_OPENER_DOMAIN}; + + # logs location + access_log /var/log/nginx/auth-off-access.log; + error_log /var/log/nginx/auth-off-error.log; + + gzip on; + gzip_min_length 1000; + + # this is the internal Docker DNS, cache only for 30s + resolver 127.0.0.11 valid=30s; + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + set $keycloak keycloak; + proxy_pass http://$keycloak:8080; + } +} diff --git a/cpanfile b/cpanfile index d7bc18fcf1064..065d261d2ef33 100644 --- a/cpanfile +++ b/cpanfile @@ -16,7 +16,6 @@ requires 'JSON::PP'; # libjson-pp-perl requires 'Cpanel::JSON::XS'; # libcpanel-json-xs-perl - fast parsing requires 'JSON::MaybeXS'; # libjson-maybexs-perl requires 'Clone'; # libclone-perl -requires 'Crypt::PasswdMD5'; # libcrypt-passwdmd5-perl requires 'Encode::Detect'; # libencode-detect-perl requires 'Barcode::ZBar'; # libbarcode-zbar-perl requires 'XML::FeedPP'; # libxml-feedpp-perl @@ -38,7 +37,7 @@ requires 'GeoIP2', '>= 2.006002, < 3.0'; # libgeoip2-perl, deps: libdata-validat requires 'Email::Valid', '>= 1.202, < 2.0'; # libemail-valid-perl requires 'Path::Tiny', '>= 0.118'; # libpath-tiny-perl requires 'XML::RPC', '== 2'; # libxml-rpc-fast-perl - +requires 'AnyEvent::RipeRedis'; # libanyevent-redis-perl # Probably not available as Debian/Ubuntu packages requires 'MongoDB', '>= 2.2.2, < 2.3'; # libmongodb-perl has 1.8.1/2.0.3 vs 2.2.2. deps: libauthen-sasl-saslprep-perl, libbson-perl, libauthen-scram-perl, libclass-xsaccessor-perl, libdigest-hmac-perl, libsafe-isa-perl, libconfig-autoconf-perl, libpath-tiny-perl @@ -50,7 +49,6 @@ requires 'Image::OCR::Tesseract'; # deps: libfile-find-rule-perl requires 'DateTime', '>= 1.54, < 2.0'; # libdatetime-perl has 1.46. deps: libclass-singleton-perl requires 'DateTime::Locale', '>= 1.32, < 2.0'; # libdatetime-locale-perl has 1.17. deps: libfile-sharedir-install-perl requires 'DateTime::Format::ISO8601'; # libdatetime-format-iso8601-perl -requires 'Crypt::ScryptKDF'; requires 'Locale::Maketext::Lexicon::Getcontext', '>= 0.05'; # deps: liblocale-maketext-lexicon-perl requires 'CLDR::Number::Format::Decimal'; requires 'CLDR::Number::Format::Percent'; @@ -68,7 +66,6 @@ requires 'JSON::Create'; requires 'JSON::Parse'; requires 'Data::DeepAccess'; requires 'XML::XML2JSON'; -requires 'Redis'; requires 'Digest::SHA1'; requires 'Data::Difference'; requires 'Data::Compare'; @@ -86,6 +83,8 @@ requires 'Log::Any::Adapter::Log4perl', '>= 0.09'; # liblog-any-adapter-log4perl # Retry requires 'Action::CircuitBreaker'; requires 'Action::Retry'; # deps: libmath-fibonacci-perl +requires 'LWP::UserAgent::Plugin'; +requires 'LWP::UserAgent::Plugin::Retry'; # AnyEvent requires 'AnyEvent'; @@ -104,6 +103,10 @@ requires 'Imager::File::JPEG'; requires 'Imager::File::PNG'; requires 'Imager::File::WEBP'; +# OIDC / OAuth +requires 'OIDC::Lite'; +requires 'Crypt::JWT'; + # To dynamically load Config_*.pm modules requires 'Module::Load'; diff --git a/docker-compose.yml b/docker-compose.yml index 66546f4bfd182..84a7e357eed2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,12 @@ x-backend-conf: &backend-conf - ODOO_CRM_DB - ODOO_CRM_USER - ODOO_CRM_PASSWORD + - KEYCLOAK_BASE_URL + - KEYCLOAK_BACKCHANNEL_BASE_URL + - KEYCLOAK_REALM_NAME + - PRODUCT_OPENER_OIDC_CLIENT_ID + - PRODUCT_OPENER_OIDC_CLIENT_SECRET + - PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT depends_on: - memcached volumes: @@ -104,8 +110,12 @@ services: - "$MINION_QUEUE" incron: <<: *backend-conf - # This service watch for new images to trigger ocr and robotoff processing + # This service watches for new images to trigger ocr and robotoff processing command: ["perl", "scripts/run_cloud_vision_ocr.pl", "/mnt/podata/new_images"] + redis-listener: + <<: *backend-conf + # This service watches for new events on a redis stream to delete users + command: ["perl", "scripts/listen_to_redis_stream.pl"] frontend: image: ghcr.io/openfoodfacts/openfoodfacts-server/frontend:${TAG} depends_on: diff --git a/docker/dev.yml b/docker/dev.yml index 4a2999d6c238d..2ab41d07738e4 100644 --- a/docker/dev.yml +++ b/docker/dev.yml @@ -27,6 +27,9 @@ x-backend-conf: &backend-conf # e.g. when developing with openfoodfacts-query extra_hosts: - "host.docker.internal:host-gateway" + - "auth.openfoodfacts.localhost:host-gateway" + networks: + default: x-minion-db-network: &minion-db-network networks: @@ -42,6 +45,7 @@ services: backend: <<: [*backend-conf, *minion-db-network] incron: *backend-conf + redis-listener: *backend-conf minion: <<: [*backend-conf, *minion-db-network] # in dev we want to use watch assets and recompile on the fly @@ -87,9 +91,21 @@ services: aliases: # trick: make it possible for robotoff to reach it internally, # using localhost domain - - world.openfoodfacts.localhost - - static.openfoodfacts.localhost - - images.openfoodfacts.localhost + - world.${PRODUCT_OPENER_DOMAIN} + - static.${PRODUCT_OPENER_DOMAIN} + - images.${PRODUCT_OPENER_DOMAIN} + - fr.${PRODUCT_OPENER_DOMAIN} + - world-be.${PRODUCT_OPENER_DOMAIN} + - world-de.${PRODUCT_OPENER_DOMAIN} + - world-it.${PRODUCT_OPENER_DOMAIN} + - es-it.${PRODUCT_OPENER_DOMAIN} + - ch-it.${PRODUCT_OPENER_DOMAIN} + - ssl-api.${PRODUCT_OPENER_DOMAIN} + - fr.pro.${PRODUCT_OPENER_DOMAIN} + - world.pro.${PRODUCT_OPENER_DOMAIN} + - auth.${PRODUCT_OPENER_DOMAIN} + - es.${PRODUCT_OPENER_DOMAIN} + - be-fr.${PRODUCT_OPENER_DOMAIN} volumes: product_images: diff --git a/docker/integration-test.yml b/docker/integration-test.yml new file mode 100644 index 0000000000000..60c45293e3163 --- /dev/null +++ b/docker/integration-test.yml @@ -0,0 +1,24 @@ +include: + - ${DEPS_DIR}/openfoodfacts-shared-services/docker-compose.yml + - ${DEPS_DIR}/openfoodfacts-auth/docker-compose.yml + +services: + backend: + depends_on: + postgres: + condition: service_started + mongodb: + condition: service_started + dynamicfront: + condition: service_started + incron: + condition: service_started + minion: + condition: service_started + redis: + condition: service_started + redis-listener: + condition: service_started + keycloak: + # Keycloak takes a while to start so need to wait until it is healthy + condition: service_healthy diff --git a/docker/prod.yml b/docker/prod.yml index 77b854d7a9914..12d4cbc43bc82 100644 --- a/docker/prod.yml +++ b/docker/prod.yml @@ -16,6 +16,10 @@ services: restart: always networks: - webnet + redis-listener: + restart: always + networks: + - webnet minion: restart: always networks: diff --git a/docker/run.yml b/docker/run.yml index e8a686abe57ac..93305f9d8366d 100644 --- a/docker/run.yml +++ b/docker/run.yml @@ -9,6 +9,10 @@ services: networks: # Needed to access MongoDB and Redis shared_network: + redis-listener: + networks: + # Needed to access MongoDB and Redis + shared_network: networks: # This network allows access to shared services like MongoDB and Redis diff --git a/lib/ProductOpener/API.pm b/lib/ProductOpener/API.pm index 7f4ac51ed6714..f677ab194418d 100644 --- a/lib/ProductOpener/API.pm +++ b/lib/ProductOpener/API.pm @@ -1,7 +1,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -53,6 +53,7 @@ BEGIN { &normalize_requested_code &customize_response_for_product &check_user_permission + &process_auth_header ); # symbols to export on request %EXPORT_TAGS = (all => [@EXPORT_OK]); } @@ -62,6 +63,7 @@ use vars @EXPORT_OK; use ProductOpener::Config qw/:all/; use ProductOpener::Display qw/:all/; use ProductOpener::HTTP qw/write_cors_headers/; +use ProductOpener::Auth qw/:all/; use ProductOpener::Users qw/:all/; use ProductOpener::Lang qw/$lc lang_in_other_lc/; use ProductOpener::Products qw/normalize_code_with_gs1_ai product_name_brand_quantity/; @@ -74,6 +76,8 @@ use ProductOpener::Ecoscore qw/localize_ecoscore/; use ProductOpener::Packaging qw/%packaging_taxonomies/; use ProductOpener::Permissions qw/has_permission/; use ProductOpener::GeoIP qw/get_country_for_ip_api/; +use ProductOpener::Paths qw/:all/; +use ProductOpener::Store qw/:all/; use ProductOpener::APIProductRead qw/read_product_api/; use ProductOpener::APIProductWrite qw/write_product_api/; @@ -83,7 +87,7 @@ use ProductOpener::APITagRead qw/read_tag_api/; use ProductOpener::APITaxonomySuggestions qw/taxonomy_suggestions_api/; use ProductOpener::ProductsFeatures qw(feature_enabled); -use CGI qw(header); +use CGI qw/:cgi :form escapeHTML/; use Apache2::RequestIO(); use Apache2::RequestRec(); use JSON::MaybeXS; @@ -908,4 +912,103 @@ sub check_user_permission ($request_ref, $response_ref, $permission) { return $error; } +=head2 process_auth_header ( $request_ref, $r ) + +Using the Authorization HTTP header, check if we have a valid user. + +=head3 Parameters + +=head4 $request_ref (input) + +Reference to the request object. + +=head4 $r (input) + +Reference to the Apache2 request object + +=head3 Return value + +1 if the user has been signed in, -1 if the Bearer token was invalid, 0 otherwise. + +=cut + +sub process_auth_header ($request_ref, $r) { + my $token = _read_auth_header($request_ref, $r); + unless ($token) { + return 0; + } + + my $access_token; + # verify token using JWKS (see Auth.pm) + eval {$access_token = verify_access_token($token);}; + my $error = $@; + if ($error) { + $log->info('Access token invalid', {token => $token}) if $log->is_info(); + } + + unless ($access_token) { + add_error( + $request_ref->{api_response}, + { + message => {id => 'invalid_token'}, + impact => {id => 'failure'}, + } + ); + return -1; + } + + $request_ref->{access_token} = $access_token; + my $user_id = get_user_id_using_token($access_token, $request_ref); + unless (defined $user_id) { + $log->info('User not found and not created') if $log->is_info(); + display_error_and_exit($request_ref, 'Internal error', 500); + } + + my $user_ref = retrieve_user($user_id); + unless (defined $user_ref) { + $log->info('User not found', {user_id => $user_id}) if $log->is_info(); + display_error_and_exit($request_ref, 'Internal error', 500); + } + + $log->debug('user_id found', {user_id => $user_id}) if $log->is_debug(); + + my $user_session = open_user_session($user_ref, undef, undef, $access_token->{access_token}, + undef, $access_token->{id_token}, $request_ref); + param('user_id', $user_id); + param('user_session', $user_session); + init_user($request_ref); + + return 1; +} + +=head2 _read_auth_header ( $request_ref, $r ) + +Using the Authorization HTTP header, check if it looks like a +Bearer token, and if it does, copy it to the request_ref + +=head3 Parameters + +=head4 $request_ref (input) + +Reference to the request object. + +=head4 $r (input) + +Reference to the Apache2 request object + +=head3 Return value + +None + +=cut + +sub _read_auth_header ($request_ref, $r) { + my $authorization = $r->headers_in->{Authorization}; + if ((defined $authorization) and ($authorization =~ /^Bearer (?.+)$/)) { + return $+{token}; + } + + return; +} + 1; diff --git a/lib/ProductOpener/APIProductWrite.pm b/lib/ProductOpener/APIProductWrite.pm index abe224347b228..b5c71de61ece9 100644 --- a/lib/ProductOpener/APIProductWrite.pm +++ b/lib/ProductOpener/APIProductWrite.pm @@ -1,7 +1,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -54,6 +54,7 @@ use ProductOpener::Packaging qw/add_or_combine_packaging_component_data get_checked_and_taxonomized_packaging_component_data/; use ProductOpener::Text qw/remove_tags_and_quote/; use ProductOpener::Tags qw/%language_fields %writable_tags_fields add_tags_to_field compute_field_tags/; +use ProductOpener::Auth qw/get_azp/; use Encode; @@ -427,7 +428,7 @@ sub write_product_api ($request_ref) { # The product does not exist yet, or the requested code is "test" if (not defined $product_ref) { - $product_ref = init_product($User_id, $Org_id, $code, $country); + $product_ref = init_product($User_id, $Org_id, $code, $country, get_azp($request_ref->{access_token})); $product_ref->{interface_version_created} = "20221102/api/v3"; } @@ -474,7 +475,7 @@ sub write_product_api ($request_ref) { # Save the product if ($code ne "test") { my $comment = $request_body_ref->{comment} || "API v3"; - store_product($User_id, $product_ref, $comment); + store_product($User_id, $product_ref, $comment, get_azp($request_ref->{access_token})); } # Select / compute only the fields requested by the caller, default to updated fields diff --git a/lib/ProductOpener/APITest.pm b/lib/ProductOpener/APITest.pm index 73c1805efe698..fc795281c7cc7 100644 --- a/lib/ProductOpener/APITest.pm +++ b/lib/ProductOpener/APITest.pm @@ -1,7 +1,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -37,6 +37,7 @@ BEGIN { &construct_test_url &create_user &edit_user + &create_user_in_keycloak &edit_product &get_page &html_displays_error @@ -64,6 +65,7 @@ use ProductOpener::Test qw/:all/; use ProductOpener::Mail qw/$LOG_EMAIL_START $LOG_EMAIL_END/; use ProductOpener::Store qw/store retrieve/; use ProductOpener::Producers qw/get_minion/; +use ProductOpener::Config qw/%oidc_options/; use Test2::V0; use Data::Dumper; @@ -86,6 +88,33 @@ no warnings qw(experimental::signatures); my $TEST_MAIN_DOMAIN = "openfoodfacts.localhost"; my $TEST_WEBSITE_URL = "http://world." . $TEST_MAIN_DOMAIN; +=head2 wait_auth() + +Wait for authentication server to be ready. +It's important because the application might fail because of that + +=cut + +sub wait_auth() { + + # simply try to access front page + my $count = 0; + my $ua = new_client(); + my $target_url = construct_test_url(""); + while (1) { + my $response = $ua->get($oidc_options{discovery_endpoint}); + last if $response->is_success; + sleep 1; + $count++; + if (($count % 3) == 0) { + print("Waiting for auth to be ready since more than $count seconds...\n"); + diag explain({url => $target_url, status => $response->code, response => $response}); + } + confess("Waited too much for auth") if $count > 60; + } + return; +} + =head2 wait_dynamic_front() Wait for dynamic_front to be ready. @@ -138,7 +167,7 @@ sub wait_server() { =head2 wait_application_ready() -Wait for server and dynamic front to be ready. +Wait for server, dynamic front, and authentication server to be ready. Run this at the beginning of every integration test =cut @@ -146,6 +175,7 @@ Run this at the beginning of every integration test sub wait_application_ready() { wait_server(); wait_dynamic_front(); + wait_auth(); return; } @@ -225,6 +255,58 @@ sub login ($ua, $user_id, $password) { return $response; } +=head2 create_user_in_keycloak($user_ref) + +Call API to create a user in Keycloak +without creating them in ProductOpener, too. +As create_user uses the ProductOpener API, this +is useful for testing the Keycloak API on it's own. + +=head3 Arguments + +=head4 $user_ref - fields + +=cut + +sub create_user_in_keycloak ($user_ref) { + + my $credential = { + type => 'password', + value => $user_ref->{password}, + temporary => $JSON::false + }; + + my $keycloak_user_ref = { + email => $user_ref->{email}, + emailVerified => $user_ref->{email_verified} ? $JSON::PP::true : $JSON::PP::true, + enabled => $JSON::PP::true, + username => $user_ref->{userid}, + credentials => [$credential], + attributes => [ + name => [$user_ref->{name}], + locale => [$user_ref->{initial_lc}], + country => [$user_ref->{initial_cc}], + ] + }; + + my $json = encode_json($keycloak_user_ref); + + my $keycloak = ProductOpener::Keycloak->new(); + my $request_token = $keycloak->get_or_refresh_token(); + my $create_user_request = HTTP::Request->new(POST => $keycloak->{users_endpoint}); + $create_user_request->header('Content-Type' => 'application/json'); + $create_user_request->header( + 'Authorization' => $request_token->{token_type} . ' ' . $request_token->{access_token}); + $create_user_request->content($json); + my $new_user_response = LWP::UserAgent::Plugin->new->request($create_user_request); + + unless ($new_user_response->is_success) { + return 0; + } + + return 1; +} + =head2 get_page ($ua, $url) Get a page of the app @@ -634,14 +716,23 @@ sub check_request_response ($test_ref, $response, $test_id, $test_dir, $expected return; } -sub execute_api_tests ($file, $tests_ref, $ua = undef) { +sub execute_api_tests ($file, $tests_ref, $ua = undef, $reuse_ua = 1) { + + if ((defined $ua) and (not($reuse_ua))) { + confess('Error in API test setup for ' . $file . ': $ua was passed but $reuse_ua was not set to 1'); + return; + } my ($test_id, $test_dir, $expected_result_dir, $update_expected_results) = (init_expected_results($file)); - $ua = $ua // LWP::UserAgent->new(); + $ua = $ua // new_client(); foreach my $test_ref (@$tests_ref) { + if (not($reuse_ua)) { + $ua = new_client(); + } + my $response = execute_request($test_ref, $ua); check_request_response($test_ref, $response, $test_id, $test_dir, $expected_result_dir, diff --git a/lib/ProductOpener/Auth.pm b/lib/ProductOpener/Auth.pm new file mode 100644 index 0000000000000..89b7fd6c4b162 --- /dev/null +++ b/lib/ProductOpener/Auth.pm @@ -0,0 +1,831 @@ +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2024 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +=head1 NAME + +ProductOpener::Auth - Perl module for OpenID Connect (OIDC) and Keycloak authentication + +=head1 DESCRIPTION + +This Perl module provides functions for user authentication, token verification, and access to protected resources using OpenID Connect (OIDC) and Keycloak. + +=cut + +package ProductOpener::Auth; + +use ProductOpener::PerlStandards; +use Exporter qw< import >; + +use Log::Any qw($log); + +BEGIN { + use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS); + @EXPORT_OK = qw( + &access_to_protected_resource + &signin_callback + &signout_callback + &password_signin + &verify_access_token + &verify_id_token + &get_user_id_using_token + &get_token_using_client_credentials + &get_token_using_password_credentials + &get_azp + &write_auth_deprecated_headers + &start_signout + + $oidc_discover_document + $jwks + ); # symbols to export on request + %EXPORT_TAGS = (all => [@EXPORT_OK]); +} + +use vars @EXPORT_OK; + +use ProductOpener::Config qw/:all/; +use ProductOpener::Display qw/$subdomain $formatted_subdomain single_param redirect_to_url display_error_and_exit/; +use ProductOpener::URL qw/get_cookie_domain format_subdomain/; +use ProductOpener::Users qw/$User_id retrieve_user store_user generate_token init_user open_user_session/; +use ProductOpener::Lang qw/$lc/; + +use OIDC::Lite; +use OIDC::Lite::Client::WebServer; +use OIDC::Lite::Model::IDToken; +use Crypt::JWT qw(decode_jwt); + +use CGI qw/:cgi :form escapeHTML/; +use Apache2::RequestIO(); +use Apache2::RequestRec(); +use MIME::Base64 qw(decode_base64); +use JSON::PP; +use Data::DeepAccess qw(deep_get); +use Storable qw(dclone); +use Encode; +use LWP::UserAgent; +use LWP::UserAgent::Plugin 'Retry'; +use HTTP::Request; +use URI::Escape::XS qw/uri_escape/; + +# Initialize some constants + +my $cookie_name = 'oidc'; +my $cookie_domain = get_cookie_domain(); + +my $callback_uri = format_subdomain('world') . '/cgi/oidc_signin_callback.pl'; +my $signout_callback_uri = format_subdomain('world') . '/cgi/oidc_signout_callback.pl'; + +my $client = undef; + +=head2 start_authorize($request_ref) + +Initiates the authorization process by redirecting the user to the authorization page. + +=head3 Arguments + +=head4 A reference to a hash containing request information. $request_ref + +=head3 Return Values + +None + +=cut + +sub start_authorize ($request_ref) { + # random private token to identify the sign-in process + my $nonce = generate_token(64); + my $return_url = $request_ref->{return_url}; + if ( (not $return_url) + or (not($return_url =~ /^https?:\/\/$subdomain\.$server_domain/))) + { + $return_url = $formatted_subdomain; + } + + # get main OIDC client (keycloak) + my $current_client = _get_client(); + my $redirect_url = $current_client->uri_to_redirect( + redirect_uri => $callback_uri, + scope => q{openid profile offline_access}, + state => $nonce, + ) + . '&ui_locales=' + . uri_escape($lc) . '&lc=' + . uri_escape($lc) . '&cc=' + . uri_escape($request_ref->{cc}); + + $request_ref->{cookie} = generate_oidc_cookie($nonce, $return_url); + redirect_to_url($request_ref, 302, $redirect_url); + return; +} + +=head2 signin_callback($request_ref) + +Handles the callback after successful authentication, verifies the ID token, and creates or retrieves the user's information. + +=head3 Arguments + +=head4 A reference to a hash containing request information. $request_ref + +=head3 Return values + +The return URL after successful authentication. + +=cut + +sub signin_callback ($request_ref) { + if (not(defined cookie($cookie_name))) { + display_error_and_exit(lang('oidc_signin_no_cookie'), 400); + return; + } + + my $code = single_param('code'); + my $state = single_param('state'); + my $time = time; + my $current_client = _get_client(); + # access token shall have been set by OIDC service, get it + my $access_token = $current_client->get_access_token( + code => $code, + redirect_uri => $callback_uri, + ) or display_error_and_exit($request_ref, $current_client->errstr, 500); + $log->info('got access token during callback', {access_token => $access_token}) if $log->is_info(); + + my %cookie_ref = cookie($cookie_name); + # verify we are in the right sign-in process, thanks to the randomly generated token + my $nonce = $cookie_ref{'nonce'}; + if (not($state eq $nonce)) { + $log->info('unexpected nonce', {nonce => $nonce, expected_nonce => $state}) if $log->is_info(); + display_error_and_exit($request_ref, 'Invalid Nonce during OIDC login', 500); + } + + # validation against JWKS + my $id_token = verify_id_token($access_token->id_token); + unless ($id_token) { + $log->info('id token did not verify') if $log->is_info(); + display_error_and_exit($request_ref, 'Authentication error', 401); + } + + my $user_id = get_user_id_using_token($id_token, $request_ref); + unless (defined $user_id) { + $log->info('User not found and not created') if $log->is_info(); + display_error_and_exit($request_ref, 'Internal error', 500); + } + + my $user_ref = retrieve_user($user_id); + unless ($user_ref) { + $log->info('User not found', {user_id => $user_id}) if $log->is_info(); + display_error_and_exit($request_ref, 'Internal error', 500); + } + + $log->debug('user found', {user_ref => $user_ref}) if $log->is_debug(); + my $user_session = open_user_session( + $user_ref, + $access_token->{refresh_token}, + $time + $access_token->{refresh_expires_in}, + $access_token->{access_token}, + $time + $access_token->{expires_in}, + $access_token->{id_token}, $request_ref + ); + # add as apache parameter for now (should better be in request_ref) + param('user_id', $user_id); + param('user_session', $user_session); + init_user($request_ref); + + return $cookie_ref{'return_url'}; +} + +=head2 password_signin($username, $password, $request_ref) + +Signs in the user with a username and password, and returns the user's ID, refresh token, refresh token expiration time, access token, and access token expiration time. + +We support this to enable passing user and password in the request json. This is a legacy way of doing. + +=head3 Arguments + +=head4 The username for password-based authentication. $username + +=head4 The password for password-based authentication. $password + +=head3 Return Values + +A list containing the user's ID, refresh token, refresh token expiration time, access token, access token expiration time, and the ID token + +=cut + +sub password_signin ($username, $password, $request_ref) { + unless ($username and $password) { + return; + } + + my $time = time; + my $access_token = get_token_using_password_credentials($username, $password); + unless ($access_token) { + return; + } + + my $id_token = verify_id_token($access_token->{id_token}); + unless ($id_token) { + $log->info('id token did not verify') if $log->is_info(); + return; + } + + my $user_id = get_user_id_using_token($id_token, $request_ref); + $log->debug('user_id found', {user_id => $user_id}) if $log->is_debug(); + return ( + $user_id, + $access_token->{refresh_token}, + # use absolute time instead of relative time + $time + $access_token->{refresh_expires_in}, + $access_token->{access_token}, + # use absolute time instead of relative time + $time + $access_token->{expires_in}, + $id_token + ); +} + +=head2 get_user_id_using_token ($id_token, , $request_ref, $require_verified_email) + +Extract the user id from the OIDC identification token (which contains an email). + +It verifies that the email is a verified email before proceeding. + +If the user properties file does not yet exists, it create it. + +=head3 Arguments + +=head4 hash ref $id_token + +The OIDC identification token information + +=head4 boolean $require_verified_email + +If true, the email must be verified before proceeding. + +=head3 Return Value + +The userid as a string + +=cut + +sub get_user_id_using_token ($id_token, $request_ref, $require_verified_email = 0) { + if ($require_verified_email and (not($id_token->{'email_verified'} eq $JSON::PP::true))) { + $log->info('User email is not verified.', {email => $id_token->{'email'}}) if $log->is_info(); + return; + } + + my $user_id = $id_token->{'preferred_username'}; + my $user_ref = retrieve_user($user_id); + unless ($user_ref) { + $log->info('User not found', {user_id => $user_id}) if $log->is_info(); + $user_ref = {userid => $user_id}; + } + + # Update duplicated information from Keycloak + $user_ref->{name} = $id_token->{'name'} // $user_id; + $user_ref->{email} = $id_token->{'email'}; + + # Make sure initial information is set (user may have been created by Redis) + defined $user_ref->{registered_t} or $user_ref->{registered_t} = time(); + defined $user_ref->{last_login_t} or $user_ref->{last_login_t} = time(); + defined $user_ref->{ip} or $user_ref->{ip} = remote_addr(); + defined $user_ref->{initial_lc} or $user_ref->{initial_lc} = $lc; + defined $user_ref->{initial_cc} or $user_ref->{initial_cc} = $request_ref->{cc}; + defined $user_ref->{initial_user_agent} or $user_ref->{initial_user_agent} = user_agent(); + + store_user($user_ref); + + return $user_ref->{userid}; +} + +=head2 refresh_access_token ($id_token) + +Refreshes the access token using the OIDC client. + +Access token have a limited life span but can be refreshed + +=head3 Arguments + +=head4 hash ref $refresh_token + +OIDC refresh token + +=head3 Return Value + +A list containing the user's ID, new refresh token, refresh token expiration time, new access token, and access token expiration time. + +=cut + +sub refresh_access_token ($refresh_token) { + my $time = time; + my $current_client = _get_client(); + my $access_token = $current_client->refresh_access_token(refresh_token => $refresh_token,) + or die $current_client->errstr; + + $log->info('refreshed access token', {access_token => $access_token}) if $log->is_info(); + return ( + $access_token->{refresh_token}, $time + $access_token->{refresh_expires_in}, + $access_token->{access_token}, $time + $access_token->{expires_in} + ); +} + +=head2 access_to_protected_resource ($request_ref) + +This method insure a user is authenticated before proceeding to a specific page. + +If user is not authenticated, or his access token can't be refreshed, +it will be redirected to signin process. + +=head3 Arguments + +=head4 A reference to a hash containing request information. $request_ref + +=head3 Return Values + +None + +=cut + +sub access_to_protected_resource ($request_ref) { + unless ($User_id) { + start_authorize($request_ref); + return; + } + + my $access_token = $request_ref->{access_token}; + my $refresh_expires_at = $request_ref->{refresh_expires_at}; + my $refresh_token = $request_ref->{refresh_token}; + my $access_expires_at = $request_ref->{access_expires_at}; + + unless ($access_token) { + start_authorize($request_ref); + return; + } + + # refresh access token if it has already expired + if ((defined $access_expires_at) and ($access_expires_at < time)) { + ($refresh_token, $refresh_expires_at, $access_token, $access_expires_at) = refresh_access_token($refresh_token); + unless ($access_token) { + start_authorize($request_ref); + return; + } + } + + # ID Token validation + #my $id_token = OIDC::Lite::Model::IDToken->load($token->id_token); + + $log->info('request is ok', $request_ref) if $log->is_info(); + + return; +} + +=head2 start_signout($request_ref) + +Initiates the sign-out process by redirecting the user to the authorization page. + +=head3 Arguments + +=head4 A reference to a hash containing request information. $request_ref + +=head3 Return Values + +None + +=cut + +sub start_signout ($request_ref) { + # compute return_url, so that after sign out, user will be redirected to the home page + my $return_url = single_param('return_url'); + die $return_url if defined $return_url; + if ( (not $return_url) + or (not($return_url =~ /^https?:\/\/$subdomain\.$server_domain/sxm))) + { + $return_url = $formatted_subdomain; + } + + my $id_token = $request_ref->{id_token}; + unless ($User_id and $id_token) { + # user is not authenticated, nothing to do; sign-out is already done, redirect to home page + param('length', 'logout'); + init_user($request_ref); + redirect_to_url($request_ref, 302, $return_url); + return; + } + + _ensure_oidc_is_discovered(); + + # random private token to identify the sign-out process + my $nonce = generate_token(64); + my $end_session_endpoint = $oidc_discover_document->{end_session_endpoint}; + my $redirect_url + = $end_session_endpoint + . '?post_logout_redirect_uri=' + . uri_escape($signout_callback_uri) + . '&id_token_hint=' + . uri_escape($id_token) + . '&state=' + . uri_escape($nonce); + + # start OIDC signout process by storing nonce and return_url in a cookie + $request_ref->{cookie} = generate_oidc_cookie($nonce, $return_url); + # then, redirect to OIDC end_session_endpoint + redirect_to_url($request_ref, 302, $redirect_url); + return; +} + +=head2 signout_callback($request_ref) + +Handles the callback after successful sign-out, clears session cookie. + +=head3 Arguments + +=head4 A reference to a hash containing request information. $request_ref + +=head3 Return values + +The return URL after successful sign-out. + +=cut + +sub signout_callback ($request_ref) { + # no cookie, nothing to do + unless (defined cookie($cookie_name)) { + return $formatted_subdomain; + } + + # ensure we are in the right process thanks to private random token + my $state = single_param('state'); + my %cookie_ref = cookie($cookie_name); + my $nonce = $cookie_ref{'nonce'}; + if (not($state eq $nonce)) { + $log->info('unexpected nonce', {nonce => $nonce, expected_nonce => $state}) if $log->is_info(); + display_error_and_exit($request_ref, 'Invalid Nonce during OIDC logout', 500); + } + + param('length', 'logout'); + init_user($request_ref); + + return $cookie_ref{'return_url'}; +} + +=head2 get_token_using_password_credentials($username, $password) + +Gets a token for the user. + +Method uses the Resource Owner Password Credentials Grant to +with the given credentials, and pre-configured Client ID, +and Client Secret. + +=head3 Arguments + +=head4 Name of the user $usersname + +=head4 Password given at sign-in $password + +=head3 Return values + +Open ID Access token, or undefined if sign-in wasn't successful. + +=cut + +sub get_token_using_password_credentials ($username, $password) { + _ensure_oidc_is_discovered(); + + # Build a request and emit it using our app specific key + # to authenticate user + my $token_request = HTTP::Request->new(POST => $oidc_discover_document->{token_endpoint}); + $token_request->header('Content-Type' => 'application/x-www-form-urlencoded'); + $token_request->content('grant_type=password&client_id=' + . uri_escape($oidc_options{client_id}) + . '&client_secret=' + . uri_escape($oidc_options{client_secret}) + . '&username=' + . uri_escape($username) + . '&password=' + . uri_escape($password) + . "&scope=openid%20profile%20offline_access"); + + my $token_response = LWP::UserAgent::Plugin->new->request($token_request); + unless ($token_response->is_success) { + $log->info('bad password - no token returned from IdP', {content => $token_response->content}) + if $log->is_info(); + return; + } + + my $access_token = decode_json($token_response->content); + $log->info('got access token from password credentials', {access_token => $access_token}) if $log->is_info(); + return $access_token; +} + +=head2 get_token_using_client_credentials() + +Gets a token for the user. + +Method uses the Client Credentials Grant to +pre-configured Client ID, and Client Secret. + +=head3 Arguments + +None + +=head3 Return values + +Open ID Access token, or undefined if sign-in wasn't successful. + +=cut + +sub get_token_using_client_credentials () { + _ensure_oidc_is_discovered(); + + my $token_request = HTTP::Request->new(POST => $oidc_discover_document->{token_endpoint}); + $token_request->header('Content-Type' => 'application/x-www-form-urlencoded'); + $token_request->content('grant_type=client_credentials&client_id=' + . uri_escape($oidc_options{client_id}) + . '&client_secret=' + . uri_escape($oidc_options{client_secret})); + my $token_response = LWP::UserAgent::Plugin->new->request($token_request); + unless ($token_response->is_success) { + $log->info('bad client credentials - no token returned from IdP', {content => $token_response->content}) + if $log->is_info(); + return; + } + + my $access_token = decode_json($token_response->content); + $log->info('got access token client credentials', {access_token => $access_token}) if $log->is_info(); + return $access_token; +} + +=head2 generate_oidc_cookie($nonce, $user_session) + +Generate a sign-in/sign-out cookie. + +The cookie is used to store information related to the current sign-in/sign-out +for validation, and to redirect the user to the correct URL. + +=head3 Arguments + +=head4 Nonce $nonce + +=head4 Return URL after sign-in/-out $return_url + +=head3 Return values + +Sign-in/sign-out cookie. + +=cut + +sub generate_oidc_cookie ($nonce, $return_url) { + my $signin_ref = {'nonce' => $nonce, 'return_url' => $return_url}; + + my $cookie_ref = { + '-name' => $cookie_name, + '-value' => $signin_ref, + '-path' => '/', + '-domain' => $cookie_domain, + '-samesite' => 'Lax', + }; + + return cookie(%$cookie_ref); +} + +=head2 verify_access_token($access_token_string) + +Verifies the access token by decoding and validating it using the JSON Web Key Set (JWKS). +(see https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets) + +Parameters: +- $access_token_string: The access token to be verified. + +Returns: The verified access token or undefined if verification fails. + +=cut + +sub verify_access_token ($access_token_string) { + _ensure_oidc_is_discovered(); + + my $access_token_verified = decode_jwt(token => $access_token_string, kid_keys => $jwks); + $log->debug('access_token found', {access_token => $access_token_string, access_token => $access_token_verified}) + if $log->is_debug(); + unless ($access_token_verified) { + return; + } + + return $access_token_verified; +} + +=head2 verify_id_token($id_token_string) + +Verifies the ID token by decoding and validating it using the JWKS. + +Parameters: +- $id_token_string: The ID token to be verified. + +Returns: The verified ID token or undefined if verification fails. + +=cut + +sub verify_id_token ($id_token_string) { + _ensure_oidc_is_discovered(); + + my $id_token = OIDC::Lite::Model::IDToken->load($id_token_string); + my $id_token_verified = decode_jwt(token => $id_token_string, kid_keys => $jwks); + $log->debug('id_token found', {id_token => $id_token, id_token_verified => $id_token_verified}) if $log->is_debug(); + unless ($id_token_verified) { + return; + } + + return $id_token_verified; +} + +=head2 get_azp($access_token) + +Retrieves the authorized party (client ID) from the access token. + +It is different for example between the website and the mobile app. + +This is useful for example for products change log. + +=head3 Arguments + +=head4 The access token. $access_token + +=head3 Return values + +The authorized party (client ID) or undefined if the token is not issued by the correct issuer. + +=cut + +sub get_azp ($access_token) { + if (not(defined $access_token)) { + return; + } + + _ensure_oidc_is_discovered(); + + if ( (defined $oidc_discover_document->{issuer}) + and (not($oidc_discover_document->{issuer} eq $access_token->{iss}))) + { + $log->warn( + 'Given token was not issued by the correct issuer', + { + actual_iss => $access_token->{iss}, + expected_iss => $oidc_discover_document->{issuer}, + azp => $access_token->{azp}, + sub => $access_token->{sub} + } + ) if $log->is_warn(); + return; + } + + return $access_token->{azp}; +} + +=head2 _get_client() + +Get the OIDC client that is used to interact with the OIDC server. + +This subroutine creates and returns an instance of the OIDC::Lite::Client::WebServer class, which represents the client profile for OpenID Connect (OIDC) authentication. The client profile is used to interact with the OIDC server for authentication and authorization purposes. + +The client profile is created with the following parameters: +- id: The client ID provided by the OIDC server. +- secret: The client secret provided by the OIDC server. +- authorize_uri: The authorization endpoint URL provided by the OIDC server. +- access_token_uri: The token endpoint URL provided by the OIDC server. + +If the client profile has already been created, it is returned directly without re-creating it. + +See L for more information on the OIDC::Lite::Client::WebServer module. + +=head3 Arguments + +None. + +=head3 Return values + +A workable instance of OIDC::Lite::Client::WebServer. + +=cut + +sub _get_client () { + if ($client) { + return $client; + } + + _ensure_oidc_is_discovered(); + $client = OIDC::Lite::Client::WebServer->new( + id => $oidc_options{client_id}, + secret => $oidc_options{client_secret}, + authorize_uri => $oidc_discover_document->{authorization_endpoint}, + access_token_uri => $oidc_discover_document->{token_endpoint}, + ); + return $client; +} + +=head2 _ensure_oidc_is_discovered( ) + +Ensures that OIDC (OpenID Connect) is discovered and configured. + +If OIDC is already discovered, the function returns without doing anything. + +Otherwise, it sends a discovery request to the OIDC endpoint and loads the discovery document. +If successful, it updates the OIDC options with the JWKS (JSON Web Key Set) configuration. + +=head3 Arguments + +None. + +=head3 Return values + +None. + +=cut + +sub _ensure_oidc_is_discovered () { + if ($jwks) { + return; + } + + $log->info('Original OIDC configuration', {discovery_endpoint => $oidc_options{discovery_endpoint}}) + if $log->is_info(); + + my $discovery_request = HTTP::Request->new(GET => $oidc_options{discovery_endpoint}); + my $discovery_response = LWP::UserAgent::Plugin->new->request($discovery_request); + unless ($discovery_response->is_success) { + $log->info('Unable to load OIDC data from IdP', {response => $discovery_response->content}) if $log->is_info(); + return; + } + + $oidc_discover_document = decode_json($discovery_response->content); + $log->info('got discovery document', {discovery => $oidc_discover_document}) if $log->is_info(); + + _load_jwks_configuration_to_oidc_options($oidc_discover_document->{jwks_uri}); + + return; +} + +=head2 _load_jwks_configuration_to_oidc_options( $jwks_uri ) + +Loads the JWKS from $jwks_uri, and stores it in the $jkw variable. + +JWKS aka JSON Web Key Sets are essential to validate access tokens +https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets + +=head3 Arguments + +=head4 URI to the JWKS. $jwks_uri + +=head3 Return values + +None. + +=cut + +sub _load_jwks_configuration_to_oidc_options ($jwks_uri) { + my $jwks_request = HTTP::Request->new(GET => $jwks_uri); + my $jwks_response = LWP::UserAgent::Plugin->new->request($jwks_request); + unless ($jwks_response->is_success) { + $log->info('Unable to load JWKS from IdP', {response => $jwks_response->content}) if $log->is_info(); + return; + } + + $jwks = decode_json($jwks_response->content); + $log->info('got JWKS', {jwks => $jwks}) if $log->is_info(); + return; +} + +=head2 write_auth_deprecated_headers() + +Writes the deprecation notice for old authentication sites as HTTP headers. + +=head3 Arguments + +None. + +=head3 Return values + +None. + +=cut + +sub write_auth_deprecated_headers() { + my $r = Apache2::RequestUtil->request(); + $r->err_headers_out->set('Deprecation', 'Mon, 01 Apr 2024 00:00:00 GMT'); + $r->err_headers_out->set('Sunset', 'Tue, 01 Apr 2025 18:00:00 GMT'); + return; +} + +1; diff --git a/lib/ProductOpener/Config2_docker.pm b/lib/ProductOpener/Config2_docker.pm index 251d2e5f30d3b..669a489316cf1 100755 --- a/lib/ProductOpener/Config2_docker.pm +++ b/lib/ProductOpener/Config2_docker.pm @@ -55,6 +55,7 @@ BEGIN { $facets_kp_url $events_username $events_password + %oidc_options $redis_url %server_options $build_cache_repo @@ -126,6 +127,17 @@ $events_url = $ENV{EVENTS_URL}; $events_username = $ENV{EVENTS_USERNAME}; $events_password = $ENV{EVENTS_PASSWORD}; +%oidc_options = ( + client_id => $ENV{PRODUCT_OPENER_OIDC_CLIENT_ID}, + client_secret => $ENV{PRODUCT_OPENER_OIDC_CLIENT_SECRET}, + discovery_endpoint => $ENV{PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT}, + # Keycloak specific endpoint used to create users. This is currently required for backwards compatibility with apps + # that create users by POSTing to /cgi/user.pl + keycloak_base_url => $ENV{KEYCLOAK_BASE_URL}, + keycloak_backchannel_base_url => $ENV{KEYCLOAK_BACKCHANNEL_BASE_URL}, + keycloak_realm_name => $ENV{KEYCLOAK_REALM_NAME} +); + # Set this to your instance of https://github.com/openfoodfacts/facets-knowledge-panels # Inject facet knowledge panels $facets_kp_url = $ENV{FACETS_KP_URL}; diff --git a/lib/ProductOpener/Config2_sample.pm b/lib/ProductOpener/Config2_sample.pm index dfa2978ef82dd..0f714ea21f9d5 100644 --- a/lib/ProductOpener/Config2_sample.pm +++ b/lib/ProductOpener/Config2_sample.pm @@ -47,6 +47,7 @@ BEGIN { $events_url $events_username $events_password + %oidc_options $redis_url %server_options @@ -90,6 +91,22 @@ $events_url = ''; $events_username = ''; $events_password = ''; +# Set this to match your instance of Keycloak +%oidc_options = ( + # This is the client ID of the "open-products-facts" client in Keycloak + client_id => '', + # This is the client secret of the "open-products-facts" client in Keycloak + client_secret => '', + # Well-known endpoint used to discover metadata about the OIDC provider + discovery_endpoint => '', + # Keycloak specific: Base URL for the Keycloak server + keycloak_base_url => '', + # Keycloak specific: Base URL for the backchannel communcation: https://www.keycloak.org/server/hostname + keycloak_backchannel_base_url => '', + # Keycloak specific: Name of the realm + keycloak_realm_name => '' +); + $redis_url = ''; %server_options = ( diff --git a/lib/ProductOpener/Config_off.pm b/lib/ProductOpener/Config_off.pm index 2e0fc1dc5e946..632a75d7ca3e1 100644 --- a/lib/ProductOpener/Config_off.pm +++ b/lib/ProductOpener/Config_off.pm @@ -80,6 +80,7 @@ BEGIN { %options %server_options + %oidc_options @product_fields @product_other_fields @@ -470,6 +471,7 @@ $rate_limiter_blocking_enabled = $ProductOpener::Config2::rate_limiter_blocking_ # server options %server_options = %ProductOpener::Config2::server_options; +%oidc_options = %ProductOpener::Config2::oidc_options; $build_cache_repo = $ProductOpener::Config2::build_cache_repo; diff --git a/lib/ProductOpener/Display.pm b/lib/ProductOpener/Display.pm index a2e9b65754ddf..1ddc29eb73d61 100644 --- a/lib/ProductOpener/Display.pm +++ b/lib/ProductOpener/Display.pm @@ -1,7 +1,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -169,7 +169,7 @@ use ProductOpener::Recipes qw(add_product_recipe_to_set analyze_recipes compute_ use ProductOpener::PackagerCodes qw($ec_code_regexp %geocode_addresses %packager_codes init_geocode_addresses init_packager_codes); use ProductOpener::Export qw(export_csv); -use ProductOpener::API qw(add_error customize_response_for_product process_api_request); +use ProductOpener::API qw(add_error customize_response_for_product process_api_request process_auth_header); use ProductOpener::Units qw/g_to_unit/; use ProductOpener::Cache qw/$max_memcached_object_size $memd generate_cache_key/; use ProductOpener::Permissions qw/has_permission/; @@ -177,7 +177,7 @@ use ProductOpener::ProductsFeatures qw(feature_enabled); use ProductOpener::RequestStats qw(:all); use Encode; -use URI::Escape::XS; +use URI::Escape::XS qw/uri_escape/; use CGI::Carp qw(fatalsToBrowser); use CGI qw(:cgi :cgi-lib :form escapeHTML charset); use HTML::Entities; @@ -395,6 +395,11 @@ sub process_template ($template_filename, $template_data_ref, $result_content_re (not defined $template_data_ref->{org_id}) and $template_data_ref->{org_id} = $Org_id; $template_data_ref->{owner_pretty_path} = get_owner_pretty_path(); + if (defined $template_data_ref->{user_id} and defined $template_data_ref->{canon_url}) { + $template_data_ref->{keycloak_account_link} + = ProductOpener::Keycloak->new()->get_account_link($template_data_ref->{canon_url}); + } + $template_data_ref->{flavor} = $flavor; $template_data_ref->{options} = \%options; $template_data_ref->{product_type} = $options{product_type}; @@ -474,6 +479,10 @@ sub process_template ($template_filename, $template_data_ref, $result_content_re return $json->encode($var); }; + $template_data_ref->{uri_escape} = sub ($var) { + return uri_escape($var); + }; + return ($tt->process($template_filename, $template_data_ref, $result_content_ref)); } @@ -861,6 +870,21 @@ sub init_request ($request_ref = {}) { } ) if $log->is_debug(); + my $signed_in_oidc = process_auth_header($request_ref, $r); + if ($signed_in_oidc < 0) { + # We were sent a bad bearer token + # Otherwise we return an error page in HTML (including for v0 / v1 / v2 API queries) + if (not((defined $request_ref->{api_version}) and ($request_ref->{api_version} >= 3)) + and (not($r->uri() =~ /\/cgi\/auth\.pl/))) + { + $log->debug( + "init_request - init_user error - display error page", + {init_user_error => $request_ref->{init_user_error}} + ) if $log->is_debug(); + display_error_and_exit($request_ref, $signed_in_oidc, 403); + } + } + my $error = ProductOpener::Users::init_user($request_ref); if ($error) { # We were sent bad user_id / password credentials @@ -10745,6 +10769,7 @@ sub display_product_history ($request_ref, $code, $product_ref) { userid => $userid, uuid => $uuid, app_version => $app_version, + clientid => $change_ref->{clientid}, diffs => compute_changes_diff_text($change_ref), comment => $comment }; diff --git a/lib/ProductOpener/Keycloak.pm b/lib/ProductOpener/Keycloak.pm new file mode 100644 index 0000000000000..9a6f2b66cf993 --- /dev/null +++ b/lib/ProductOpener/Keycloak.pm @@ -0,0 +1,280 @@ +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2024 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +=head1 NAME + +ProductOpener::Keycloak - Perl module for Keycloak user management + +=head1 DESCRIPTION + +This Perl module provides a class that can be used to access Keycloak's user management API. + +=cut + +package ProductOpener::Keycloak; + +use ProductOpener::PerlStandards; + +use Log::Any qw($log); + +use ProductOpener::Auth qw/get_token_using_client_credentials/; +use ProductOpener::Config qw/:all/; + +use JSON; +use LWP::UserAgent; +use LWP::UserAgent::Plugin 'Retry'; +use HTTP::Request; +use URI::Escape::XS qw/uri_escape/; + +sub new($class) { + my $self = {}; + bless $self, $class; + + unless ((defined $oidc_options{keycloak_base_url}) + and (defined $oidc_options{keycloak_backchannel_base_url}) + and (defined $oidc_options{keycloak_realm_name})) + { + die 'keycloak_base_url or keycloak_backchannel_base_url or keycloak_realm_name not configured'; + } + + $self->{users_endpoint} + = $oidc_options{keycloak_backchannel_base_url} + . '/admin/realms/' + . uri_escape($oidc_options{keycloak_realm_name}) + . '/users'; + + $self->{account_service} + = $oidc_options{keycloak_base_url} . '/realms/' . uri_escape($oidc_options{keycloak_realm_name}) . '/account'; + + return $self; +} + +=head2 get_or_refresh_token() + +Retrieves or refreshes the access token for managing users with Keycloak. + +If the token is not defined, it retrieves a new token using client credentials. +If the token is defined but has expired, it refreshes the token. +The token is stored in the object and its expiration time is updated. + +=head3 Arguments + +None + +=head3 Return values + +Returns the access token. +Throws an exception if the token cannot be obtained. + +=cut + +sub get_or_refresh_token ($self) { + if (not(defined $self->{token})) { + $self->{token} = get_token_using_client_credentials(); + $self->{token}->{expires_at} = time() + $self->{token}->{expires_in}; + } + else { + my $now = time(); + my $cutoff = $self->{token}->{expires_at} - 15; + if ($now > $self->{token}->{expires_at}) { + $self->{token} = get_token_using_client_credentials(); + $self->{token}->{expires_at} = time() + $self->{token}->{expires_in}; + } + } + + return $self->{token} // die 'Could not get token to manage users with users_endpoint'; +} + +=head2 create_user ($user_ref, $password) + +Create use on keycloak side. + +This is needed as we register new users via an old, undocumented API function. +We create the user properties file locally before, and we create the user in keycloak in this sub. + +=head3 Arguments + +=head4 User info hashmap reference $user_ref + +=head4 String $password + +=head3 Return Value + +A hashmap reference with created user information. + +=cut + +sub create_user ($self, $user_ref, $password) { + # use a special application authorization to handle creation + my $token = $self->get_or_refresh_token(); + unless ($token) { + display_error_and_exit('Could not get token to manage users with keycloak_users_endpoint', 500); + } + + # user creation payload + my $api_request_ref = { + email => $user_ref->{email}, + emailVerified => $JSON::PP::true, # TODO: Keep this for compat with current register endpoint? + enabled => $JSON::PP::true, + username => $user_ref->{userid}, + credentials => [ + { + type => 'password', + temporary => $JSON::PP::false, + value => $password + } + ], + attributes => [ + name => $user_ref->{name}, + locale => $user_ref->{preferred_language}, + country => $user_ref->{country}, + reqested_org => $user_ref->{requested_org}, + newsletter => ($user_ref->{newsletter} ? 'subscribe' : undef) + ] + }; + my $json = encode_json($api_request_ref); + + # create request with right headers + my $create_user_request = HTTP::Request->new(POST => $self->{users_endpoint}); + $create_user_request->header('Content-Type' => 'application/json'); + $create_user_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token}); + $create_user_request->content($json); + # issue the request to keycloak + my $new_user_response = LWP::UserAgent::Plugin->new->request($create_user_request); + unless ($new_user_response->is_success) { + display_error_and_exit($new_user_response->content, 500); + } + + # continue the process by fetching user data, + # which profile location is given in previous response + my $get_user_request = HTTP::Request->new(GET => $new_user_response->header('location')); + $get_user_request->header('Content-Type' => 'application/json'); + $get_user_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token}); + my $get_user_response = LWP::UserAgent::Plugin->new->request($get_user_request); + unless ($get_user_response->is_success) { + display_error_and_exit($get_user_response->content, 500); + } + + my $json_response = $get_user_response->decoded_content(charset => 'UTF-8'); + my @created_users = decode_json($json_response); + return $created_users[0]; +} + +=head2 find_user_by_username ($username) + +Try to find a user in Keycloak by their username. + +=head3 Arguments + +=head4 User's username $username + +=head3 Return Value + +A hashmap reference with user information from Keycloak. + +=cut + +sub find_user_by_username ($self, $username) { + return $self->_find_user_by_single_attribute_exact('username', $username); +} + +=head2 find_user_by_email ($mail) + +Try to find a user in Keycloak by their mail address. + +=head3 Arguments + +=head4 User's mail address $mail + +=head3 Return Value + +A hashmap reference with user information from Keycloak. + +=cut + +sub find_user_by_email ($self, $email) { + return $self->_find_user_by_single_attribute_exact('email', $email); +} + +=head2 get_account_link() + +Gets the link to the account service on Keycloak. + +=head3 Arguments + +=head4 Canonical URL of the current site string $url + +=head3 Return values + +Returns the URL. + +=cut + +sub get_account_link ($self, $url) { + return + $self->{account_service} + . '?referrer=' + . uri_escape($oidc_options{client_id}) + . '&referrer_uri=' + . uri_escape($url); +} + +=head2 _find_user_by_single_attribute_exact ($name, $value) + +Try to find a user in Keycloak by a single attribute key/value combo. + +This should only be used with unique attributes like email or username. + +=head3 Arguments + +=head4 Name of the attribute $name + +=head4 Value of the attribute $value + +=head3 Return Value + +A hashmap reference with user information from Keycloak. + +=cut + +sub _find_user_by_single_attribute_exact ($self, $name, $value) { + # use a special application authorization to handle search + my $token = $self->get_or_refresh_token(); + unless ($token) { + display_error_and_exit('Could not get token to search users with keycloak_users_endpoint', 500); + } + + # create request with right headers + my $search_uri = $self->{users_endpoint} . '?exact=true&' . uri_escape($name) . '=' . uri_escape($value); + my $search_user_request = HTTP::Request->new(GET => $search_uri); + $search_user_request->header('Accept' => 'application/json'); + $search_user_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token}); + # issue the request to keycloak + my $search_user_response = LWP::UserAgent::Plugin->new->request($search_user_request); + unless ($search_user_response->is_success) { + display_error_and_exit($search_user_response->content, 500); + } + + my $json_response = $search_user_response->decoded_content(charset => 'UTF-8'); + my $users = decode_json($json_response); + return $$users[0]; +} + +1; diff --git a/lib/ProductOpener/Minion.pm b/lib/ProductOpener/Minion.pm new file mode 100644 index 0000000000000..d13a06bb71df3 --- /dev/null +++ b/lib/ProductOpener/Minion.pm @@ -0,0 +1,91 @@ +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2024 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +=head1 NAME + +ProductOpener::Minion - functions to integrate with minion + +=head1 DESCRIPTION + +C is handling pushing info to Redis +to communicate updates to all services, including search-a-licious, +as well as receiving updates from other services like Keycloak. + +=cut + +package ProductOpener::Minion; + +use ProductOpener::PerlStandards; +use Exporter qw< import >; + +use Log::Any qw($log); + +BEGIN { + use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS); + @EXPORT_OK = qw( + + &get_minion + &queue_job + + ); # symbols to export on request + %EXPORT_TAGS = (all => [@EXPORT_OK]); +} + +use vars @EXPORT_OK; + +use ProductOpener::Config qw/:all/; + +use Minion; + +# Minion backend +my $minion; + +=head2 get_minion() + +Function to get the backend minion + +=head3 Arguments + +None + +=head3 Return values + +The backend minion $minion + +=cut + +sub get_minion() { + if (not defined $minion) { + if (not defined $server_options{minion_backend}) { + print STDERR "No Minion backend configured in lib/ProductOpener/Config2.pm\n"; + } + else { + print STDERR "Initializing Minion backend configured in lib/ProductOpener/Config2.pm\n"; + $minion = Minion->new(%{$server_options{minion_backend}}); + } + } + return $minion; +} + +sub queue_job { ## no critic (Subroutines::RequireArgUnpacking) + return get_minion()->enqueue(@_); +} + +1; diff --git a/lib/ProductOpener/Producers.pm b/lib/ProductOpener/Producers.pm index 5e6ceb8aecf3b..d4102ef91a9fb 100644 --- a/lib/ProductOpener/Producers.pm +++ b/lib/ProductOpener/Producers.pm @@ -1,7 +1,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -82,6 +82,7 @@ use ProductOpener::Export qw/export_csv/; use ProductOpener::Import qw/$IMPORT_MAX_PACKAGING_COMPONENTS import_csv_file import_products_categories_from_public_database/; use ProductOpener::ImportConvert qw/clean_fields/; +use ProductOpener::Minion qw/get_minion/; use ProductOpener::Users qw/$Org_id $Owner_id $User_id %User/; use ProductOpener::Orgs qw/update_export_date/; @@ -93,37 +94,6 @@ use JSON::MaybeXS; use Time::Local; use Data::Dumper; use Text::CSV(); -use Minion; - -# Minion backend -my $minion; - -=head2 get_minion() - -Function to get the backend minion - -=head3 Arguments - -None - -=head3 Return values - -The backend minion $minion - -=cut - -sub get_minion() { - if (not defined $minion) { - if (not defined $server_options{minion_backend}) { - print STDERR "No Minion backend configured in lib/ProductOpener/Config2.pm\n"; - } - else { - print STDERR "Initializing Minion backend configured in lib/ProductOpener/Config2.pm\n"; - $minion = Minion->new(%{$server_options{minion_backend}}); - } - } - return $minion; -} =head1 FUNCTIONS @@ -2071,8 +2041,4 @@ sub update_export_status_for_csv_file_task ($job, $args_ref) { return; } -sub queue_job { ## no critic (Subroutines::RequireArgUnpacking) - return get_minion()->enqueue(@_); -} - 1; diff --git a/lib/ProductOpener/Products.pm b/lib/ProductOpener/Products.pm index 8f1781309af76..6720e2cb0ebc3 100644 --- a/lib/ProductOpener/Products.pm +++ b/lib/ProductOpener/Products.pm @@ -789,7 +789,7 @@ sub get_owner_id ($userid, $orgid, $ownerid) { return $ownerid; } -=head2 init_product ( $userid, $orgid, $code, $countryid ) +=head2 init_product ( $userid, $orgid, $code, $countryid, $client_id = undef ) Initializes and return a $product_ref structure for a new product. If $countryid is defined and is not "en:world", then assign this country for the countries field. @@ -801,7 +801,7 @@ Returns a $product_ref structure =cut -sub init_product ($userid, $orgid, $code, $countryid) { +sub init_product ($userid, $orgid, $code, $countryid, $client_id = undef) { $log->debug("init_product", {userid => $userid, orgid => $orgid, code => $code, countryid => $countryid}) if $log->is_debug(); @@ -832,9 +832,8 @@ sub init_product ($userid, $orgid, $code, $countryid) { product_type => $options{product_type}, }; - if (defined $server) { - $product_ref->{server} = $server; - } + $product_ref->{server} = $server if defined $server; + $product_ref->{created_by_client} = $client_id if defined $client_id; if ((defined $server_options{private_products}) and ($server_options{private_products})) { my $ownerid = get_owner_id($userid, $orgid, $Owner_id); @@ -1135,7 +1134,7 @@ sub compute_sort_keys ($product_ref) { return; } -=head2 store_product ($user_id, $product_ref, $comment) +=head2 store_product ($user_id, $product_ref, $comment, $client_id = undef) Save changes of a product: - in a new .sto file on the disk @@ -1145,7 +1144,7 @@ Before saving, some field values are computed, and product history and completen =cut -sub store_product ($user_id, $product_ref, $comment) { +sub store_product ($user_id, $product_ref, $comment, $client_id = undef) { my $code = $product_ref->{code}; my $product_id = $product_ref->{_id}; @@ -1377,6 +1376,7 @@ sub store_product ($user_id, $product_ref, $comment) { # last_modified_t is the date of the last change of the product raw data # last_updated_t is the date of the last change of the product derived data (e.g. ingredient analysis, scores etc.) $product_ref->{last_modified_by} = $user_id; + $product_ref->{last_modified_by_client} = $client_id if defined $client_id; $product_ref->{last_modified_t} = time() + 0; $product_ref->{last_updated_t} = $product_ref->{last_modified_t}; if (not exists $product_ref->{creator}) { @@ -1396,6 +1396,7 @@ sub store_product ($user_id, $product_ref, $comment) { my $change_ref = { userid => $user_id, + clientid => $client_id, ip => remote_addr(), t => $product_ref->{last_modified_t}, comment => $comment, diff --git a/lib/ProductOpener/Redis.pm b/lib/ProductOpener/Redis.pm index a15137665c019..beef196a5c71c 100644 --- a/lib/ProductOpener/Redis.pm +++ b/lib/ProductOpener/Redis.pm @@ -1,18 +1,37 @@ +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2024 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . =head1 NAME -ProductOpener::Redis - functions to push information to redis +ProductOpener::Redis - functions to integrate with redis =head1 DESCRIPTION C is handling pushing info to Redis -to communicate updates to all services, including search-a-licious. +to communicate updates to all services, including search-a-licious, +as well as receiving updates from other services like Keycloak. =cut package ProductOpener::Redis; -use ProductOpener::Config qw/:all/; use ProductOpener::PerlStandards; use Exporter qw< import >; use Encode; @@ -23,7 +42,10 @@ BEGIN { @EXPORT_OK = qw( &get_rate_limit_user_requests &increment_rate_limit_requests + &subscribe_to_redis_streams &push_to_redis_stream + + &process_xread_stream_reply ); # symbols to export on request %EXPORT_TAGS = (all => [@EXPORT_OK]); } @@ -31,8 +53,13 @@ BEGIN { use vars @EXPORT_OK; use Log::Any qw/$log/; -use ProductOpener::Config qw/$redis_url/; -use Redis; +use ProductOpener::Config qw/:all/; +use ProductOpener::Minion qw/queue_job/; +use ProductOpener::Users qw/retrieve_user store_user/; +use ProductOpener::Text qw/remove_tags_and_quote/; +use ProductOpener::Store qw/get_string_id_for_lang/; +use AnyEvent; +use AnyEvent::RipeRedis; =head2 $redis_client @@ -60,11 +87,26 @@ sub init_redis() { $log->debug("init_redis", {redis_url => $redis_url}) if $log->is_debug(); eval { - $redis_client = Redis->new( - server => $redis_url, + my ($host, $port) = split /:/, $redis_url; + $redis_client = AnyEvent::RipeRedis->new( + host => $host, + port => $port, # we don't want to sacrifice too much performance for redis problems cnx_timeout => 1, write_timeout => 1, + on_connect => sub { + $log->info("Connected to Redis") if $log->is_info(); + }, + + on_disconnect => sub { + $log->info("Disconnected from Redis") if $log->is_info(); + }, + + on_error => sub { + my $err = shift; + + $log->warn("Error from Redis", {error => $err}) if $log->is_warn(); + }, ); }; if ($@) { @@ -74,6 +116,180 @@ sub init_redis() { return; } +=head2 subscribe_to_redis_streams () + +Subscribe to redis stream to be informed about user deletions. + +=cut + +sub subscribe_to_redis_streams () { + if (!$redis_url) { + # No Redis URL provided, we can't push to Redis + if (!$sent_warning_about_missing_redis_url) { + $log->warn("Redis URL not provided for streaming") if $log->is_warn(); + $sent_warning_about_missing_redis_url = 1; + } + return; + } + + if (!defined $redis_client) { + # we where deconnected, try again + $log->info("Trying to reconnect to Redis") if $log->is_info(); + init_redis(); + } + + if (!defined $redis_client) { + $log->warn("Can't connect to Redis") if $log->is_warn(); + return; + } + + _read_user_streams('$'); + + return; +} + +sub _read_user_streams($search_from) { + my @streams = ('user-registered', 'user-deleted', $search_from, $search_from); + + $log->info("Reading from Redis", {streams => \@streams}) if $log->is_info(); + $redis_client->xread( + 'BLOCK' => 0, + 'STREAMS' => 'user-deleted', + 'user-registered', + $search_from, + $search_from, + sub { + my ($reply_ref, $err) = @_; + if ($err) { + $log->warn("Error reading from Redis", {error => $err}) if $log->is_warn(); + return; + } + + if ($reply_ref) { + my $last_processed_message_id = process_xread_stream_reply($reply_ref); + if ($last_processed_message_id) { + $search_from = $last_processed_message_id; + } + } + + _read_user_streams($search_from); + return; + } + ); + + return; +} + +sub process_xread_stream_reply($reply_ref) { + my $last_processed_message_id; + + my @streams = @{$reply_ref}; + foreach my $stream_ref (@streams) { + my @stream = @{$stream_ref}; + if ($stream[0] eq 'user-registered') { + $last_processed_message_id = _process_registered_users_stream($stream[1]); + } + elsif ($stream[0] eq 'user-deleted') { + $last_processed_message_id = _process_deleted_users_stream($stream[1]); + } + + } + + return $last_processed_message_id,; +} + +sub _process_registered_users_stream($stream_values_ref) { + my $last_processed_message_id; + + foreach my $outer_ref (@{$stream_values_ref}) { + my @outer = @{$outer_ref}; + my $message_id = $outer[0]; + my @values = @{$outer[1]}; + + my %message_hash; + for (my $i = 0; $i < scalar(@values); $i += 2) { + my $key = $values[$i]; + my $value = $values[$i + 1]; + $message_hash{$key} = $value; + } + + my $user_id = $message_hash{'userName'}; + my $newsletter = $message_hash{'newsletter'}; + my $requested_org = $message_hash{'requestedOrg'}; + my $email = $message_hash{'email'}; + + $log->info("User registered", {user_id => $user_id, newsletter => $newsletter}) + if $log->is_info(); + + # Create the user if they don't exist and set the properties + my $user_ref = retrieve_user($user_id); + unless ($user_ref) { + # This doesn't set registered_t and other fields, + # these are updated in Auth.pm when the user is redirected back to PO + $user_ref = { + userid => $user_id, + name => $user_id + }; + } + $user_ref->{email} = $email; + if (defined $requested_org) { + $user_ref->{requested_org} = remove_tags_and_quote(decode utf8 => $requested_org); + + my $requested_org_id = get_string_id_for_lang("no_language", $user_ref->{requested_org}); + + if ($requested_org_id ne "") { + $user_ref->{requested_org_id} = $requested_org_id; + $user_ref->{pro} = 1; + } + } + store_user($user_ref); + + my $args_ref = {userid => $user_id}; + + queue_job(welcome_user => [$args_ref] => {queue => $server_options{minion_local_queue}}); + + # Subscribe to newsletter + if ($newsletter eq 'subscribe') { + queue_job(subscribe_user_newsletter => [$args_ref] => {queue => $server_options{minion_local_queue}}); + } + + # Register interest in joining an organisation + if (defined $requested_org) { + queue_job(process_user_requested_org => [$args_ref] => {queue => $server_options{minion_local_queue}}); + } + + $last_processed_message_id = $message_id; + } + + return $last_processed_message_id; +} + +sub _process_deleted_users_stream($stream_values_ref) { + my $last_processed_message_id; + + foreach my $outer_ref (@{$stream_values_ref}) { + my @outer = @{$outer_ref}; + my $message_id = $outer[0]; + my @values = @{$outer[1]}; + + my %message_hash; + for (my $i = 0; $i < scalar(@values); $i += 2) { + my $key = $values[$i]; + my $value = $values[$i + 1]; + $message_hash{$key} = $value; + } + + $log->info("User deleted", {user_id => $message_hash{'userName'}}) if $log->is_info(); + + my $args_ref = {userid => $message_hash{'userName'}}; + queue_job(delete_user => [$args_ref] => {queue => $server_options{minion_local_queue}}); + + $last_processed_message_id = $message_id; + } + + return $last_processed_message_id; +} + =head2 push_to_redis_stream ($user_id, $product_ref, $action, $comment, $diffs) Add an event to Redis stream to inform that a product was updated. @@ -123,6 +339,7 @@ sub push_to_redis_stream ($user_id, $product_ref, $action, $comment, $diffs, $ti if (defined $redis_client) { $log->debug("Pushing product update to Redis", {product_code => $product_ref->{code}}) if $log->is_debug(); eval { + my $cv = AE::cv; $redis_client->xadd( # name of the Redis stream $options{redis_stream_name}, @@ -135,11 +352,32 @@ sub push_to_redis_stream ($user_id, $product_ref, $action, $comment, $diffs, $ti 'code', Encode::encode_utf8($product_ref->{code}), 'rev', Encode::encode_utf8($product_ref->{rev}), # product_type should be used over flavor (kept for backward compatibility) - 'product_type', $options{product_type}, - 'flavor', $options{current_server}, - 'user_id', Encode::encode_utf8($user_id), 'action', Encode::encode_utf8($action), - 'comment', Encode::encode_utf8($comment), 'diffs', encode_json($diffs) + 'product_type', + $options{product_type}, + 'flavor', + $options{current_server}, + 'user_id', + Encode::encode_utf8($user_id), + 'action', + Encode::encode_utf8($action), + 'comment', + Encode::encode_utf8($comment), + 'diffs', + encode_json($diffs), + sub { + my ($reply, $err) = @_; + if (defined $err) { + $log->warn("Error adding data to stream", {error => $err}) if $log->is_warn(); + } + else { + $log->info("Data added to stream with ID", {reply => $reply}) if $log->is_info(); + } + + $cv->send; + return; + } ); + $cv->recv; }; $error = $@; } diff --git a/lib/ProductOpener/Test.pm b/lib/ProductOpener/Test.pm index a7d2fea99018f..2126748cd38db 100644 --- a/lib/ProductOpener/Test.pm +++ b/lib/ProductOpener/Test.pm @@ -1,7 +1,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -66,6 +66,7 @@ use ProductOpener::Config qw/:all/; use ProductOpener::Paths qw/%BASE_DIRS/; use ProductOpener::Data qw/execute_query get_products_collection/; use ProductOpener::Store "store"; +use ProductOpener::Auth qw/get_token_using_client_credentials/; use Carp qw/confess/; use Data::DeepAccess qw(deep_exists deep_get deep_set); @@ -79,6 +80,7 @@ use File::Path qw/make_path remove_tree/; use File::Copy; use Path::Tiny qw/path/; use Scalar::Util qw(looks_like_number); +use URI::Escape::XS qw/uri_escape/; use Test::File::Contents qw/files_eq_or_diff/; use Log::Any qw($log); @@ -243,11 +245,18 @@ sub remove_all_users () { # Important: check we are not on a prod database check_not_production(); # clean files - # clean files remove_tree($BASE_DIRS{USERS}, {keep_root => 1, error => \my $err}); if (@$err) { confess("not able to remove some users directories: " . join(":", @$err)); } + # clean keycloak + my @users = get_users_from_keycloak(); + foreach (@users) { + foreach (@{$_}) { + _delete_user_from_keycloak($_); + } + } + return; } =head2 remove_all_orgs () @@ -890,4 +899,78 @@ sub wait_for ($code, $timeout = 3, $poll_time = 1) { return $code->(); } +=head2 get_users_from_keycloak() + +Get a list of users registered in our Keycloak realm + +=head3 Return values + +Returns an array of users in Keycloak. + +=cut + +sub get_users_from_keycloak () { + unless ((defined $oidc_options{keycloak_backchannel_base_url}) and (defined $oidc_options{keycloak_realm_name})) { + confess('keycloak_backchannel_base_url and keycloak_realm_name not configured'); + } + + my $token = get_token_using_client_credentials(); + unless ($token) { + confess('Could not get token to manage users with keycloak_users_endpoint'); + } + + my $keycloak_users_endpoint + = $oidc_options{keycloak_backchannel_base_url} + . '/admin/realms/' + . uri_escape($oidc_options{keycloak_realm_name}) + . '/users'; + my $get_users_request = HTTP::Request->new(GET => $keycloak_users_endpoint); + $get_users_request->header('Accept' => 'application/json'); + $get_users_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token}); + my $get_users_response = LWP::UserAgent->new->request($get_users_request); + unless ($get_users_response->is_success) { + confess($get_users_response->content); + } + + my @users = decode_json($get_users_response->content); + return @users; +} + +=head2 _delete_user_from_keycloak($keycloak_user) + +Removes the given users from our Keycloak realm + +=head3 parameters + +=head4 $user - sub + +The user that will be deleted from Keycloak + +=cut + +sub _delete_user_from_keycloak ($user) { + unless ((defined $oidc_options{keycloak_backchannel_base_url}) and (defined $oidc_options{keycloak_realm_name})) { + confess('keycloak_backchannel_base_url and keycloak_realm_name not configured'); + } + + my $token = get_token_using_client_credentials(); + unless ($token) { + confess('Could not get token to manage users with keycloak_users_endpoint'); + } + + my $keycloak_users_endpoint + = $oidc_options{keycloak_backchannel_base_url} + . '/admin/realms/' + . uri_escape($oidc_options{keycloak_realm_name}) + . '/users'; + my $delete_user_request = HTTP::Request->new(DELETE => $keycloak_users_endpoint . '/' . $user->{id}); + $delete_user_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token}); + my $delete_user_response = LWP::UserAgent->new->request($delete_user_request); + unless ($delete_user_response->is_success) { + confess($delete_user_response->content); + } + + return; +} + 1; diff --git a/lib/ProductOpener/TestDefaults.pm b/lib/ProductOpener/TestDefaults.pm index 389812444df7b..4313dcea0266d 100644 --- a/lib/ProductOpener/TestDefaults.pm +++ b/lib/ProductOpener/TestDefaults.pm @@ -1,7 +1,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -50,7 +50,7 @@ The default test password =cut -my $test_password = "testtest"; +my $test_password = "!!!TestTest1!!!"; =head2 %default_user_form diff --git a/lib/ProductOpener/URL.pm b/lib/ProductOpener/URL.pm index fe253558629cc..92b4bf985e328 100644 --- a/lib/ProductOpener/URL.pm +++ b/lib/ProductOpener/URL.pm @@ -1,7 +1,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -50,6 +50,7 @@ BEGIN { use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS); @EXPORT_OK = qw( &format_subdomain + &get_cookie_domain ); # symbols to export on request %EXPORT_TAGS = (all => [@EXPORT_OK]); @@ -115,4 +116,29 @@ sub subdomain_supports_https ($sd) { } +=head2 get_cookie_domain( ) + +C gets the domain that should be used for cookies. + +=head3 Arguments + +None. + +=head3 Return Values + +A URL that the server should use when emitting cookies. + +=cut + +sub get_cookie_domain() { + my $cookie_domain = '.' . $server_domain; # e.g. fr.openfoodfacts.org sets the domain to .openfoodfacts.org + $cookie_domain =~ s/\.pro\./\./; # e.g. .pro.openfoodfacts.org -> .openfoodfacts.org + if (defined $server_options{cookie_domain}) { + $cookie_domain + = '.' . $server_options{cookie_domain}; # e.g. fr.import.openfoodfacts.org sets domain to .openfoodfacts.org + } + + return $cookie_domain; +} + 1; diff --git a/lib/ProductOpener/Users.pm b/lib/ProductOpener/Users.pm index 4cf64aeeec22c..7d5a0150c94a7 100644 --- a/lib/ProductOpener/Users.pm +++ b/lib/ProductOpener/Users.pm @@ -1,7 +1,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -62,22 +62,24 @@ BEGIN { &init_user &is_admin_user - &create_password_hash - &check_password_hash &retrieve_user &retrieve_userids - &retrieve_user_by_email &store_user &store_user_session &remove_user_by_org_admin &add_users_to_org_by_admin &is_suspicious_name + &is_email_has_off_account &check_session + &open_user_session &generate_token &update_login_time + &welcome_user_task + &delete_user_task + ); # symbols to export on request %EXPORT_TAGS = (all => [@EXPORT_OK]); } @@ -96,15 +98,15 @@ use ProductOpener::Products qw/find_and_replace_user_id_in_products/; use ProductOpener::Text qw/remove_tags_and_quote/; use ProductOpener::Brevo qw/add_contact_to_list/; use ProductOpener::CRM qw/update_contact_last_login/; +use ProductOpener::Auth qw/:all/; +use ProductOpener::Keycloak qw/:all/; +use ProductOpener::URL qw/:all/; use CGI qw/:cgi :form escapeHTML/; use Encode; use JSON::MaybeXS; -use Email::Valid; -use Crypt::PasswdMD5 qw(unix_md5_crypt); use Math::Random::Secure qw(irand); -use Crypt::ScryptKDF qw(scrypt_hash scrypt_hash_verify); use Log::Any qw($log); use MIME::Base32 qw(encode_base32); @@ -113,12 +115,7 @@ my @user_groups = qw(producer database app bot moderator pro_moderator); # Initialize some constants my $cookie_name = 'session'; -my $cookie_domain = "." . $server_domain; # e.g. fr.openfoodfacts.org sets the domain to .openfoodfacts.org -$cookie_domain =~ s/\.pro\./\./; # e.g. .pro.openfoodfacts.org -> .openfoodfacts.org -if (defined $server_options{cookie_domain}) { - $cookie_domain - = "." . $server_options{cookie_domain}; # e.g. fr.import.openfoodfacts.org sets domain to .openfoodfacts.org -} +my $cookie_domain = get_cookie_domain(); =head1 FUNCTIONS @@ -138,81 +135,122 @@ sub generate_token ($name_length) { return join '', map {$chars[irand @chars]} 1 .. $name_length; } -=head2 create_password_hash($password) +# we use user_init() now and not create_user() -Takes $password and hashes it using scrypt +=head2 welcome_user_task ($job, $args_ref) -C This function hashes the user's password using Scrypt which is a salted hashing algorithm. -Password salting adds a random sequence of data to each password and then hashes it. Password hashing is turning a password into a random string by using some algorithm. +C Background task that welcomes a user. +This function sends a welcome mail to the user. =head3 Arguments -$password : String +Minion job arguments. $args_ref contains the userid and email -=head3 Return values +=cut -Returns the salted hashed sequence. +sub welcome_user_task ($job, $args_ref) { + return if not defined $job; -=cut + my $job_id = $job->{id}; -sub create_password_hash ($password) { + my $log_message = "welcome_user_task - job: $job_id started - args: " . encode_json($args_ref) . "\n"; + open(my $minion_log, ">>", "$BASE_DIRS{LOGS}/minion.log"); + print $minion_log $log_message; + close($minion_log); - return scrypt_hash($password); -} + print STDERR $log_message; -=head2 check_password_hash ($password, $hash) + my $userid = $args_ref->{userid}; + my $user_ref = find_user_by_username($userid); + + # Fetch the HTML mail template corresponding to the user language, english is the + # default if the translation is not available + my $language = $user_ref->{attributes}->{preferred_language}; + my $email_content = get_html_email_content("user_welcome.html", $language); + my $user_name = $user_ref->{attributes}->{name} || $userid; + # Replace placeholders by user values + $email_content =~ s/\{\{USERID\}\}/$userid/g; + $email_content =~ s/\{\{NAME\}\}/$user_name/g; + send_html_email($user_ref, lang("add_user_email_subject"), $email_content); -Turns $password into hash using md5 or scrypt and compares it to $hash. + $job->finish("done"); -C This function takes the hash generated by create_password_hash() and the input password string. -Further, it hashes the input password string md5 or scrypt and verifies if it matches with stored password hash. -If the stored hash matches the input-password hash, it returns 1. Otherwise, it's a 0. + return; +} -=head3 Arguments +=head2 subscribe_user_newsletter_task ($job, $args_ref) -Takes in 2 string: $password and $hash generated by create_password_hash() +C Background task that adds the user to Brevo. +This function uses the Brevo API to add the user to the address. -=head3 Return values +=head3 Arguments -Boolean: This function returns a 1/0 (True or False) +Minion job arguments. $args_ref contains the userid and email =cut -sub check_password_hash ($password, $hash) { +sub subscribe_user_newsletter_task ($job, $args_ref) { + return if not defined $job; - if ($hash =~ /^\$1\$(?:.*)/) { - if ($hash eq unix_md5_crypt($password, $hash)) { - return 1; - } - else { - return 0; - } - } - else { - return scrypt_hash_verify($password, $hash); + my $job_id = $job->{id}; + + my $log_message = "subscribe_user_newsletter_task - job: $job_id started - args: " . encode_json($args_ref) . "\n"; + open(my $minion_log, ">>", "$BASE_DIRS{LOGS}/minion.log"); + print $minion_log $log_message; + close($minion_log); + + print STDERR $log_message; + + my $userid = $args_ref->{userid}; + my $user_ref = find_user_by_username($userid); + if (not(defined $user_ref)) { + $job->fail({errors => ['User with id ' . $userid . ' not found in Keycloak.']}); + return; } -} -# we use user_init() now and not create_user() + add_contact_to_list( + $user_ref->{email}, $user_ref->{username}, + $user_ref->{attributes}->{country}, + $user_ref->{attributes}->{preferred_language} + ); + + $job->finish("done"); + + return; +} -=head2 delete_user ($user_ref) +=head2 process_user_requested_org_task ($job, $args_ref) -C Creates a background job to delete the user +C Background task that to register a new user with an organisation =head3 Arguments -Takes in the $user_ref of the user to be deleted +Minion job arguments. $args_ref contains the userid =cut -sub delete_user ($user_ref) { - my $args_ref = { - userid => get_string_id_for_lang("no_language", $user_ref->{userid}), - email => $user_ref->{email}, - }; +sub process_user_requested_org_task ($job, $args_ref) { + return if not defined $job; - require ProductOpener::Producers; - ProductOpener::Producers::queue_job(delete_user => [$args_ref] => {queue => $server_options{minion_local_queue}}); + my $job_id = $job->{id}; + + my $log_message = "process_user_requested_org_task - job: $job_id started - args: " . encode_json($args_ref) . "\n"; + open(my $minion_log, ">>", "$BASE_DIRS{LOGS}/minion.log"); + print $minion_log $log_message; + close($minion_log); + + print STDERR $log_message; + + my $userid = $args_ref->{userid}; + my $user_ref = retreive_user($userid); + if (not(defined $user_ref)) { + $job->fail({errors => ['User with id ' . $userid . ' not found.']}); + return; + } + + process_user_requested_org($user_ref, {}); + + $job->finish("done"); return; } @@ -391,10 +429,11 @@ sub check_user_form ($request_ref, $type, $user_ref, $errors_ref) { if ((defined $email) and ($email ne '') and ($user_ref->{email} ne $email)) { # check that the email is not already used - my $existing_user = retrieve_user_by_email($email); - if (defined $existing_user and $existing_user->{userid} ne $user_ref->{userid}) { + my $user_id_from_mail = is_email_has_off_account($email); + if ((defined $user_id_from_mail) and ($user_id_from_mail ne $user_ref->{userid})) { + # email is already associated with an OFF account $log->debug("check_user_form - email already in use", - {type => $type, email => $email, existing_userid => $existing_user->{userid}}) + {type => $type, email => $email, existing_userid => $user_id_from_mail}) if $log->is_debug(); push @{$errors_ref}, $Lang{error_email_already_in_use}{$lc}; } @@ -404,10 +443,6 @@ sub check_user_form ($request_ref, $type, $user_ref, $errors_ref) { $user_ref->{email} = $email; } - # Country and preferred language - $user_ref->{preferred_language} = remove_tags_and_quote(single_param("preferred_language")); - $user_ref->{country} = remove_tags_and_quote(single_param("country")); - # Is there a checkbox to make a professional account if (defined single_param("pro_checkbox")) { @@ -475,25 +510,14 @@ sub check_user_form ($request_ref, $type, $user_ref, $errors_ref) { # Check input parameters, redisplay if necessary - if (length($user_ref->{name}) < 2) { - push @{$errors_ref}, $Lang{error_no_name}{$lc}; - } - elsif (length($user_ref->{name}) > 60) { - push @{$errors_ref}, $Lang{error_name_too_long}{$lc}; - } - - my $address; - eval {$address = Email::Valid->address(-address => $user_ref->{email}, -mxcheck => 1);}; - $address = 0 if $@; - if (not $address) { - push @{$errors_ref}, $Lang{error_invalid_email}{$lc}; - } - else { - # If all checks have passed, reinitialize with modified email - $user_ref->{email} = $address; - } - if ($type eq 'add') { + if (length($user_ref->{name}) < 2) { + push @{$errors_ref}, $Lang{error_no_name}{$lc}; + } + elsif (length($user_ref->{name}) > 60) { + push @{$errors_ref}, $Lang{error_name_too_long}{$lc}; + } + if (length($user_ref->{userid}) < 2) { push @{$errors_ref}, $Lang{error_no_username}{$lc}; } @@ -506,17 +530,6 @@ sub check_user_form ($request_ref, $type, $user_ref, $errors_ref) { elsif (length($user_ref->{userid}) > 40) { push @{$errors_ref}, $Lang{error_username_too_long}{$lc}; } - - if (length(decode utf8 => single_param('password')) < 6) { - push @{$errors_ref}, $Lang{error_invalid_password}{$lc}; - } - } - - if (param('password') ne single_param('confirm_password')) { - push @{$errors_ref}, $Lang{error_different_passwords}{$lc}; - } - elsif (single_param('password') ne '') { - $user_ref->{encrypted_password} = create_password_hash(encode_utf8(decode utf8 => single_param('password'))); } return; @@ -673,59 +686,30 @@ edit / add / delete sub process_user_form ($type, $user_ref, $request_ref) { my $userid = $user_ref->{userid}; - my $error = 0; $log->debug("process_user_form", {type => $type, user_ref => $user_ref}) if $log->is_debug(); - # Professional account with a requested org (existing or new) - process_user_requested_org($user_ref, $request_ref); + if ($type eq 'add') { + # Create new user in Keycloak first + my $keycloak = ProductOpener::Keycloak->new(); + $keycloak->create_user($user_ref, single_param('password')); + } + else { + # Professional account with a requested org (existing or new) + # Keycloak round trip will do this for new users but still call here for edits + process_user_requested_org($user_ref, $request_ref); + } # save user store_user($user_ref); - if ($type eq 'add') { - - # Initialize the session to send a session cookie back - # so that newly created users do not have to login right after - - param("user_id", $userid); - init_user($request_ref); - - # Fetch the HTML mail template corresponding to the user language, english is the - # default if the translation is not available - my $language = $user_ref->{preferred_language} || $user_ref->{initial_lc}; - my $email_content = get_html_email_content("user_welcome.html", $language); - my $user_name = $user_ref->{name}; - # Replace placeholders by user values - $email_content =~ s/\{\{USERID\}\}/$userid/g; - $email_content =~ s/\{\{NAME\}\}/$user_name/g; - $error = send_html_email($user_ref, lang("add_user_email_subject"), $email_content); - - my $admin_mail_body = <{name} -email: $user_ref->{email} -twitter: https://twitter.com/$user_ref->{twitter} -newsletter: $user_ref->{newsletter} -discussion: $user_ref->{discussion} -lc: $user_ref->{initial_lc} -cc: $user_ref->{initial_cc} - -EMAIL - ; - $error += send_email_to_admin("Inscription de $userid", $admin_mail_body); - } - # Check if the user subscribed to the newsletter - if ($user_ref->{newsletter}) { - add_contact_to_list($user_ref->{email}, $user_ref->{user_id}, $user_ref->{country}, - $user_ref->{preferred_language}); - } + param("user_id", $userid); + init_user($request_ref); - return $error; + return; } =head2 check_edit_owner($user_ref, $errors_ref) @@ -809,27 +793,6 @@ sub check_edit_owner ($user_ref, $errors_ref, $ownerid = undef) { return; } -=head2 migrate_password_hash($user_ref) - -We used to use crypt instead of scrypt to store hashed passwords. -If the user is logging in with a correct password, we can update the password hash. - -=head3 Arguments - -=head4 User object $user_ref - -=cut - -sub migrate_password_hash ($user_ref) { - - # Migration: take the occasion of having password to upgrade to scrypt, if it is still in crypt format - if ($user_ref->{'encrypted_password'} =~ /^\$1\$(?:.*)/) { - $user_ref->{'encrypted_password'} = create_password_hash(encode_utf8(decode utf8 => single_param('password'))); - $log->info("crypt password upgraded to scrypt_hash") if $log->is_info(); - } - return; -} - =head2 remove_old_sessions($user_ref) Remove the oldest session if we have too many sessions opened for an user. @@ -913,7 +876,7 @@ sub generate_session_cookie ($user_id, $user_session) { return cookie(%$cookie_ref); } -=head2 open_user_session($user_ref, $request_ref) +=head2 open_user_session($user_ref, $refresh_token, $refresh_expires_at, $access_token, $access_expires_at, $id_token, $request_ref) Open a session, store it in the user object, and return a cookie with the session id in the request object. @@ -921,6 +884,16 @@ Open a session, store it in the user object, and return a cookie with the sessio =head4 User object $user_ref +=head4 OIDC Refresh Token $refresh_token + +=head4 Timestamp after which the OIDC Refresh Token cannot be used $refresh_expires_at + +=head4 OIDC Access Token $access_token + +=head4 Timestamp after which the OIDC Access Token cannot be used $access_expires_at + +=head4 OIDC ID Token $id_token + =head4 Request object $request_ref =head3 Return values @@ -929,7 +902,9 @@ The cookie is returned in $request_ref =cut -sub open_user_session ($user_ref, $request_ref) { +sub open_user_session ($user_ref, $refresh_token, $refresh_expires_at, $access_token, $access_expires_at, $id_token, + $request_ref) +{ my $user_id = $user_ref->{'userid'}; @@ -944,7 +919,12 @@ sub open_user_session ($user_ref, $request_ref) { # Store the ip and time corresponding to the given session $user_ref->{'user_sessions'}{$user_session} = { ip => remote_addr(), - time => time() + time => time(), + refresh_token => $refresh_token, + refresh_expires_at => $refresh_expires_at, + access_token => $access_token, + access_expires_at => $access_expires_at, + id_token => $id_token }; # Store user data @@ -954,7 +934,7 @@ sub open_user_session ($user_ref, $request_ref) { $request_ref->{cookie} = generate_session_cookie($user_id, $user_session); - return; + return $user_session; } sub retrieve_user ($user_id) { @@ -969,22 +949,86 @@ sub retrieve_user ($user_id) { return $user_ref; } -sub user_exists ($user_id) { - my $user_file = "$BASE_DIRS{USERS}/" . get_string_id_for_lang("no_language", $user_id) . ".sto"; - return (-e $user_file); +sub is_email_has_off_account ($email) { + + my $user = _find_user_by_email_in_keycloak($email); + unless (defined $user) { + return; # Email is not known in Keycloak + } + + my $user_id = $user->{preferred_username}; + my $user_ref = retrieve_user($user_id); + unless ($user_ref) { + $log->info('User not found', {user_id => $user_id}) if $log->is_info(); + return; # Email is not associated with an OFF account + } + + return $user_ref->{userid}; } -sub retrieve_user_by_email($email) { - my $user_ref; - my $emails_ref = retrieve("$BASE_DIRS{USERS}/users_emails.sto"); - if (not defined $emails_ref->{$email}) { - # not found, try with lower case email - $email = lc $email; +sub _find_user_by_email_in_keycloak($email) { + my $keycloak = ProductOpener::Keycloak->new(); + return $keycloak->find_user_by_email($email); +} + +=head2 _get_or_create_account_by_mail ($email) + +Tries to get a user based on their mail address from Keycloak. + +If the account is available in Keycloak, but does not exist +as a `user.sto` file, yet, it will be created. + +=head3 Arguments + +=head4 string $email + +Mail address of the user + +=head4 boolean $require_verified_email + +If true, the email must be verified before proceeding. + +=head3 Return values + +User's user ID + +=cut + +sub _get_or_create_account_by_mail ($email, $require_verified_email = 0) { + + my $user = _find_user_by_email_in_keycloak($email); + unless (defined $user) { + return; # Email is not known in Keycloak } - if (defined $emails_ref->{$email}) { - $user_ref = retrieve_user($emails_ref->{$email}[0]); + + my $user_id = $user->{preferred_username}; + my $user_ref = retrieve_user($user_id); + unless ($user_ref) { + if ($require_verified_email and (not($user->{emailVerified} eq $JSON::PP::true))) { + $log->info('User not found, and user email is not verified. Not creating new account in OFF.', + {user => $user->{email}}) + if $log->is_info(); + return; + } + + # Initialize user based on Keycloak data + $log->info('User not found. Creating based on Keycloak data', {user_id => $user_id, keycloak_user => $user}) + if $log->is_info(); + $user_ref = { + userid => $user->{username}, + name => $user->{name} // $user->{username} + }; + + $user_ref->{email} = $user->{email}; + store_user($user_ref); } - return $user_ref; + + return $user_ref->{userid}; +} + +sub user_exists ($user_id) { + my $user_file = "$BASE_DIRS{USERS}/" . get_string_id_for_lang("no_language", $user_id) . ".sto"; + return (-e $user_file); } # store user information that is not reflected in Keycloak @@ -996,21 +1040,6 @@ sub store_user_session ($user_ref) { } sub store_user ($user_ref) { - my $userid = $user_ref->{userid}; - - # Update email - my $emails_ref = retrieve("$BASE_DIRS{USERS}/users_emails.sto"); - my $email = $user_ref->{email}; - - if ((defined $email) and ($email =~ /\@/)) { - $emails_ref->{$email} = [$userid]; - } - if (defined $user_ref->{old_email}) { - delete $emails_ref->{$user_ref->{old_email}}; - delete $user_ref->{old_email}; - } - store("$BASE_DIRS{USERS}/users_emails.sto", $emails_ref); - # save user store_user_session($user_ref); @@ -1026,13 +1055,16 @@ sub remove_user ($user_ref) { unlink($user_file); # Remove the e-mail - my $emails_ref = retrieve("$BASE_DIRS{USERS}/users_emails.sto"); - my $email = $user_ref->{email}; - - if ((defined $email) and ($email =~ /\@/)) { - if (defined $emails_ref->{$email}) { - delete $emails_ref->{$email}; - store("$BASE_DIRS{USERS}/users_emails.sto", $emails_ref); + my $emails_file = "$BASE_DIRS{USERS}/users_emails.sto"; + if (-e $emails_file) { + my $emails_ref = retrieve($emails_file); + my $email = $user_ref->{email}; + + if ((defined $email) and ($email =~ /\@/)) { + if (defined $emails_ref->{$email}) { + delete $emails_ref->{$email}; + store($emails_file, $emails_ref); + } } } @@ -1057,13 +1089,6 @@ sub retrieve_userids() { return @userids; } -sub is_email_has_off_account ($email) { - my $user_ref = retrieve_user_by_email($email); - return $user_ref->{userid} if defined $user_ref; - - return; # Email is not associated with an OFF account -} - sub remove_user_by_org_admin ($orgid, $user_id) { my $groups_ref = ['admins', 'members', 'pending']; remove_user_from_org($orgid, $user_id, $groups_ref); @@ -1132,6 +1157,36 @@ sub init_user ($request_ref) { ); } + # User was authenticated via OIDC + elsif ((defined $request_ref->{oidc_user_id}) and ($request_ref->{oidc_user_id} ne '')) { + $user_id = $request_ref->{oidc_user_id}; + + $log->context->{user_id} = $user_id; + $log->debug("user_id is defined") if $log->is_debug(); + my $session = undef; + + # If the user exists + if (defined $user_id) { + $user_ref = retrieve_user($user_id); + + if (defined $user_ref) { + $user_id = $user_ref->{'userid'}; + $log->context->{user_id} = $user_id; + + if (not defined request_param($request_ref, 'no_log')) # no need to store sessions for internal requests + { + open_user_session($user_ref, undef, undef, undef, undef, undef, $request_ref); + } + } + else { + $user_id = undef; + $log->info('bad user') if $log->is_info(); + # Trigger an error + return ($Lang{error_bad_login_password}{$lc}); + } + } + } + # Retrieve user_id and password from form parameters elsif ( (defined request_param($request_ref, 'user_id')) and (request_param($request_ref, 'user_id') ne '') @@ -1141,20 +1196,11 @@ sub init_user ($request_ref) { $user_id = remove_tags_and_quote(request_param($request_ref, 'user_id')); if ($user_id =~ /\@/) { - $log->info("got email while initializing user", {email => $user_id}) if $log->is_info(); - $user_ref = retrieve_user_by_email($user_id); - - if (not defined $user_ref) { - $user_id = undef; - $log->info("Unknown user e-mail", {email => $user_id}) if $log->is_info(); - # Trigger an error + $user_id = _get_or_create_account_by_mail($user_id); + # Trigger an error + unless (defined $user_id) { return ($Lang{error_bad_login_password}{$lc}); } - else { - $user_id = $user_ref->{userid}; - } - - $log->info("corresponding user_id", {userid => $user_id}) if $log->is_info(); } $log->context->{user_id} = $user_id; @@ -1171,15 +1217,12 @@ sub init_user ($request_ref) { $user_id = $user_ref->{'userid'}; $log->context->{user_id} = $user_id; - my $hash_is_correct = check_password_hash(encode_utf8(request_param($request_ref, 'password')), - $user_ref->{'encrypted_password'}); + my ($oidc_user_id, $refresh_token, $refresh_expires_at, $access_token, $access_expires_at, $id_token) + = password_signin($user_id, encode_utf8(request_param($request_ref, 'password')), $request_ref); # We don't have the right password - if (not $hash_is_correct) { + if (not $oidc_user_id) { $user_id = undef; - $log->info( - "bad password - input does not match stored hash", - {encrypted_password => $user_ref->{'encrypted_password'}} - ) if $log->is_info(); + $log->info('bad password - input does not match stored hash') if $log->is_info(); # Trigger an error return ($Lang{error_bad_login_password}{$lc}); } @@ -1187,11 +1230,12 @@ sub init_user ($request_ref) { elsif ( not defined request_param($request_ref, 'no_log')) # no need to store sessions for internal requests { - $log->info("correct password for user provided") if $log->is_info(); + $user_id = $oidc_user_id; - migrate_password_hash($user_ref); + $log->info("correct password for user provided") if $log->is_info(); - open_user_session($user_ref, $request_ref); + open_user_session($user_ref, $refresh_token, $refresh_expires_at, + $access_token, $access_expires_at, $id_token, $request_ref); update_login_time($user_ref); } } @@ -1269,6 +1313,15 @@ sub init_user ($request_ref) { $log->debug("user identified", {user_id => $user_id, stocked_user_id => $user_ref->{'userid'}}) if $log->is_debug(); $user_id = $user_ref->{'userid'}; + + my $session_ref = $user_ref->{'user_sessions'}{$user_session}; + $request_ref->{access_token} = $session_ref->{access_token} if $session_ref->{access_token}; + $request_ref->{access_expires_at} = $session_ref->{access_expires_at} + if $session_ref->{access_expires_at}; + $request_ref->{refresh_token} = $session_ref->{refresh_token} if $session_ref->{refresh_token}; + $request_ref->{refresh_expires_at} = $session_ref->{refresh_expires_at} + if $session_ref->{refresh_expires_at}; + $request_ref->{id_token} = $session_ref->{id_token} if $session_ref->{id_token}; } } else { diff --git a/lib/startup_apache2.pl b/lib/startup_apache2.pl index 7d0bc9e1320c6..e0907f14cd3a4 100755 --- a/lib/startup_apache2.pl +++ b/lib/startup_apache2.pl @@ -3,7 +3,7 @@ # This file is part of Product Opener. # # Product Opener -# Copyright (C) 2011-2023 Association Open Food Facts +# Copyright (C) 2011-2024 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # @@ -118,6 +118,7 @@ use ProductOpener::NutritionCiqual qw/:all/; use ProductOpener::NutritionEstimation qw/:all/; use ProductOpener::RequestStats qw/:all/; +use ProductOpener::Auth qw/:all/; use Apache2::Const -compile => qw(OK); use Apache2::Connection (); diff --git a/po/common/common.pot b/po/common/common.pot index b399fbb7f1549..e3fd117b6b594 100644 --- a/po/common/common.pot +++ b/po/common/common.pot @@ -617,20 +617,24 @@ msgid "Change your account parameters" msgstr "" msgctxt "edit_user" -msgid "Account parameters" -msgstr "Account parameters" +msgid "Preferences" +msgstr "" msgctxt "edit_user_display" -msgid "Account parameters" -msgstr "Account parameters" +msgid "Preferences" +msgstr "" msgctxt "edit_user_process" -msgid "Account parameters" -msgstr "Account parameters" +msgid "Preferences" +msgstr "" msgctxt "edit_user_result" -msgid "Your account parameters have been changed." -msgstr "Your account parameters have been changed." +msgid "Your preference shave been changed." +msgstr "" + +msgctxt "user_in_keycloak" +msgid "Account parameters" +msgstr "" msgctxt "editors_p" msgid "editors" @@ -4968,6 +4972,10 @@ msgctxt "number_of_members" msgid "Number of Members" msgstr "Number of Members" +msgctxt "serial_no" +msgid "S.No" +msgstr "S.No" + msgctxt "contact_form" msgid "Contact form" msgstr "Contact form" @@ -6950,6 +6958,11 @@ msgctxt "skip_to_content" msgid "Skip to Content" msgstr "Skip to Content" +msgctxt "oidc_signin_no_cookie" +msgid "You need to enable cookies to sign in." +msgstr "You need to enable cookies to sign in." + + msgctxt "cant_delete_main_contact" msgid "You cannot remove the main contact. Change it first." msgstr "You cannot remove the main contact. Change it first." diff --git a/po/common/en.po b/po/common/en.po index 282b66755d97c..3fd7148a008f8 100644 --- a/po/common/en.po +++ b/po/common/en.po @@ -622,20 +622,24 @@ msgid "Change your account parameters" msgstr "Change your account parameters" msgctxt "edit_user" -msgid "Account parameters" -msgstr "Account parameters" +msgid "Preferences" +msgstr "Preferences" msgctxt "edit_user_display" -msgid "Account parameters" -msgstr "Account parameters" +msgid "Preferences" +msgstr "Preferences" msgctxt "edit_user_process" -msgid "Account parameters" -msgstr "Account parameters" +msgid "Preferences" +msgstr "Preferences" msgctxt "edit_user_result" -msgid "Your account parameters have been changed." -msgstr "Your account parameters have been changed." +msgid "Your preference shave been changed." +msgstr "Your preference shave been changed." + +msgctxt "user_in_keycloak" +msgid "Account parameters" +msgstr "Account parameters" msgctxt "editors_p" msgid "editors" @@ -4992,6 +4996,10 @@ msgctxt "number_of_members" msgid "Number of Members" msgstr "Number of Members" +msgctxt "serial_no" +msgid "S.No" +msgstr "S.No" + msgctxt "contact_form" msgid "Contact form" msgstr "Contact form" @@ -7204,3 +7212,7 @@ msgstr "Bad repairability" msgctxt "concerned_categories" msgid "Bad repairability" msgstr "Bad repairability" + +msgctxt "oidc_signin_no_cookie" +msgid "You need to enable cookies to sign in." +msgstr "You need to enable cookies to sign in." diff --git a/scripts/create_dummies.pl b/scripts/create_dummies.pl new file mode 100755 index 0000000000000..5beaa3e4d7686 --- /dev/null +++ b/scripts/create_dummies.pl @@ -0,0 +1,33 @@ +#!/usr/bin/perl -w + +use ProductOpener::PerlStandards; +use ProductOpener::Users; + +my $type = 'add'; +my $rndm = ProductOpener::Users::generate_token(4); +my $request_ref = {}; +my $uid = $rndm . 0; +my $user_ref = { + email => $uid . '@example.org', + userid => $uid, + name => $uid, + password => 'testtest', + preferred_language => 'de', + country => 'de:Germany' +}; +my @errors = (); +ProductOpener::Users::check_user_form($request_ref, $type, $user_ref, \@errors); + +if ($#errors > 0) { + use Data::Dumper; + print STDERR Dumper(\@errors) . "\n"; + return 1; +} + +for (my $i = 1; $i <= 200000; $i++) { + my $uid = $rndm . $i; + $user_ref->{email} = $uid . '@example.org'; + $user_ref->{userid} = $uid; + $user_ref->{name} = $uid; + ProductOpener::Users::process_user_form($type, $user_ref, $request_ref); +} diff --git a/scripts/deploy/verify-deployment.sh b/scripts/deploy/verify-deployment.sh index 1b673849a0091..7110311a6ea14 100644 --- a/scripts/deploy/verify-deployment.sh +++ b/scripts/deploy/verify-deployment.sh @@ -203,7 +203,6 @@ function compute_expected_links { EXPECTED_LINKS["/etc/logrotate.d/apache2"]="$REPO_PATH/conf/logrotate/apache2" # Note: other link on old versions: - # /srv/$SERVICE/users_emails.sto -> /srv/$SERVICE/users/users_emails.sto # /srv/$SERVICE/orgs_glns.sto -> /srv/$SERVICE/orgs/orgs_glns.sto # } diff --git a/scripts/listen_to_redis_stream.pl b/scripts/listen_to_redis_stream.pl new file mode 100755 index 0000000000000..6ddcc4039a427 --- /dev/null +++ b/scripts/listen_to_redis_stream.pl @@ -0,0 +1,63 @@ +#!/usr/bin/perl -w + +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2024 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# This script is meant to be called through process_new_image_off.sh, itself run through an icrontab + +use ProductOpener::PerlStandards; + +use ProductOpener::Config qw/:all/; +use ProductOpener::Redis qw/:all/; + +use Log::Any qw($log); +use Log::Any::Adapter ('Stderr', log_level => 'debug'); + +use AnyEvent; +use EV; + +sub run ($cv) { + subscribe_to_redis_streams(); + + # call event loop + $cv->recv; # Wait for the event loop to finish + EV::run(); + return; +} + +sub main() { + $log->info("Starting listen_to_redis_stream.pl") if $log->is_info(); + + my $cv = AE::cv; + + # signal handler for TERM, KILL, QUIT + foreach my $sig (qw/TERM KILL QUIT/) { + EV::signal $sig, sub { + $log->info("Exiting after receiving", {signal => $sig}) if $log->is_info(); + $cv->send; + exit(0); + }; + } + + run($cv); + return; +} + +main(); diff --git a/scripts/migrate_users_to_keycloak.pl b/scripts/migrate_users_to_keycloak.pl new file mode 100755 index 0000000000000..8d784cadc7db9 --- /dev/null +++ b/scripts/migrate_users_to_keycloak.pl @@ -0,0 +1,236 @@ +#!/usr/bin/perl -w + +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2024 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +use ProductOpener::PerlStandards; + +use ProductOpener::Auth qw/:all/; +use ProductOpener::Config qw/:all/; +use ProductOpener::Paths qw/:all/; +use ProductOpener::Store qw/:all/; +use ProductOpener::Keycloak qw/:all/; + +use JSON; +use LWP::UserAgent; +use LWP::UserAgent::Plugin 'Retry'; +use HTTP::Request; +use URI::Escape::XS qw/uri_escape/; + +use Log::Any '$log', default_adapter => 'Stderr'; + +my $keycloak = ProductOpener::Keycloak->new(); + +my $keycloak_partialimport_endpoint + = $oidc_options{keycloak_backchannel_base_url} + . '/admin/realms/' + . uri_escape($oidc_options{keycloak_realm_name}) + . '/partialImport'; + +sub create_user_in_keycloak_with_scrypt_credential ($keycloak_user_ref) { + my $json = encode_json($keycloak_user_ref); + + my $request_token = $keycloak->get_or_refresh_token(); + my $create_user_request = HTTP::Request->new(POST => $keycloak->{users_endpoint}); + $create_user_request->header('Content-Type' => 'application/json'); + $create_user_request->header( + 'Authorization' => $request_token->{token_type} . ' ' . $request_token->{access_token}); + $create_user_request->content($json); + my $new_user_response = LWP::UserAgent::Plugin->new->request($create_user_request); + unless ($new_user_response->is_success) { + $log->error($new_user_response->content); + return; + } + + $request_token = $keycloak->get_or_refresh_token(); + my $get_user_request = HTTP::Request->new(GET => $new_user_response->header('location')); + $get_user_request->header('Content-Type' => 'application/json'); + $get_user_request->header('Authorization' => $request_token->{token_type} . ' ' . $request_token->{access_token}); + my $get_user_response = LWP::UserAgent::Plugin->new->request($get_user_request); + unless ($get_user_response->is_success) { + $log->error($get_user_response->content); + return; + } + + my $json_response = $get_user_response->decoded_content(charset => 'UTF-8'); + my @created_users = decode_json($json_response); + return $created_users[0]; +} + +sub import_users_in_keycloak ($users_ref) { + my $request_data = {users => $users_ref}; + my $json = encode_json($request_data); + + my $request_token = $keycloak->get_or_refresh_token(); + $log->error($keycloak_partialimport_endpoint); + my $import_users_request = HTTP::Request->new(POST => $keycloak_partialimport_endpoint); + $import_users_request->header('Content-Type' => 'application/json'); + $import_users_request->header( + 'Authorization' => $request_token->{token_type} . ' ' . $request_token->{access_token}); + $import_users_request->content($json); + my $import_users_response = LWP::UserAgent::Plugin->new->request($import_users_request); + + unless ($import_users_response->is_success) { + $log->error( + 'There was an error importing users to Keycloak. Please ensure that the client has permission to manage the realm. This is not enabled by default and should only be a temporary permission.', + { + response => $import_users_response->content, + client_id => $oidc_options{client_id}, + keycloak_realm_name => $oidc_options{keycloak_realm_name} + } + ); + } + + return; +} + +sub migrate_user ($user_file, $anonymize) { + my $keycloak_user_ref = convert_to_keycloak_user($user_file, $anonymize); + if (not(defined $keycloak_user_ref)) { + $log->warn('unable to convert user_ref'); + return; + } + + create_user_in_keycloak_with_scrypt_credential($keycloak_user_ref); + + return; +} + +sub convert_to_keycloak_user ($user_file, $anonymize) { + my $user_ref = retrieve($user_file); + if (not(defined $user_ref)) { + $log->warn('undefined $user_ref'); + return; + } + + my $credential + = $anonymize ? {} : convert_scrypt_password_to_keycloak_credentials($user_ref->{'encrypted_password'}) // {}; + my $keycloak_user_ref = { + email => ($anonymize ? 'off.' . $user_ref->{userid} . '@example.org' : $user_ref->{email}), + # Currently, the assumption is that all users have verified their email address. This is not true, but it's better than forcing all existing users to verify their email address. + emailVerified => $JSON::PP::true, + enabled => $JSON::PP::true, + username => $user_ref->{userid}, + credentials => [$credential], + attributes => [ + name => [($anonymize ? $user_ref->{userid} : $user_ref->{name})], + locale => [$user_ref->{initial_lc}], + country => [$user_ref->{initial_cc}], + importTimestamp => time(), + importSourceChangedTimestamp => (stat($user_file))[9] + ], + createdTimestamp => $user_ref->{registered_t} * 1000 + }; + + return $keycloak_user_ref; +} + +sub convert_scrypt_password_to_keycloak_credentials ($hashed_password) { + unless (defined $hashed_password) { + return; + } + + my $credential = {}; + my ($alg, $N, $r, $p, $salt, $hash) = ($hashed_password =~ /^(SCRYPT):(\d+):(\d+):(\d+):([^\:]+):([^\:]+)$/); + if ((defined $alg) and ($alg eq 'SCRYPT')) { + # Only migrate SCRYPT passwords. If there are still users that use MD5, + # they haven't signed in in 8 years, and will have to change their + # password, if they want to use the server again. + $credential->{type} = 'password'; + + my $secret_data = { + value => $hash, + salt => $salt + }; + + $credential->{secretData} = encode_json($secret_data); + + my $credential_data = { + hashIterations => -1, + algorithm => 'scrypt', + additionalParameters => { + N => [$N], + r => [$r], + p => [$p], + } + }; + + $credential->{credentialData} = encode_json($credential_data); + $credential->{temporary} = $JSON::false; + } + + return $credential; +} + +my $importtype = 'realm-batch'; +if ((scalar @ARGV) > 0 and (length($ARGV[0]) > 0)) { + $importtype = $ARGV[0]; +} + +my $anonymize = 0; +if ((scalar @ARGV) > 0 and ('anonymize' eq $ARGV[-1])) { + # Anonymize the user data by removing the email address, name, and password. + # This is useful for testing the migration script and for adding production data to the test server. + $anonymize = 1; +} + +if ($importtype eq 'realm-batch') { + my @users = (); + + if (opendir(my $dh, "$BASE_DIRS{USERS}/")) { + + foreach my $file (readdir($dh)) { + if (($file =~ /.+\.sto$/) and ($file ne 'users_emails.sto')) { + my $keycloak_user = convert_to_keycloak_user("$BASE_DIRS{USERS}/$file", $anonymize); + push(@users, $keycloak_user) if defined $keycloak_user; + } + + if (scalar @users >= 2000) { + import_users_in_keycloak(\@users); + @users = (); + } + } + + closedir $dh; + } + + if (scalar @users) { + import_users_in_keycloak(\@users); + } +} +elsif ($importtype eq 'api-multi') { + if (opendir(my $dh, "$BASE_DIRS{USERS}/")) { + foreach my $file (readdir($dh)) { + if (($file =~ /.+\.sto$/) and ($file ne 'users_emails.sto')) { + migrate_user("$BASE_DIRS{USERS}/$file", $anonymize); + } + } + + closedir $dh; + } +} +elsif ($importtype eq 'api-single') { + if ((scalar @ARGV) == 2 and (length($ARGV[1]) > 0)) { + migrate_user($ARGV[1], $anonymize); + } +} +else { + die "Unknown import type: $importtype"; +} diff --git a/scripts/minion_producers.pl b/scripts/minion_producers.pl index 799816b9cbf34..04fa6e0ae6a37 100755 --- a/scripts/minion_producers.pl +++ b/scripts/minion_producers.pl @@ -73,6 +73,9 @@ app->minion->add_task( import_products_categories_from_public_database => \&import_products_categories_from_public_database_task); +app->minion->add_task(welcome_user => \&ProductOpener::Users::welcome_user_task); +app->minion->add_task(subscribe_user_newsletter => \&ProductOpener::Users::subscribe_user_newsletter_task); +app->minion->add_task(process_user_requested_org => \&ProductOpener::Users::process_user_requested_org_task); app->minion->add_task(delete_user => \&ProductOpener::Users::delete_user_task); app->config( diff --git a/scripts/snippets/remove_broken_entry_with_empty_email.pl b/scripts/snippets/remove_broken_entry_with_empty_email.pl deleted file mode 100755 index eb24484684f27..0000000000000 --- a/scripts/snippets/remove_broken_entry_with_empty_email.pl +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/perl -w - -use ProductOpener::PerlStandards; - -use ProductOpener::Config qw/:all/; -use ProductOpener::Paths qw/:all/; -use ProductOpener::Store qw/:all/; - -# TODO: This script probably won't be needed with Keycloak -my $emails_ref = retrieve("$BASE_DIRS{USERS}/users_emails.sto"); - -if (defined $emails_ref->{''}) { - delete $emails_ref->{''}; - store("$BASE_DIRS{USERS}/users_emails.sto", $emails_ref); -} diff --git a/scripts/taxonomies/generators/gen_nutrient_levels_taxonomy.pl b/scripts/taxonomies/generators/gen_nutrient_levels_taxonomy.pl old mode 100755 new mode 100644 diff --git a/stop_words.txt b/stop_words.txt index c59566ab8f8a5..34942c200e44d 100644 --- a/stop_words.txt +++ b/stop_words.txt @@ -41,6 +41,8 @@ Carrefour Catégorie Catalogue céléri +cgi +CGI CIC CIN CALNUT @@ -147,7 +149,10 @@ JS json JSON jsonp +JWKS kcal +Keycloak +keycloak kJ Kokosnussöl l'acérola @@ -192,6 +197,9 @@ off Offals openfoodfacts OpenFoodFacts +OpenID +oidc +OIDC Origine overriden packagings @@ -216,6 +224,7 @@ poudre pre pre-processing processings +ProductOpener Programme pre prepend @@ -236,6 +245,8 @@ scrypt Scrypt serverTimePretty sftp +signin +signout SignalConso sirop slad diff --git a/templates/web/common/site_layout.tt.html b/templates/web/common/site_layout.tt.html index f36e0b2e6d452..b449653d7d9cf 100644 --- a/templates/web/common/site_layout.tt.html +++ b/templates/web/common/site_layout.tt.html @@ -109,7 +109,7 @@
    [% IF not user_id.defined %]
  • - + account_circle [% lang('sign_in') %] @@ -123,6 +123,7 @@ [% user.name %]