-
Notifications
You must be signed in to change notification settings - Fork 74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
THREESCALE-765: Brute force protection for the admin login screen #3841
THREESCALE-765: Brute force protection for the admin login screen #3841
Conversation
|
||
describe('ReCaptchaV3', () => { | ||
it('should render itself correctly', () => { | ||
const wrapper = mount(<ReCaptchaV3 enabled action="test/action" siteKey="fakeKey" />) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This component loads a JS script from google. @josemigallas do you know whether this test would actually download the script every time it runs?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you use a real browser in test (@javascript
in cucumbers), then it will try. But we try to kill internet in these tests so it should fail in circleci.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Surprisingly I did exactly that and the cuke doesn't fail: https://github.com/3scale/porta/blob/1cbf07e991bec976bb8b9a753c5cda740410be85/features/provider/login.feature
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably mock kit somewhere, somehow. For javascript tests you can create a mock like like the ones for Braintree and Stripe: https://github.com/3scale/porta/blob/a25da8553ddd1b1f20f85a8a8e3988c299596f8f/spec/javascripts/__mocks__/braintree-web.js
} | ||
|
||
const ReCaptchaV3 = ({ enabled = false, siteKey, action }: Props): ReactElement => { | ||
if (!enabled) return (<div>{null}</div>) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (!enabled) return (<div>{null}</div>) | |
if (!enabled) { | |
return <div>{null}</div> | |
} |
Also, why <div>{null}</div>
and not null
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because the linter fails with:
TS2322: Type 'null' is not assignable to type 'ReactElement<any, string | JSXElementConstructor<any>>'.
action: string; | ||
} | ||
|
||
const ReCaptchaV3 = ({ enabled = false, siteKey, action }: Props): ReactElement => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const ReCaptchaV3 = ({ enabled = false, siteKey, action }: Props): ReactElement => { | |
const ReCaptchaV3: FunctionComponent<Props> = ({ enabled = false, siteKey, action }) => { |
|
||
import { useScript } from 'utilities/useScript' | ||
|
||
import type { ReactElement } from 'react' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
import type { ReactElement } from 'react' | |
import type { FunctionComponent } from 'react' |
const [token, setToken] = useState('') | ||
|
||
useScript(`https://www.google.com/recaptcha/api.js?render=${siteKey}`, () => { | ||
const grecaptcha = window.grecaptcha |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const grecaptcha = window.grecaptcha | |
const { grecaptcha } = window |
|
||
const ReCaptchaV3 = jest.requireActual('utilities/ReCaptchaV3').ReCaptchaV3 as FunctionComponent<Props> | ||
|
||
describe('ReCaptchaV3', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMHO there's no need to "describe" the element you're testing. The file is already "ReCaptchaV3". I would use describe to wrap different scenarios, for instance:
describe("when recaptcha is disabled", () => {
it('should do that...', () => {})
})
describe("when recaptcha is enabled", () => {
it('should do this...', () => {})
})
Given "{provider} tries to log in" do |provider| | ||
username = provider.admins.first.username | ||
set_current_domain provider.external_admin_domain | ||
visit provider_login_path | ||
fill_in 'Email or Username', with: username | ||
fill_in 'Password', with: "supersecret" | ||
click_button 'Sign in' | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I'm not missing anything, this step is the same as
porta/features/step_definitions/session_steps.rb
Lines 3 to 6 in 3898a60
Given "{provider} logs in" do |provider| | |
set_current_domain(provider.external_admin_domain) | |
try_provider_login(provider.admins.first.username, 'supersecret') | |
end |
When "{provider} wants to log in" do |provider| | ||
set_current_domain provider.external_admin_domain | ||
visit provider_login_path | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of this use:
When they go to the provider login page
Then "the provider login attempt fails" do | ||
assert_current_path "/p/sessions" | ||
assert find_field('Email or Username') | ||
assert find_field('Password') | ||
assert find_button('Sign in', disabled: true) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would simply use:
And the current page is the provider login page
And also assert the error message (if any)
Then they should see "Login failed"
Also, there are steps for those assertions:
And there is a field "Email or Username"
And there is a field "Password"
And there should be a button to "Sign in"
# or
And I should see button "Sign in" disabled
But I honestly think it doesn't have to be that meticulous.
const inputName = `g-recaptcha-response-data[${action}]` | ||
const [token, setToken] = useState('') | ||
|
||
useScript(`https://www.google.com/recaptcha/api.js?render=${siteKey}`, () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you need to useEffect this? Is siteKey
ever going to change dynamically? Maybe I am mistaken but to me it looks like siteKey
is passed once to the component, at first render. If so, there's no need to use an effect, simply render the script.
I wonder if it would make sense to do it in the template (new.html.slim) and use a defer:
<script id="recaptcha_script" src="https://www.google.com/recaptcha/api.js?render=#{siteKey}" defer />
defer
will load the script but wait for the HTML to be parsed before executing the script, but before DOMContentLoaded is triggered.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@@ -114,6 +121,7 @@ const LoginForm: FunctionComponent<Props> = ({ | |||
onChange={handleOnChange('password')} | |||
/> | |||
</FormGroup> | |||
<ReCaptchaV3 action={recaptcha.action} enabled={recaptcha.enabled} siteKey={recaptcha.siteKey} /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think it is correct to pass enabled
. Either render ReCaptchaV3 or don't:
<ReCaptchaV3 action={recaptcha.action} enabled={recaptcha.enabled} siteKey={recaptcha.siteKey} /> | |
{recaptcha.enabled && <ReCaptchaV3 action={recaptcha.action} siteKey={recaptcha.siteKey} />} |
This is a React patttern called "conditional rendering".
Hm, strange, it doesn't work for me as described, i.e. I don't see any Google message on the login screen, and I don't see recaptcha errors with min score 0.99 (after restarting the server). Also, I think it's confusing that the setting in I think we probably need another setting for the admin portal (and its own UI for this setting). The user may decide to use recaptcha for the developer portal, and not for admin portal, and vice versa... I know that's more work, but I think it would be better. UPDATE: I missed the part "on the master portal" 😬 When switching the setting there, it actually works. The place of the setting is still very confusing though, because we don't have a developer portal as such in master 🫠 |
f94eff1
to
debebc6
Compare
@josemigallas I addressed all your comments on the React components. But I couldn't do the same on cucumbers. Some of your suggestions broke some other cukes, and I failed to find a way to fix it. After two days I give up. |
Based on this comment on the issue, I created a new screen under Account Settings -> Integrate -> Bot Protection to enable the login protection for the current provider. |
add_column :settings, :admin_bot_protection_level, :string | ||
change_column_default :settings, :admin_bot_protection_level, "none" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
add_column :settings, :admin_bot_protection_level, :string | |
change_column_default :settings, :admin_bot_protection_level, "none" | |
add_column :settings, :admin_bot_protection_level, :string, default: "none" |
Not sure if there was a specific reason to separate the two operations? If not, I think they can be merged into one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I did that at the beginning, but I received a warning from strong_migrations
because it's an unsafe operation, only for Oracle.
=== Dangerous operation detected #strong_migrations ===
Adding a column with a non-null default causes the entire table to be rewritten.
Instead, add the column without a default value, then change the default.
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
Then backfill the existing rows in the Rails console or a separate migration with disable_ddl_transaction!.
class BackfillAddAdminBotProtectionToSettings < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
def up
Setting.unscoped.in_batches do |relation|
relation.update_all admin_bot_protection_level: "none"
sleep(0.01)
end
end
end
Then add the NOT NULL constraint in separate migrations.
/home/jlledom/redhat/porta/db/migrate/20240711145208_add_admin_bot_protection_to_settings.rb:3:in `up'
So I updated the migration to:
- Split into two operations
- Don't backfill
- Don't make the column
NOT NULL
- Adapt the code to interpret
NULL
as:none
It's much simpler, less risky and works for all DB engines.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, thanks for the explanation @jlledom
The feature works well, although I think that the config screen could be maybe improved a bit. It is currently not clear, what it protects, and it's even more confusing given that there is another screen in the admin portal which is identical. The location of the dev portal one (Developer Portal > Settings > Bot Protection) kind of suggests that it's bot protection for the developer portal, but for this new screen it's not clear, and I think it can be easily confused. |
@mayorova I added some descriptions in 6454b02 I took the text from this comment in the ticket: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This and sites/spam_protections/edit look the same, can we use a partial?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 2cc9c9b
input_html: { disabled: !Recaptcha.captcha_configured? } | ||
|
||
= form.actions do | ||
= form.commit_button 'Update Settings' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no new line?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unacceptable
@@ -0,0 +1,11 @@ | |||
= semantic_form_for settings, url: url, html: {:id => 'bot-protection-settings' } do |form| | |||
= form.inputs 'Protection against bots' do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With this refactor we don't keep the "admin portal" and "developer portal" label in this title. Or it was considered redundant?
Actually, we probably can move this and the commit button labels to I18n, and reuse the complete view, not only the form.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With this refactor we don't keep the "admin portal" and "developer portal" label in this title. Or it was considered redundant?
I don't remember 🤕
Actually, we probably can move this and the commit button labels to I18n, and reuse the complete view, not only the form.
I don't like the idea of reuse the complete view, because then we should set some parameters like url
or field
from the controller.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, but we can still use I18n for Protection against bots
and set it to different values for admin and dev portal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here you go: 1f9ef72
@jlledom I've updated this PR with more suggestions 😬 I just find it easier to follow and update. But it's OK to keep as it is, the only real suggestion is to update the description. And also looks like this branch needs some conflict resolution. |
50a917f
to
ad41e23
Compare
@mayorova I applied your suggestions and resolved the conflict |
It showed errors twice in the admin login screen.
8f45824
to
e9d88d9
Compare
What this PR does / why we need it:
Add a Recaptcha to the admin portal login screen.
This PR includes a new screen under Account Settings -> Integrate -> Bot Protection to enable/disable the Recaptcha.
Which issue(s) this PR fixes
https://issues.redhat.com/browse/THREESCALE-765
Verification steps
RECAPTCHA_MIN_BOT_SCORE
to0.99
and restart the serverSpecial notes for your reviewer:
Some references I took to write the React component:
Please, @josemigallas @lvillen review the UI part, it's probably not aligned with design guidelines or not well integrated with the rest of the UI code base.