Skip to content

Commit

Permalink
Merge pull request #3841 from jlledom/THREESCALE-765-brute-force-admi…
Browse files Browse the repository at this point in the history
…n-login

THREESCALE-765: Brute force protection for the admin login screen
  • Loading branch information
jlledom authored Aug 5, 2024
2 parents be41de0 + e9d88d9 commit b17a18a
Show file tree
Hide file tree
Showing 37 changed files with 392 additions and 97 deletions.
27 changes: 27 additions & 0 deletions app/controllers/provider/admin/bot_protections_controller.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 10 additions & 5 deletions app/controllers/provider/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true
class Provider::SessionsController < FrontendController
include ThreeScale::BotProtection::Controller

layout 'provider/login'
skip_before_action :login_required
Expand All @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions app/helpers/vertical_nav_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/javascript/packs/login.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@
.pf-c-alert.invisible {
visibility: hidden;
}

.login-layout {
.grecaptcha-badge {
visibility: hidden;
}
}
8 changes: 8 additions & 0 deletions app/javascript/src/Login/components/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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;
};
Expand All @@ -25,6 +31,7 @@ interface Props {
const LoginForm: FunctionComponent<Props> = ({
flashMessages,
providerSessionsPath,
recaptcha,
session
}) => {
const [state, setState] = useState({
Expand Down Expand Up @@ -114,6 +121,7 @@ const LoginForm: FunctionComponent<Props> = ({
onChange={handleOnChange('password')}
/>
</FormGroup>
{recaptcha.enabled && <ReCaptchaV3 action={recaptcha.action} siteKey={recaptcha.siteKey} /> }

<ActionGroup>
<Button
Expand Down
7 changes: 7 additions & 0 deletions app/javascript/src/Login/components/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ interface Props {
providerRequestPasswordResetPath: string;
show3scaleLoginForm: boolean;
disablePasswordReset: boolean;
recaptcha: {
enabled: boolean;
siteKey: string;
action: string;
};
session: {
username: string | null;
};
Expand All @@ -28,6 +33,7 @@ const LoginPage: FunctionComponent<Props> = ({
flashMessages,
providerRequestPasswordResetPath,
providerSessionsPath,
recaptcha,
session,
show3scaleLoginForm
}) => (
Expand All @@ -53,6 +59,7 @@ const LoginPage: FunctionComponent<Props> = ({
<LoginForm
flashMessages={flashMessages}
providerSessionsPath={providerSessionsPath}
recaptcha={recaptcha}
session={session}
/>
)}
Expand Down
10 changes: 10 additions & 0 deletions app/javascript/src/Types/ReCaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
declare global {
interface Window {
grecaptcha: ReCaptchaInstance;
}
}

export interface ReCaptchaInstance {
ready: (cb: () => void) => void;
execute: (sitekey: string, options: { action: string }) => Promise<string>;
}
39 changes: 39 additions & 0 deletions app/javascript/src/utilities/ReCaptchaV3.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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 (
<div>
<small>
This site is protected by reCAPTCHA and the Google <a href="https://policies.google.com/privacy">Privacy Policy</a> and <a href="https://policies.google.com/terms">Terms of Service</a> apply.
</small>
<input className="g-recaptcha g-recaptcha-response" data-sitekey={siteKey} id={inputId} name={inputName} type="hidden" value={token} />
</div>
)
}

export type { Props }
export { ReCaptchaV3 }
19 changes: 19 additions & 0 deletions app/javascript/src/utilities/useScript.ts
Original file line number Diff line number Diff line change
@@ -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 }
2 changes: 1 addition & 1 deletion app/lib/three_scale/bot_protection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module ThreeScale
module BotProtection
LEVELS = [['None', :none], ['reCAPTCHA', :captcha]].freeze
LEVELS = [['None', :none], ['reCAPTCHA v3', :captcha]].freeze
end
end

2 changes: 1 addition & 1 deletion app/lib/three_scale/bot_protection/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def bot_check(options = { flash: true })
def verify_captcha(options)
success = verify_recaptcha(action: controller_path, minimum_score: Rails.configuration.three_scale.recaptcha_min_bot_score)

flash[:error] = flash[:recaptcha_error] if options[:flash] && flash.key?(:recaptcha_error)
flash.now[:error] = flash[:recaptcha_error] if options[:flash] && flash.key?(:recaptcha_error)
flash.delete(:recaptcha_error)

success
Expand Down
9 changes: 7 additions & 2 deletions app/models/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/views/provider/admin/bot_protections/_form.html.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
= semantic_form_for settings, url: url, html: {:id => 'bot-protection-settings' } do |form|
= form.inputs selector_label do
= form.input field,
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'
5 changes: 5 additions & 0 deletions app/views/provider/admin/bot_protections/edit.html.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- content_for :title, 'Bot Protection'
- content_for :page_header_title, 'Bot Protection'
- content_for :page_header_body, t('.description')

= render partial: 'form', locals: { url: provider_admin_bot_protection_path, settings: @settings, field: :admin_bot_protection_level, selector_label: t('.selector_label') }
7 changes: 6 additions & 1 deletion app/views/provider/sessions/new.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
- authentication_providers = (@authentication_providers || []).map { |ap| {authorizeURL: ap.authorize_url, humanKind: ap.human_kind} }
- flash_messages = (flash || []).map { |f| {type: f[0], message: f[1]}}
- is_master_account = domain_account.master?
- recaptcha_enabled = !!@bot_protection_enabled
- recaptcha_site_key = Rails.configuration.three_scale.recaptcha_public_key
- recaptcha_action = controller_path
div#pf-login-page-container data-login-props={redirectUrl: (session[:return_to].nil? ? "null" : (request.protocol + request.host_with_port + session[:return_to])),
authenticationProviders: authentication_providers,
flashMessages: flash_messages,
Expand All @@ -11,4 +14,6 @@ div#pf-login-page-container data-login-props={redirectUrl: (session[:return_to].
providerLoginPath: provider_login_path,
providerAdminDashboardPath: provider_admin_dashboard_path,
disablePasswordReset: is_master_account,
session: {username: params[:username]} }.to_json
recaptcha: { enabled: recaptcha_enabled, siteKey: recaptcha_site_key, action: recaptcha_action },
session: {username: params[:username]},
}.to_json
21 changes: 3 additions & 18 deletions app/views/sites/spam_protections/edit.html.erb
Original file line number Diff line number Diff line change
@@ -1,20 +1,5 @@
<% content_for(:title) do %>
Bot Protection
<% end %>

<% content_for :title, 'Bot Protection' %>
<% content_for :page_header_title, 'Bot Protection' %>
<% content_for :page_header_body, t('.description') %>

<%= semantic_form_for @settings, :url => admin_site_spam_protection_path, :html => {:id => 'spam-protection-settings' } do |form| %>
<%= form.inputs 'Protection against bots' do %>
<%= form.input :spam_protection_level,
:label => false,
hint: t(".captcha_hint_#{Recaptcha.captcha_configured?.to_s}"),
:as => :radio,
:collection => ThreeScale::BotProtection::LEVELS,
input_html: { disabled: !Recaptcha.captcha_configured? } %>
<% end %>

<%= form.actions do %>
<%= form.commit_button 'Update Settings' %>
<% end %>
<% end %>
<%= render partial: 'provider/admin/bot_protections/form', locals: { url: admin_site_spam_protection_path, settings: @settings, field: :spam_protection_level, selector_label: t('.selector_label') } %>
6 changes: 6 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,10 @@ en:
edit:
delete_link: I understand the consequences, proceed to delete '%{name}' backend
delete_confirmation: Are you sure you want to delete the backend '%{name}'?
bot_protections:
edit:
selector_label: Protection against bots on the admin portal
description: Bot protection uses Google reCAPTCHA to detect bots and prevent malicious attacks on the admin portal.
keys:
create:
success: A new key has been added.
Expand Down Expand Up @@ -609,6 +613,8 @@ en:
sites:
spam_protections:
edit:
selector_label: Protection against bots on the developer portal
description: Bot protection uses Google reCAPTCHA to detect bots and prevent malicious attacks on the developer portal.
captcha_hint_false: 'reCAPTCHA has not been configured correctly, bot protection cannot be enabled.'
captcha_hint_true: "reCAPTCHA v3 will invisibly verify interactions to detect bots."
usage_rules:
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions db/migrate/20240715075900_add_admin_bot_protection_to_settings.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions db/oracle_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions db/postgres_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading

0 comments on commit b17a18a

Please sign in to comment.