Commit 4e6e375a authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Paul Gascou-Vaillancourt

Implement ArkoseLabs sign-in challenge

This adds ArkoseLabs' challenge to the sign-in form.
This is behind the arkose_labs_login_challenge feature flag.
parent 4d048f64
= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f|
= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-sign-in-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f|
.form-group
= f.label _('Username or email'), for: 'user_login', class: 'label-bold'
= f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' }
= f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' }
.form-group
= f.label :password, class: 'label-bold'
= f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
......@@ -16,8 +16,10 @@
- else
= link_to _('Forgot your password?'), new_password_path(:user)
%div
- if captcha_enabled? || captcha_on_login_required?
- if Feature.enabled?(:arkose_labs_login_challenge)
= render_if_exists 'devise/sessions/arkose_labs'
- elsif captcha_enabled? || captcha_on_login_required?
= recaptcha_tags nonce: content_security_policy_nonce
.submit-container.move-submit-down
= f.submit _('Sign in'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'sign_in_button' }
= f.button _('Sign in'), type: :submit, class: "gl-button btn btn-block btn-confirm js-sign-in-button#{' js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' }
import axios from '~/lib/utils/axios_utils';
import { buildApiUrl } from '~/api/api_utils';
const USERNAME_PLACEHOLDER = ':username';
const ENDPOINT = `/api/:version/users/${USERNAME_PLACEHOLDER}/captcha_check`;
export const needsArkoseLabsChallenge = (username = '') =>
axios.get(buildApiUrl(ENDPOINT).replace(USERNAME_PLACEHOLDER, encodeURIComponent(username)));
import Vue from 'vue';
import { GlAlert } from '@gitlab/ui';
import { needsArkoseLabsChallenge } from 'ee/rest_api';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { __ } from '~/locale';
const VERIFICATION_TOKEN_INPUT_NAME = 'arkose_labs_token';
const LOADING_ICON = loadingIconForLegacyJS({ classes: ['gl-mr-2'] });
const CHALLENGE_ERRORS_CONTAINER_CLASS = 'js-arkose-labs-error-message';
export class ArkoseLabs {
constructor() {
this.signInForm = document.querySelector('.js-sign-in-form');
if (!this.signInForm) {
return;
}
this.usernameField = this.signInForm.querySelector('.js-username-field');
this.arkoseLabsChallengeContainer = this.signInForm.querySelector('.js-arkose-labs-challenge');
this.signInButton = this.signInForm.querySelector('.js-sign-in-button');
this.onUsernameFieldBlur = this.onUsernameFieldBlur.bind(this);
this.onSignInFormSubmitted = this.onSignInFormSubmitted.bind(this);
this.setConfig = this.setConfig.bind(this);
this.passArkoseLabsChallenge = this.passArkoseLabsChallenge.bind(this);
this.handleArkoseLabsFailure = this.handleArkoseLabsFailure.bind(this);
this.publicKey = this.arkoseLabsChallengeContainer.dataset.apiKey;
this.username = this.usernameField.value || '';
this.arkoseLabsInitialized = false;
this.arkoseLabsChallengePassed = false;
window.setupArkoseLabsEnforcement = this.setConfig;
this.attachEventListeners();
if (this.username.length) {
this.checkIfNeedsChallenge();
}
}
attachEventListeners() {
this.usernameField.addEventListener('blur', this.onUsernameFieldBlur);
this.signInForm.addEventListener('submit', this.onSignInFormSubmitted);
}
detachEventListeners() {
this.usernameField.removeEventListener('blur', this.onUsernameFieldBlur);
this.signInForm.removeEventListener('submit', this.onSignInFormSubmitted);
}
onUsernameFieldBlur() {
const { value } = this.usernameField;
if (this.username !== this.usernameField.value) {
this.username = value;
this.checkIfNeedsChallenge();
}
}
onSignInFormSubmitted(e) {
if (!this.arkoseLabsInitialized || this.arkoseLabsChallengePassed) {
return;
}
e.preventDefault();
this.showArkoseLabsErrorMessage();
}
async checkIfNeedsChallenge() {
if (this.arkoseLabsInitialized) {
return;
}
this.setButtonLoadingState();
try {
const {
data: { result },
} = await needsArkoseLabsChallenge(this.username);
if (result) {
this.initArkoseLabsChallenge();
}
} catch {
// API call failed, do not initialize Arkose challenge.
// Button will be reset in `finally` block.
} finally {
this.resetButton();
}
}
setButtonLoadingState() {
const label = __('Loading');
this.signInButton.innerHTML = `
${LOADING_ICON.outerHTML}
${label}
`;
this.signInButton.setAttribute('disabled', true);
}
resetButton() {
this.signInButton.innerText = __('Sign in');
this.signInButton.removeAttribute('disabled');
}
initArkoseLabsChallenge() {
this.arkoseLabsInitialized = true;
const tag = document.createElement('script');
[
['type', 'text/javascript'],
['src', `https://client-api.arkoselabs.com/v2/${this.publicKey}/api.js`],
['nonce', true],
['async', true],
['defer', true],
['data-callback', 'setupArkoseLabsEnforcement'],
].forEach(([attr, value]) => {
tag.setAttribute(attr, value);
});
document.head.appendChild(tag);
const tokenInput = document.createElement('input');
tokenInput.name = VERIFICATION_TOKEN_INPUT_NAME;
tokenInput.setAttribute('type', 'hidden');
this.tokenInput = tokenInput;
this.signInForm.appendChild(tokenInput);
}
setConfig(enforcement) {
enforcement.setConfig({
mode: 'inline',
selector: '.js-arkose-labs-challenge',
onShown: () => {
this.arkoseLabsChallengeContainer.classList.remove('gl-display-none!');
},
onCompleted: this.passArkoseLabsChallenge,
onSuppress: this.passArkoseLabsChallenge,
onError: this.handleArkoseLabsFailure,
});
}
createArkoseLabsErrorMessageContainer() {
if (!this.arkoseLabsErrorMessageContainer) {
const arkoseLabsErrorMessageContainer = document.createElement('div');
arkoseLabsErrorMessageContainer.className = `gl-mb-3 ${CHALLENGE_ERRORS_CONTAINER_CLASS}`;
arkoseLabsErrorMessageContainer.setAttribute('data-testid', 'arkose-labs-error-message');
this.arkoseLabsChallengeContainer.parentNode.insertBefore(
arkoseLabsErrorMessageContainer,
this.arkoseLabsChallengeContainer.nextSibling,
);
this.arkoseLabsErrorMessageContainer = arkoseLabsErrorMessageContainer;
}
this.arkoseLabsErrorMessageContainer.classList.remove('gl-display-none');
}
showArkoseLabsErrorMessage() {
this.createArkoseLabsErrorMessageContainer();
this.arkoseLabsErrorMessageContainer.innerHTML = `
<span class="gl-text-red-500">
${__('Complete verification to sign in.')}
</span>`;
}
hideArkoseLabsErrorMessage() {
this.arkoseLabsErrorMessageContainer?.classList.add('gl-display-none');
}
passArkoseLabsChallenge(response) {
this.arkoseLabsChallengePassed = true;
this.tokenInput.value = response.token;
this.hideArkoseLabsErrorMessage();
}
handleArkoseLabsFailure() {
this.createArkoseLabsErrorMessageContainer();
return new Vue({
el: `.${CHALLENGE_ERRORS_CONTAINER_CLASS}`,
components: { GlAlert },
render(h) {
return h(
GlAlert,
{
props: {
title: __('Unable to verify the user'),
dismissible: false,
variant: 'danger',
},
attrs: {
'data-testid': 'arkose-labs-failure-alert',
},
},
__(
'An error occurred when loading the user verification challenge. Refresh to try again.',
),
);
},
});
}
}
import '~/pages/sessions/new/index';
if (gon.features.arkoseLabsLoginChallenge) {
import('ee/arkose_labs/arkose_labs')
.then(({ ArkoseLabs }) => {
// eslint-disable-next-line no-new
new ArkoseLabs();
})
.catch(() => {});
}
export * from './api/groups_api';
export * from './api/subscriptions_api';
export * from './api/dora_api';
export * from './api/arkose_labs_api';
# frozen_string_literal: true
module ArkoseLabsCSP
extend ActiveSupport::Concern
included do
content_security_policy do |policy|
next unless Feature.enabled?(:arkose_labs_login_challenge)
default_script_src = policy.directives['script-src'] || policy.directives['default-src']
script_src_values = Array.wrap(default_script_src) | ["https://client-api.arkoselabs.com"]
policy.script_src(*script_src_values)
default_frame_src = policy.directives['frame-src'] || policy.directives['default-src']
frame_src_values = Array.wrap(default_frame_src) | ['https://client-api.arkoselabs.com']
policy.frame_src(*frame_src_values)
end
end
end
......@@ -6,7 +6,12 @@ module EE
extend ::Gitlab::Utils::Override
prepended do
include ArkoseLabsCSP
before_action :gitlab_geo_logout, only: [:destroy]
before_action only: [:new] do
push_frontend_feature_flag(:arkose_labs_login_challenge, default_enabled: :yaml)
end
end
override :new
......@@ -18,6 +23,10 @@ module EE
state = geo_login_state.encode
redirect_to oauth_geo_auth_url(host: current_node_uri.host, port: current_node_uri.port, state: state)
else
if ::Feature.enabled?(:arkose_labs_login_challenge)
@arkose_labs_public_key ||= ENV['ARKOSE_LABS_PUBLIC_KEY'] # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
super
end
end
......
.js-arkose-labs-challenge.gl-display-flex.gl-justify-content-center.gl-mt-3.gl-mb-n3{ class: "gl-display-none!", data: { api_key: @arkose_labs_public_key, testid: 'arkose-labs-challenge' } }
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'ArkoseLabs content security policy' do
let(:user) { create(:user) }
it 'has proper Content Security Policy headers' do
visit root_path
expect(response_headers['Content-Security-Policy']).to include('https://client-api.arkoselabs.com')
end
end
import MockAdapter from 'axios-mock-adapter';
import * as arkoseLabsApi from 'ee/api/arkose_labs_api';
import axios from '~/lib/utils/axios_utils';
describe('ArkoseLabs API', () => {
let axiosMock;
beforeEach(() => {
window.gon = { api_version: 'v4' };
axiosMock = new MockAdapter(axios);
});
afterEach(() => {
axiosMock.restore();
});
describe('needsArkoseLabsChallenge', () => {
beforeEach(() => {
jest.spyOn(axios, 'get');
axiosMock.onGet().reply(200);
});
it.each`
username | expectedUrlFragment
${undefined} | ${''}
${''} | ${''}
${'foo'} | ${'foo'}
${'éøà'} | ${'%C3%A9%C3%B8%C3%A0'}
${'dot.slash/'} | ${'dot.slash%2F'}
`(
'calls the API with $expectedUrlFragment in the URL when given $username as the username',
({ username, expectedUrlFragment }) => {
arkoseLabsApi.needsArkoseLabsChallenge(username);
expect(axios.get).toHaveBeenCalledWith(
`/api/v4/users/${expectedUrlFragment}/captcha_check`,
);
},
);
});
});
import AxiosMockAdapter from 'axios-mock-adapter';
import { ArkoseLabs } from 'ee/arkose_labs/arkose_labs';
import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
describe('ArkoseLabs', () => {
let arkoseLabs;
let axiosMock;
// Finders
const findByTestId = (testId) => document.querySelector(`[data-testid="${testId}"]`);
const findScriptTags = () => document.querySelectorAll('script');
const findSignInForm = () => findByTestId('sign-in-form');
const findUsernameInput = () => findByTestId('username-field');
const findSignInButton = () => findByTestId('sign-in-button');
const findArkoseLabsChallengeContainer = () => findByTestId('arkose-labs-challenge');
const findArkoseLabsErrorMessage = () => findByTestId('arkose-labs-error-message');
const findArkoseLabsFailureAlert = () => findByTestId('arkose-labs-failure-alert');
const findArkoseLabsVerificationTokenInput = () =>
document.querySelector('input[name="arkose_labs_token"]');
// Helpers
const createForm = (username = '') => {
loadFixtures('sessions/new.html');
findUsernameInput().value = username;
};
const initArkoseLabs = (username) => {
createForm(username);
arkoseLabs = new ArkoseLabs();
};
const setUsername = (username) => {
const input = findUsernameInput();
input.focus();
input.value = username;
input.blur();
};
const submitForm = () => {
findSignInForm().dispatchEvent(new Event('submit'));
};
// Assertions
const itInitializesArkoseLabs = () => {
it("includes ArkoseLabs' script", () => {
expect(findScriptTags().length).toBe(1);
});
it('creates a hidden input for the verification token', () => {
const input = findArkoseLabsVerificationTokenInput();
expect(input).not.toBeNull();
expect(input.value).toBe('');
});
};
const expectHiddenArkoseLabsError = () => {
expect(findArkoseLabsErrorMessage().classList.contains('gl-display-none')).toBe(true);
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
});
it('skips the initialization if the login form is not present', () => {
expect(() => {
arkoseLabs = new ArkoseLabs();
}).not.toThrow();
expect(arkoseLabs.signInForm).toBeNull();
});
describe('when the username field is pre-filled', () => {
const username = 'invite-email-username';
it("does not include ArkoseLabs' script initially", () => {
expect(findScriptTags().length).toBe(0);
});
it('puts the sign-in button in the loading state', () => {
initArkoseLabs(username);
const signInButton = findSignInButton();
expect(signInButton.innerText).toMatchInterpolatedText('Loading');
expect(signInButton.disabled).toBe(true);
});
it('triggers a request to the captcha_check API', async () => {
initArkoseLabs(username);
expect(axiosMock.history.get).toHaveLength(0);
await waitForPromises();
expect(axiosMock.history.get).toHaveLength(1);
expect(axiosMock.history.get[0].url).toMatch(`/users/${username}/captcha_check`);
});
describe('if the challenge is not needed', () => {
beforeEach(async () => {
axiosMock.onGet().reply(200, { result: false });
initArkoseLabs(username);
await waitForPromises();
});
it('resets the loading button', () => {
const signInButton = findSignInButton();
expect(signInButton.innerText).toMatchInterpolatedText('Sign in');
});
it('does not show ArkoseLabs error when submitting the form', () => {
submitForm();
expect(findArkoseLabsErrorMessage()).toBe(null);
});
describe('if the challenge becomes needed', () => {
beforeEach(async () => {
axiosMock.onGet().reply(200, { result: true });
setUsername('bob');
await waitForPromises();
});
itInitializesArkoseLabs();
});
});
describe('if the challenge is needed', () => {
beforeEach(async () => {
axiosMock.onGet().reply(200, { result: true });
initArkoseLabs(username);
await waitForPromises();
});
itInitializesArkoseLabs();
it('shows ArkoseLabs error when submitting the form', () => {
submitForm();
expect(findArkoseLabsErrorMessage()).not.toBe(null);
});
it('un-hides the challenge container once the iframe has been shown', () => {
let onShown;
arkoseLabs.setConfig({
setConfig: ({ onShown: handler }) => {
onShown = handler;
},
});
expect(findArkoseLabsChallengeContainer().classList.contains('gl-display-none!')).toBe(
true,
);
onShown();
expect(findArkoseLabsChallengeContainer().classList.contains('gl-display-none!')).toBe(
false,
);
});
it('shows an error alert if the challenge fails to load', () => {
let onError;
arkoseLabs.setConfig({
setConfig: ({ onError: handler }) => {
onError = handler;
},
});
expect(findArkoseLabsFailureAlert()).toBe(null);
onError();
expect(findArkoseLabsFailureAlert()).not.toBe(null);
});
describe.each`
handlerName
${'onCompleted'}
${'onSuppress'}
`(
'when ArkoseLabs calls `$handlerName` handler that has been configured',
({ handlerName }) => {
let handlerMock;
const enforcement = {
setConfig: ({ [handlerName]: handler }) => {
handlerMock = handler;
},
};
const response = { token: 'verification-token' };
beforeEach(() => {
submitForm();
arkoseLabs.setConfig(enforcement);
handlerMock(response);
});
it('removes ArkoseLabs error', () => {
expectHiddenArkoseLabsError();
});
it('does not show again the error when re-submitting the form', () => {
submitForm();
expectHiddenArkoseLabsError();
});
it("sets the verification token input's value", () => {
expect(findArkoseLabsVerificationTokenInput().value).toBe(response.token);
});
},
);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'devise/sessions/new' do
before do
view.instance_variable_set(:@arkose_labs_public_key, "arkose-api-key")
end
describe 'ArkoseLabs challenge' do
subject { render(template: 'devise/sessions/new', layout: 'layouts/devise') }
before do
stub_devise
disable_captcha
allow(Gitlab).to receive(:com?).and_return(true)
end
context 'when the :arkose_labs_login_challenge feature flag is enabled' do
before do
stub_feature_flags(arkose_labs_login_challenge: true)
subject
end
it 'renders the challenge container' do
expect(rendered).to have_css('.js-arkose-labs-challenge')
end
it 'passes the API key to the challenge container' do
expect(rendered).to have_selector('.js-arkose-labs-challenge[data-api-key="arkose-api-key"]')
end
end
context 'when the :arkose_labs_login_challenge feature flag is disabled' do
before do
stub_feature_flags(arkose_labs_login_challenge: false)
subject
end
it 'does not render challenge container' do
expect(rendered).not_to have_css('.js-arkose-labs-challenge')
end
end
end
def stub_devise
allow(view).to receive(:devise_mapping).and_return(Devise.mappings[:user])
allow(view).to receive(:resource).and_return(spy)
allow(view).to receive(:resource_name).and_return(:user)
end
def disable_captcha
allow(view).to receive(:captcha_enabled?).and_return(false)
allow(view).to receive(:captcha_on_login_required?).and_return(false)
end
end
......@@ -3819,6 +3819,9 @@ msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
msgid "An error occurred when loading the user verification challenge. Refresh to try again."
msgstr ""
msgid "An error occurred when updating the title"
msgstr ""
......@@ -9176,6 +9179,9 @@ msgstr ""
msgid "Complete"
msgstr ""
msgid "Complete verification to sign in."
msgstr ""
msgid "Completed"
msgstr ""
......@@ -39801,6 +39807,9 @@ msgstr ""
msgid "Unable to update this issue at this time."
msgstr ""
msgid "Unable to verify the user"
msgstr ""
msgid "Unapprove a merge request"
msgstr ""
......
......@@ -3,6 +3,10 @@
require 'spec_helper'
RSpec.describe 'devise/sessions/new' do
before do
allow(view).to receive(:arkose_labs_public_key).and_return("arkose-api-key")
end
describe 'marketing text' do
subject { render(template: 'devise/sessions/new', layout: 'layouts/devise') }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment