From 4720e476f8ecfaae2b6c6577b534a037b8bbbb76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Mon, 15 Jul 2024 12:17:00 +0200 Subject: [PATCH 1/9] Migration, new setting: `:admin_bot_protection_level` --- app/models/settings.rb | 9 +++++++-- ...40715075900_add_admin_bot_protection_to_settings.rb | 10 ++++++++++ db/oracle_schema.rb | 1 + db/postgres_schema.rb | 1 + db/schema.rb | 1 + 5 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20240715075900_add_admin_bot_protection_to_settings.rb diff --git a/app/models/settings.rb b/app/models/settings.rb index aa4dc06165..e5a3bbd014 100644 --- a/app/models/settings.rb +++ b/app/models/settings.rb @@ -12,9 +12,9 @@ class Settings < ApplicationRecord :content_bg_colour, :tracker_code, :favicon, :plans_tab_bg_colour, :plans_bg_colour, :content_border_colour, :cc_privacy_path, :cc_terms_path, :cc_refunds_path, :change_service_plan_permission, :spam_protection_level, :authentication_strategy, :janrain_api_key, :janrain_relying_party, :cms_token, :cas_server_url, :sso_key, - :sso_login_url, length: { maximum: 255 } + :admin_bot_protection_level, :sso_login_url, length: { maximum: 255 } - symbolize :spam_protection_level + symbolize :spam_protection_level, :admin_bot_protection_level include Switches @@ -101,6 +101,11 @@ def spam_protection_level level == :auto ? :captcha : level end + # To avoid a dangerous DB migration, we allow empty values in the column and assume empty means `:none` + def admin_bot_protection_level + super&.to_sym || :none + end + delegate :provider_id_for_audits, :to => :account, :allow_nil => true private diff --git a/db/migrate/20240715075900_add_admin_bot_protection_to_settings.rb b/db/migrate/20240715075900_add_admin_bot_protection_to_settings.rb new file mode 100644 index 0000000000..ac833645ed --- /dev/null +++ b/db/migrate/20240715075900_add_admin_bot_protection_to_settings.rb @@ -0,0 +1,10 @@ +class AddAdminBotProtectionToSettings < ActiveRecord::Migration[6.1] + def up + add_column :settings, :admin_bot_protection_level, :string + change_column_default :settings, :admin_bot_protection_level, "none" + end + + def down + remove_column :settings, :admin_bot_protection_level + end +end diff --git a/db/oracle_schema.rb b/db/oracle_schema.rb index 5339807aed..22e698f471 100644 --- a/db/oracle_schema.rb +++ b/db/oracle_schema.rb @@ -1264,6 +1264,7 @@ t.string "iam_tools_switch", default: "denied", null: false t.string "require_cc_on_signup_switch", default: "denied", null: false t.boolean "enforce_sso", default: false, null: false + t.string "admin_bot_protection_level", default: "none" t.index ["account_id"], name: "index_settings_on_account_id", unique: true end diff --git a/db/postgres_schema.rb b/db/postgres_schema.rb index 32677d0efb..395d5aacc2 100644 --- a/db/postgres_schema.rb +++ b/db/postgres_schema.rb @@ -1268,6 +1268,7 @@ t.string "iam_tools_switch", limit: 255, default: "denied", null: false t.string "require_cc_on_signup_switch", limit: 255, default: "denied", null: false t.boolean "enforce_sso", default: false, null: false + t.string "admin_bot_protection_level", default: "none" t.index ["account_id"], name: "index_settings_on_account_id", unique: true end diff --git a/db/schema.rb b/db/schema.rb index 9802165ddc..adcd8c4e5c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1268,6 +1268,7 @@ t.string "iam_tools_switch", default: "denied", null: false t.string "require_cc_on_signup_switch", default: "denied", null: false t.boolean "enforce_sso", default: false, null: false + t.string "admin_bot_protection_level", default: "none" t.index ["account_id"], name: "index_settings_on_account_id", unique: true end From a3d94706d5c41acb74e1899459f04ccf5ee646ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Mon, 1 Jul 2024 10:57:09 +0200 Subject: [PATCH 2/9] New React component to load external scripts --- app/javascript/src/utilities/useScript.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/javascript/src/utilities/useScript.ts diff --git a/app/javascript/src/utilities/useScript.ts b/app/javascript/src/utilities/useScript.ts new file mode 100644 index 0000000000..63016ca217 --- /dev/null +++ b/app/javascript/src/utilities/useScript.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react' + +const useScript = (url: string, cb: (ev: Event) => void): void => { + useEffect(() => { + const script = document.createElement('script') + + script.src = url + script.async = true + script.addEventListener('load', cb) + + document.body.appendChild(script) + + return () => { + document.body.removeChild(script) + } + }, []) +} + +export { useScript } From b1f0669753c69455b2d2ad370c69d887a0c1870a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Mon, 1 Jul 2024 10:58:49 +0200 Subject: [PATCH 3/9] New React component for Google Recaptcha V3 Based on: - https://w11i.me/recaptcha-v3-react - https://stackoverflow.com/questions/53832882/react-and-recaptcha-v3 - https://developers.google.com/recaptcha/docs/v3#programmatically_invoke_the_challenge --- app/javascript/src/Types/ReCaptcha.ts | 10 +++++ app/javascript/src/utilities/ReCaptchaV3.tsx | 39 ++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 app/javascript/src/Types/ReCaptcha.ts create mode 100644 app/javascript/src/utilities/ReCaptchaV3.tsx diff --git a/app/javascript/src/Types/ReCaptcha.ts b/app/javascript/src/Types/ReCaptcha.ts new file mode 100644 index 0000000000..238b7c9abc --- /dev/null +++ b/app/javascript/src/Types/ReCaptcha.ts @@ -0,0 +1,10 @@ +declare global { + interface Window { + grecaptcha: ReCaptchaInstance; + } +} + +export interface ReCaptchaInstance { + ready: (cb: () => void) => void; + execute: (sitekey: string, options: { action: string }) => Promise; +} diff --git a/app/javascript/src/utilities/ReCaptchaV3.tsx b/app/javascript/src/utilities/ReCaptchaV3.tsx new file mode 100644 index 0000000000..32f494443f --- /dev/null +++ b/app/javascript/src/utilities/ReCaptchaV3.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react' + +import { useScript } from 'utilities/useScript' + +import type { FunctionComponent } from 'react' + +interface Props { + siteKey: string; + action: string; +} + +const ReCaptchaV3: FunctionComponent = ({ siteKey, action }) => { + const inputId = `g-recaptcha-response-data-${action}`.replace(/\//g, '-') + const inputName = `g-recaptcha-response-data[${action}]` + const [token, setToken] = useState('') + + useScript(`https://www.google.com/recaptcha/api.js?render=${siteKey}`, () => { + const { grecaptcha } = window + grecaptcha.ready(() => { + grecaptcha.execute(siteKey, { action: action }) + .then((t: string) => { + setToken(t) + }) + .catch((error: string) => { console.error(error) }) + }) + }) + + return ( +
+ + This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply. + + +
+ ) +} + +export type { Props } +export { ReCaptchaV3 } From 76507a6e53f4006d3decdc6f47b67abcb1d45053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Fri, 12 Jul 2024 08:59:24 +0200 Subject: [PATCH 4/9] New screen to set `:admin_bot_protection_level` --- .../admin/bot_protections_controller.rb | 27 +++++++++++++++++++ app/helpers/vertical_nav_helper.rb | 1 + .../admin/bot_protections/edit.html.slim | 14 ++++++++++ config/routes.rb | 1 + 4 files changed, 43 insertions(+) create mode 100644 app/controllers/provider/admin/bot_protections_controller.rb create mode 100644 app/views/provider/admin/bot_protections/edit.html.slim diff --git a/app/controllers/provider/admin/bot_protections_controller.rb b/app/controllers/provider/admin/bot_protections_controller.rb new file mode 100644 index 0000000000..58f5f906d5 --- /dev/null +++ b/app/controllers/provider/admin/bot_protections_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Provider::Admin::BotProtectionsController < Provider::Admin::BaseController + + activate_menu! :account, :integrate, :bot_protection + + before_action :find_settings + + def edit; end + + def update + if @settings.update(params[:settings]) + flash[:notice] = 'Bot protection settings updated.' + redirect_to edit_provider_admin_bot_protection_url + else + flash[:error] = 'There were problems saving the settings.' + render :action => 'edit' + end + end + + private + + def find_settings + @settings = current_account.settings + end + +end diff --git a/app/helpers/vertical_nav_helper.rb b/app/helpers/vertical_nav_helper.rb index 0b48d0e37d..4e2dfea30d 100644 --- a/app/helpers/vertical_nav_helper.rb +++ b/app/helpers/vertical_nav_helper.rb @@ -77,6 +77,7 @@ def account_itegrate_items items = [] items << {id: :webhooks, title: 'Webhooks', path: edit_provider_admin_webhooks_path} if can? :manage, :web_hooks items << {id: :apidocs, title: '3scale API Docs', path: provider_admin_api_docs_path} + items << {id: :bot_protection, title: 'Bot Protection', path: edit_provider_admin_bot_protection_path} end # Audience diff --git a/app/views/provider/admin/bot_protections/edit.html.slim b/app/views/provider/admin/bot_protections/edit.html.slim new file mode 100644 index 0000000000..3ab687deb2 --- /dev/null +++ b/app/views/provider/admin/bot_protections/edit.html.slim @@ -0,0 +1,14 @@ +- content_for :title, 'Bot Protection' +- content_for :page_header_title, 'Bot Protection' + += semantic_form_for @settings, url: provider_admin_bot_protection_path, html: {:id => 'bot-protection-settings' } do |form| + = form.inputs 'Protection against bots' do + = form.input :admin_bot_protection_level, + label: false, + hint: t("sites.spam_protections.edit.captcha_hint_#{Recaptcha.captcha_configured?.to_s}"), + as: :radio, + collection: ThreeScale::BotProtection::LEVELS, + input_html: { disabled: !Recaptcha.captcha_configured? } + + = form.actions do + = form.commit_button 'Update Settings' diff --git a/config/routes.rb b/config/routes.rb index 8fc06eb566..05316b41c2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -236,6 +236,7 @@ resource :api_docs, :only => [:show] resource :liquid_docs, :only => [:show] resource :webhooks, :only => [ :new, :edit, :create, :update, :show ] + resource :bot_protection, :only => [ :edit, :update ] namespace :registry do constraints(id: /((?!\.json\Z)[^\/])+/) do From 08a775265195d5bbaee3f948fd2238ec44b6dce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Mon, 1 Jul 2024 10:59:33 +0200 Subject: [PATCH 5/9] Add a Recaptcha to the Admin portal login screen --- app/controllers/provider/sessions_controller.rb | 15 ++++++++++----- app/javascript/packs/login.scss | 6 ++++++ app/javascript/src/Login/components/LoginForm.tsx | 8 ++++++++ app/javascript/src/Login/components/LoginPage.tsx | 7 +++++++ app/views/provider/sessions/new.html.slim | 7 ++++++- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/app/controllers/provider/sessions_controller.rb b/app/controllers/provider/sessions_controller.rb index de4bfd990e..f2690fc6f6 100644 --- a/app/controllers/provider/sessions_controller.rb +++ b/app/controllers/provider/sessions_controller.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true class Provider::SessionsController < FrontendController + include ThreeScale::BotProtection::Controller layout 'provider/login' skip_before_action :login_required @@ -12,27 +13,27 @@ class Provider::SessionsController < FrontendController def new @session = Session.new @authentication_providers = published_authentication_providers + @bot_protection_enabled = bot_protection_enabled? end def create session_return_to logout_keeping_session! - @user, strategy = authenticate_user + @user, strategy = authenticate_user if bot_check if @user self.current_user = @user flash[:first_login] = true if current_user.user_sessions.empty? - create_user_session!(strategy.authentication_provider_id) + create_user_session!(strategy&.authentication_provider_id) flash[:notice] = 'Signed in successfully' AuditLogService.call("Signed in: #{current_user.id}/#{current_user.username} #{current_user.first_name} #{current_user.last_name}") redirect_back_or_default provider_admin_path else - @session = Session.new - flash.now[:error] = strategy.error_message - @authentication_providers = published_authentication_providers + new + flash.now[:error] ||= strategy&.error_message render :action => :new end end @@ -107,4 +108,8 @@ def session_return_to def instantiate_sessions_presenter @presenter = Provider::SessionsPresenter.new(domain_account) end + + def bot_protection_level + domain_account.settings.admin_bot_protection_level + end end diff --git a/app/javascript/packs/login.scss b/app/javascript/packs/login.scss index e5338dce9f..e96d8563be 100644 --- a/app/javascript/packs/login.scss +++ b/app/javascript/packs/login.scss @@ -7,3 +7,9 @@ .pf-c-alert.invisible { visibility: hidden; } + +.login-layout { + .grecaptcha-badge { + visibility: hidden; + } +} diff --git a/app/javascript/src/Login/components/LoginForm.tsx b/app/javascript/src/Login/components/LoginForm.tsx index 11a27fdf30..5f63daff6c 100644 --- a/app/javascript/src/Login/components/LoginForm.tsx +++ b/app/javascript/src/Login/components/LoginForm.tsx @@ -9,6 +9,7 @@ import { import { validateLogin } from 'Login/utils/validations' import { CSRFToken } from 'utilities/CSRFToken' +import { ReCaptchaV3 } from 'utilities/ReCaptchaV3' import { LoginAlert } from 'Login/components/LoginAlert' import type { FlashMessage } from 'Types/FlashMessages' @@ -17,6 +18,11 @@ import type { FunctionComponent } from 'react' interface Props { flashMessages: FlashMessage[]; providerSessionsPath: string; + recaptcha: { + enabled: boolean; + siteKey: string; + action: string; + }; session: { username: string | null; }; @@ -25,6 +31,7 @@ interface Props { const LoginForm: FunctionComponent = ({ flashMessages, providerSessionsPath, + recaptcha, session }) => { const [state, setState] = useState({ @@ -114,6 +121,7 @@ const LoginForm: FunctionComponent = ({ onChange={handleOnChange('password')} /> + {recaptcha.enabled && }