Commit 7a19589b authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch 'arkose-labs-challenge' into 'master'

Implement ArkoseLabs sign-in challenge

See merge request gitlab-org/gitlab!82737
parents 4d048f64 7ecc4b66
= 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)));
<script>
import { uniqueId } from 'lodash';
import { GlAlert } from '@gitlab/ui';
import { needsArkoseLabsChallenge } from 'ee/rest_api';
import { logError } from '~/lib/logger';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { __ } from '~/locale';
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
import { initArkoseLabsScript } from '../init_arkose_labs_script';
const LOADING_ICON = loadingIconForLegacyJS({ classes: ['gl-mr-2'] });
const MSG_ARKOSE_NEEDED = __('Complete verification to sign in.');
const MSG_ARKOSE_FAILURE_TITLE = __('Unable to verify the user');
const MSG_ARKOSE_FAILURE_BODY = __(
'An error occurred when loading the user verification challenge. Refresh to try again.',
);
const ARKOSE_CONTAINER_CLASS = 'js-arkose-labs-container-';
const VERIFICATION_TOKEN_INPUT_NAME = 'arkose_labs_token';
export default {
components: {
DomElementListener,
GlAlert,
},
props: {
publicKey: {
type: String,
required: true,
},
formSelector: {
type: String,
required: true,
},
usernameSelector: {
type: String,
required: true,
},
submitSelector: {
type: String,
required: true,
},
},
data() {
return {
arkoseLabsIframeShown: false,
showArkoseNeededError: false,
showArkoseFailure: false,
username: '',
isLoading: false,
arkoseInitialized: false,
arkoseToken: '',
arkoseContainerClass: uniqueId(ARKOSE_CONTAINER_CLASS),
arkoseChallengePassed: false,
};
},
computed: {
isVisible() {
return this.arkoseLabsIframeShown || this.showErrorContainer;
},
showErrorContainer() {
return this.showArkoseNeededError || this.showArkoseFailure;
},
},
watch: {
username() {
this.checkIfNeedsChallenge();
},
isLoading(val) {
this.updateSubmitButtonLoading(val);
},
},
mounted() {
this.username = this.getUsernameValue();
},
methods: {
onArkoseLabsIframeShown() {
this.arkoseLabsIframeShown = true;
},
hideErrors() {
this.showArkoseNeededError = false;
this.showArkoseFailure = false;
},
getUsernameValue() {
return document.querySelector(this.usernameSelector)?.value || '';
},
onUsernameBlur() {
this.username = this.getUsernameValue();
},
onSubmit(e) {
if (!this.arkoseInitialized || this.arkoseChallengePassed) {
return;
}
e.preventDefault();
this.showArkoseNeededError = true;
},
async checkIfNeedsChallenge() {
if (!this.username || this.arkoseInitialized) {
return;
}
this.isLoading = true;
try {
const {
data: { result },
} = await needsArkoseLabsChallenge(this.username);
if (result) {
await this.initArkoseLabs();
}
} catch (e) {
if (e.response?.status === 404) {
// We ignore 404 errors as it just means the username does not exist.
} else if (e.response?.status) {
// If the request failed with any other error code, we initialize the challenge to make
// sure it isn't being bypassed by purposefully making the endpoint fail.
this.initArkoseLabs();
} else {
// For any other failure, we show the initialization error message.
this.handleArkoseLabsFailure(e);
}
} finally {
this.isLoading = false;
}
},
async initArkoseLabs() {
this.arkoseInitialized = true;
const enforcement = await initArkoseLabsScript({ publicKey: this.publicKey });
enforcement.setConfig({
mode: 'inline',
selector: `.${this.arkoseContainerClass}`,
onShown: this.onArkoseLabsIframeShown,
onCompleted: this.passArkoseLabsChallenge,
onError: this.handleArkoseLabsFailure,
});
},
passArkoseLabsChallenge(response) {
this.arkoseChallengePassed = true;
this.arkoseToken = response.token;
this.hideErrors();
},
handleArkoseLabsFailure(e) {
logError('ArkoseLabs initialization error', e);
this.showArkoseFailure = true;
},
updateSubmitButtonLoading(val) {
const button = document.querySelector(this.submitSelector);
if (val) {
const label = __('Loading');
button.innerHTML = `
${LOADING_ICON.outerHTML}
${label}
`;
button.setAttribute('disabled', true);
} else {
button.innerText = __('Sign in');
button.removeAttribute('disabled');
}
},
},
MSG_ARKOSE_NEEDED,
MSG_ARKOSE_FAILURE_TITLE,
MSG_ARKOSE_FAILURE_BODY,
VERIFICATION_TOKEN_INPUT_NAME,
};
</script>
<template>
<div v-show="isVisible">
<input
v-if="arkoseInitialized"
:name="$options.VERIFICATION_TOKEN_INPUT_NAME"
type="hidden"
:value="arkoseToken"
/>
<dom-element-listener :selector="usernameSelector" @blur="onUsernameBlur" />
<dom-element-listener :selector="formSelector" @submit="onSubmit" />
<div
class="gl-display-flex gl-justify-content-center gl-mt-3 gl-mb-n3"
:class="arkoseContainerClass"
data-testid="arkose-labs-challenge"
></div>
<div v-if="showErrorContainer" class="gl-mb-3" data-testid="arkose-labs-error-message">
<gl-alert
v-if="showArkoseFailure"
:title="$options.MSG_ARKOSE_FAILURE_TITLE"
variant="danger"
:dismissible="false"
>
{{ $options.MSG_ARKOSE_FAILURE_BODY }}
</gl-alert>
<span v-else-if="showArkoseNeededError" class="gl-text-red-500">
{{ $options.MSG_ARKOSE_NEEDED }}
</span>
</div>
</div>
</template>
import Vue from 'vue';
import SignInArkoseApp from './components/sign_in_arkose_app.vue';
const FORM_SELECTOR = '.js-sign-in-form';
const USERNAME_SELECTOR = `${FORM_SELECTOR} .js-username-field`;
const SUBMIT_SELECTOR = `${FORM_SELECTOR} .js-sign-in-button`;
export const setupArkoseLabs = () => {
const signInForm = document.querySelector(FORM_SELECTOR);
const el = signInForm?.querySelector('.js-arkose-labs-challenge');
if (!el) {
return null;
}
const publicKey = el.dataset.apiKey;
return new Vue({
el,
render(h) {
return h(SignInArkoseApp, {
props: {
publicKey,
formSelector: FORM_SELECTOR,
usernameSelector: USERNAME_SELECTOR,
submitSelector: SUBMIT_SELECTOR,
},
});
},
});
};
import { uniqueId } from 'lodash';
const CALLBACK_NAME = '_initArkoseLabsScript_callback_';
const getCallbackName = () => uniqueId(CALLBACK_NAME);
export const initArkoseLabsScript = ({ publicKey }) => {
const callbackFunctionName = getCallbackName();
return new Promise((resolve) => {
window[callbackFunctionName] = (enforcement) => {
delete window[callbackFunctionName];
resolve(enforcement);
};
const tag = document.createElement('script');
[
['type', 'text/javascript'],
['src', `https://client-api.arkoselabs.com/v2/${publicKey}/api.js`],
['data-callback', callbackFunctionName],
].forEach(([attr, value]) => {
tag.setAttribute(attr, value);
});
document.head.appendChild(tag);
});
};
import '~/pages/sessions/new/index';
if (gon.features.arkoseLabsLoginChallenge) {
import('ee/arkose_labs')
.then(({ setupArkoseLabs }) => {
setupArkoseLabs();
})
.catch((e) => {
throw e;
});
}
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{ data: { api_key: @arkose_labs_public_key } }
# 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 { nextTick } from 'vue';
import AxiosMockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import SignInArkoseApp from 'ee/arkose_labs/components/sign_in_arkose_app.vue';
import axios from '~/lib/utils/axios_utils';
import { logError } from '~/lib/logger';
import waitForPromises from 'helpers/wait_for_promises';
import { initArkoseLabsScript } from 'ee/arkose_labs/init_arkose_labs_script';
jest.mock('~/lib/logger');
// ArkoseLabs enforcement mocks
jest.mock('ee/arkose_labs/init_arkose_labs_script');
let onShown;
let onCompleted;
let onError;
initArkoseLabsScript.mockImplementation(() => ({
setConfig: ({ onShown: shownHandler, onCompleted: completedHandler, onError: errorHandler }) => {
onShown = shownHandler;
onCompleted = completedHandler;
onError = errorHandler;
},
}));
const MOCK_USERNAME = 'cassiopeia';
const MOCK_PUBLIC_KEY = 'arkose-labs-public-api-key';
describe('SignInArkoseApp', () => {
let wrapper;
let axiosMock;
// Finders
const makeTestIdSelector = (testId) => `[data-testid="${testId}"]`;
const findByTestId = (testId) => document.querySelector(makeTestIdSelector(testId));
const findSignInForm = () => findByTestId('sign-in-form');
const findUsernameInput = () => findByTestId('username-field');
const findSignInButton = () => findByTestId('sign-in-button');
const findArkoseLabsErrorMessage = () => wrapper.findByTestId('arkose-labs-error-message');
const findArkoseLabsVerificationTokenInput = () =>
wrapper.find('input[name="arkose_labs_token"]');
// Helpers
const createForm = (username = '') => {
loadFixtures('sessions/new.html');
findUsernameInput().value = username;
};
const initArkoseLabs = (username) => {
createForm(username);
wrapper = mountExtended(SignInArkoseApp, {
propsData: {
publicKey: MOCK_PUBLIC_KEY,
formSelector: makeTestIdSelector('sign-in-form'),
usernameSelector: makeTestIdSelector('username-field'),
submitSelector: makeTestIdSelector('sign-in-button'),
},
});
};
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(initArkoseLabsScript).toHaveBeenCalledWith({ publicKey: MOCK_PUBLIC_KEY });
});
it('creates a hidden input for the verification token', () => {
const input = findArkoseLabsVerificationTokenInput();
expect(input.exists()).toBe(true);
expect(input.element.value).toBe('');
});
};
const expectHiddenArkoseLabsError = () => {
expect(findArkoseLabsErrorMessage().exists()).toBe(false);
};
const expectArkoseLabsInitError = () => {
expect(wrapper.text()).toContain(wrapper.vm.$options.MSG_ARKOSE_FAILURE_BODY);
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
axiosMock.restore();
wrapper?.destroy();
});
describe('when the username field is pre-filled', () => {
it("does not include ArkoseLabs' script initially", () => {
expect(initArkoseLabsScript).not.toHaveBeenCalled();
});
it('puts the sign-in button in the loading state', async () => {
initArkoseLabs(MOCK_USERNAME);
await nextTick();
const signInButton = findSignInButton();
expect(signInButton.innerText).toMatchInterpolatedText('Loading');
expect(signInButton.disabled).toBe(true);
});
it('triggers a request to the captcha_check API', async () => {
initArkoseLabs(MOCK_USERNAME);
expect(axiosMock.history.get).toHaveLength(0);
await waitForPromises();
expect(axiosMock.history.get).toHaveLength(1);
expect(axiosMock.history.get[0].url).toMatch(`/users/${MOCK_USERNAME}/captcha_check`);
});
describe('if the challenge is not needed', () => {
beforeEach(async () => {
axiosMock.onGet().reply(200, { result: false });
initArkoseLabs(MOCK_USERNAME);
await waitForPromises();
});
it('resets the loading button', () => {
const signInButton = findSignInButton();
expect(signInButton.innerText).toMatchInterpolatedText('Sign in');
expect(signInButton.disabled).toBe(false);
});
it('does not show ArkoseLabs error when submitting the form', async () => {
submitForm();
await nextTick();
expect(findArkoseLabsErrorMessage().exists()).toBe(false);
});
describe('if the challenge becomes needed', () => {
beforeEach(async () => {
axiosMock.onGet().reply(200, { result: true });
setUsername(`malicious-${MOCK_USERNAME}`);
await waitForPromises();
});
itInitializesArkoseLabs();
});
});
describe('if the challenge is needed', () => {
beforeEach(async () => {
axiosMock.onGet().reply(200, { result: true });
initArkoseLabs(MOCK_USERNAME);
await waitForPromises();
});
itInitializesArkoseLabs();
it('shows ArkoseLabs error when submitting the form', async () => {
submitForm();
await nextTick();
expect(findArkoseLabsErrorMessage().exists()).toBe(true);
expect(wrapper.text()).toContain(wrapper.vm.$options.MSG_ARKOSE_NEEDED);
});
it('un-hides the challenge container once the iframe has been shown', async () => {
expect(wrapper.isVisible()).toBe(false);
onShown();
await nextTick();
expect(wrapper.isVisible()).toBe(true);
});
it('shows an error alert if the challenge fails to load', async () => {
expect(wrapper.text()).not.toContain(wrapper.vm.$options.MSG_ARKOSE_FAILURE_BODY);
const error = new Error();
onError(error);
expect(logError).toHaveBeenCalledWith('ArkoseLabs initialization error', error);
await nextTick();
expectArkoseLabsInitError();
});
describe('when ArkoseLabs calls `onCompleted` handler that has been configured', () => {
const response = { token: 'verification-token' };
beforeEach(() => {
submitForm();
onCompleted(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().element.value).toBe(response.token);
});
});
});
});
describe('when the username check fails', () => {
it('with a 404, nothing happens', async () => {
axiosMock.onGet().reply(404);
initArkoseLabs(MOCK_USERNAME);
await waitForPromises();
expect(initArkoseLabsScript).not.toHaveBeenCalled();
expectHiddenArkoseLabsError();
});
it('with some other HTTP error, the challenge is initialized', async () => {
axiosMock.onGet().reply(500);
initArkoseLabs(MOCK_USERNAME);
await waitForPromises();
expect(initArkoseLabsScript).toHaveBeenCalled();
expectHiddenArkoseLabsError();
});
it('due to the script inclusion, an error is shown', async () => {
const error = new Error();
initArkoseLabsScript.mockImplementation(() => {
throw new Error();
});
axiosMock.onGet().reply(200, { result: true });
initArkoseLabs(MOCK_USERNAME);
await waitForPromises();
expectArkoseLabsInitError();
expect(logError).toHaveBeenCalledWith('ArkoseLabs initialization error', error);
});
});
});
import { initArkoseLabsScript } from 'ee/arkose_labs/init_arkose_labs_script';
jest.mock('lodash/uniqueId', () => (x) => `${x}7`);
const EXPECTED_CALLBACK_NAME = '_initArkoseLabsScript_callback_7';
const TEST_PUBLIC_KEY = 'arkose-labs-public-api-key';
describe('initArkoseLabsScript', () => {
let subject;
const initSubject = () => {
subject = initArkoseLabsScript({ publicKey: TEST_PUBLIC_KEY });
};
const findScriptTags = () => document.querySelectorAll('script');
afterEach(() => {
subject = null;
document.getElementsByTagName('html')[0].innerHTML = '';
});
it('sets a global enforcement callback', () => {
initSubject();
expect(window[EXPECTED_CALLBACK_NAME]).not.toBe(undefined);
});
it('adds ArkoseLabs scripts to the HTML head', () => {
expect(findScriptTags()).toHaveLength(0);
initSubject();
const scriptTag = findScriptTags().item(0);
expect(scriptTag.getAttribute('type')).toBe('text/javascript');
expect(scriptTag.getAttribute('src')).toBe(
`https://client-api.arkoselabs.com/v2/${TEST_PUBLIC_KEY}/api.js`,
);
expect(scriptTag.getAttribute('data-callback')).toBe(EXPECTED_CALLBACK_NAME);
});
it('when callback is called, cleans up the global object and resolves the Promise', () => {
initSubject();
const enforcement = 'ArkoseLabsEnforcement';
window[EXPECTED_CALLBACK_NAME](enforcement);
expect(window[EXPECTED_CALLBACK_NAME]).toBe(undefined);
return expect(subject).resolves.toBe(enforcement);
});
});
# 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 ""
......
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