summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-09-29 12:54:43 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-09-29 12:54:49 +0000
commitef37104bc0f26d2fd509f49d8e16c5a45b392b43 (patch)
treee71a0146422048320b2ec273ab4077c26996f28f
parentaa5cc6421acfe210c256cdcc840a853fed7cd824 (diff)
downloadgitlab-ce-ef37104bc0f26d2fd509f49d8e16c5a45b392b43.tar.gz
Add latest changes from gitlab-org/security/gitlab@14-1-stable-ee
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue98
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/index.js31
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js4
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb11
-rw-r--r--app/helpers/external_link_helper.rb5
-rw-r--r--app/helpers/icons_helper.rb2
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml13
-rw-r--r--config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb42
-rw-r--r--doc/api/dependencies.md10
-rw-r--r--doc/user/profile/account/two_factor_authentication.md6
-rw-r--r--locale/gitlab.pot3
-rw-r--r--spec/controllers/profiles/two_factor_auths_controller_spec.rb44
-rw-r--r--spec/features/profiles/two_factor_auths_spec.rb88
-rw-r--r--spec/features/users/login_spec.rb1
-rw-r--r--spec/frontend/authentication/two_factor_auth/components/__snapshots__/manage_two_factor_form_spec.js.snap99
-rw-r--r--spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js98
-rw-r--r--spec/helpers/external_link_helper_spec.rb8
-rw-r--r--spec/helpers/icons_helper_spec.rb8
-rw-r--r--spec/initializers/100_patch_omniauth_oauth2_spec.rb56
19 files changed, 601 insertions, 26 deletions
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
new file mode 100644
index 00000000000..280c222c380
--- /dev/null
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlFormInput, GlFormGroup, GlButton, GlForm } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { __ } from '~/locale';
+
+export const i18n = {
+ currentPassword: __('Current password'),
+ confirmWebAuthn: __(
+ 'Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.',
+ ),
+ confirm: __('Are you sure? This will invalidate your registered applications and U2F devices.'),
+ disableTwoFactor: __('Disable two-factor authentication'),
+ regenerateRecoveryCodes: __('Regenerate recovery codes'),
+};
+
+export default {
+ name: 'ManageTwoFactorForm',
+ i18n,
+ components: {
+ GlForm,
+ GlFormInput,
+ GlFormGroup,
+ GlButton,
+ },
+ inject: [
+ 'webauthnEnabled',
+ 'profileTwoFactorAuthPath',
+ 'profileTwoFactorAuthMethod',
+ 'codesProfileTwoFactorAuthPath',
+ 'codesProfileTwoFactorAuthMethod',
+ ],
+ data() {
+ return {
+ method: '',
+ action: '#',
+ };
+ },
+ computed: {
+ confirmText() {
+ if (this.webauthnEnabled) {
+ return i18n.confirmWebAuthn;
+ }
+
+ return i18n.confirm;
+ },
+ },
+ methods: {
+ handleFormSubmit(event) {
+ this.method = event.submitter.dataset.formMethod;
+ this.action = event.submitter.dataset.formAction;
+ },
+ },
+ csrf,
+};
+</script>
+
+<template>
+ <gl-form
+ class="gl-display-inline-block"
+ method="post"
+ :action="action"
+ @submit="handleFormSubmit($event)"
+ >
+ <input type="hidden" name="_method" data-testid="test-2fa-method-field" :value="method" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+
+ <gl-form-group :label="$options.i18n.currentPassword" label-for="current-password">
+ <gl-form-input
+ id="current-password"
+ type="password"
+ name="current_password"
+ required
+ data-qa-selector="current_password_field"
+ />
+ </gl-form-group>
+
+ <gl-button
+ type="submit"
+ class="btn-danger gl-mr-3 gl-display-inline-block"
+ data-testid="test-2fa-disable-button"
+ variant="danger"
+ :data-confirm="confirmText"
+ :data-form-action="profileTwoFactorAuthPath"
+ :data-form-method="profileTwoFactorAuthMethod"
+ >
+ {{ $options.i18n.disableTwoFactor }}
+ </gl-button>
+ <gl-button
+ type="submit"
+ class="gl-display-inline-block"
+ data-testid="test-2fa-regenerate-codes-button"
+ :data-form-action="codesProfileTwoFactorAuthPath"
+ :data-form-method="codesProfileTwoFactorAuthMethod"
+ >
+ {{ $options.i18n.regenerateRecoveryCodes }}
+ </gl-button>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/authentication/two_factor_auth/index.js b/app/assets/javascripts/authentication/two_factor_auth/index.js
index 5e59c44e8cd..f663c0705e6 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/index.js
+++ b/app/assets/javascripts/authentication/two_factor_auth/index.js
@@ -1,8 +1,39 @@
import Vue from 'vue';
import { updateHistory, removeParams } from '~/lib/utils/url_utility';
+import ManageTwoFactorForm from './components/manage_two_factor_form.vue';
import RecoveryCodes from './components/recovery_codes.vue';
import { SUCCESS_QUERY_PARAM } from './constants';
+export const initManageTwoFactorForm = () => {
+ const el = document.querySelector('.js-manage-two-factor-form');
+
+ if (!el) {
+ return false;
+ }
+
+ const {
+ webauthnEnabled = false,
+ profileTwoFactorAuthPath = '',
+ profileTwoFactorAuthMethod = '',
+ codesProfileTwoFactorAuthPath = '',
+ codesProfileTwoFactorAuthMethod = '',
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ webauthnEnabled,
+ profileTwoFactorAuthPath,
+ profileTwoFactorAuthMethod,
+ codesProfileTwoFactorAuthPath,
+ codesProfileTwoFactorAuthMethod,
+ },
+ render(createElement) {
+ return createElement(ManageTwoFactorForm);
+ },
+ });
+};
+
export const initRecoveryCodes = () => {
const el = document.querySelector('.js-2fa-recovery-codes');
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index 186072531b8..00b5309ea85 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -1,5 +1,5 @@
import { mount2faRegistration } from '~/authentication/mount_2fa';
-import { initRecoveryCodes } from '~/authentication/two_factor_auth';
+import { initRecoveryCodes, initManageTwoFactorForm } from '~/authentication/two_factor_auth';
import { parseBoolean } from '~/lib/utils/common_utils';
document.addEventListener('DOMContentLoaded', () => {
@@ -16,3 +16,5 @@ document.addEventListener('DOMContentLoaded', () => {
});
initRecoveryCodes();
+
+initManageTwoFactorForm();
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index e2f8baa8226..5cbab55722f 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -2,6 +2,9 @@
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_two_factor_requirement
+
+ before_action :validate_current_password, only: [:create, :codes, :destroy]
+
before_action do
push_frontend_feature_flag(:webauthn)
end
@@ -129,6 +132,14 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
private
+ def validate_current_password
+ return if current_user.valid_password?(params[:current_password])
+
+ current_user.increment_failed_attempts!
+
+ redirect_to profile_two_factor_auth_path, alert: _('You must provide a valid current password')
+ end
+
def build_qr_code
uri = current_user.otp_provisioning_uri(account_string, issuer: issuer_host)
RQRCode.render_qrcode(uri, :svg, level: :m, unit: 3)
diff --git a/app/helpers/external_link_helper.rb b/app/helpers/external_link_helper.rb
index 058302d1ed8..c951d0daf96 100644
--- a/app/helpers/external_link_helper.rb
+++ b/app/helpers/external_link_helper.rb
@@ -1,9 +1,12 @@
# frozen_string_literal: true
module ExternalLinkHelper
+ include ActionView::Helpers::TextHelper
+
def external_link(body, url, options = {})
- link_to url, { target: '_blank', rel: 'noopener noreferrer' }.merge(options) do
+ link = link_to url, { target: '_blank', rel: 'noopener noreferrer' }.merge(options) do
"#{body}#{sprite_icon('external-link', css_class: 'gl-ml-1')}".html_safe
end
+ sanitize(link, tags: %w(a svg use), attributes: %w(target rel data-testid class href).concat(options.stringify_keys.keys))
end
end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 096a3f2269b..c38b4a7aedf 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -44,7 +44,7 @@ module IconsHelper
content_tag(
:svg,
- content_tag(:use, '', { 'xlink:href' => "#{sprite_icon_path}##{icon_name}" } ),
+ content_tag(:use, '', { 'href' => "#{sprite_icon_path}##{icon_name}" } ),
class: css_classes.empty? ? nil : css_classes.join(' '),
data: { testid: "#{icon_name}-icon" }
)
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 927b6d4edef..d1d6b6301b8 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -17,13 +17,7 @@
= _("You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication.")
%p
= _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.')
- %div
- = link_to _('Disable two-factor authentication'), profile_two_factor_auth_path,
- method: :delete,
- data: { confirm: webauthn_enabled ? _('Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.') : _('Are you sure? This will invalidate your registered applications and U2F devices.') },
- class: 'gl-button btn btn-danger gl-mr-3'
- = form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f|
- = submit_tag _('Regenerate recovery codes'), class: 'gl-button btn btn-default'
+ .js-manage-two-factor-form{ data: { webauthn_enabled: webauthn_enabled, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } }
- else
%p
@@ -53,6 +47,11 @@
.form-group
= label_tag :pin_code, _('Pin code'), class: "label-bold"
= text_field_tag :pin_code, nil, class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' }
+ .form-group
+ = label_tag :current_password, _('Current password'), class: 'label-bold'
+ = password_field_tag :current_password, nil, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
+ %p.form-text.text-muted
+ = _('Your current password is required to register a two-factor authenticator app.')
.gl-mt-3
= submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'register_2fa_app_button' }
diff --git a/config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb b/config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb
index 760fcba5935..1ede92609a9 100644
--- a/config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb
+++ b/config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb
@@ -1,14 +1,46 @@
# frozen_string_literal: true
+# See https://github.com/omniauth/omniauth-oauth2/blob/v1.7.1/lib/omniauth/strategies/oauth2.rb#L84-L101
+# for the original version of this code.
+#
+# Note: We need to override `callback_phase` directly (instead of using a module with `include` or `prepend`),
+# because the method has a `super` call which needs to go to the `OmniAuth::Strategy` module,
+# and it also deletes `omniauth.state` from the session as a side effect.
+
module OmniAuth
module Strategies
class OAuth2
- alias_method :original_callback_phase, :callback_phase
-
- # Monkey patch until PR is merged and released upstream
- # https://github.com/omniauth/omniauth-oauth2/pull/129
def callback_phase
- original_callback_phase
+ error = request.params["error_reason"].presence || request.params["error"].presence
+ # Monkey patch #1:
+ #
+ # Swap the order of these conditions around so the `state` param is verified *first*,
+ # before using the error params returned by the provider.
+ #
+ # This avoids content spoofing attacks by crafting a URL with malicious messages,
+ # because the `state` param is only present in the session after a valid OAuth2 authentication flow.
+ if !options.provider_ignores_state && (request.params["state"].to_s.empty? || request.params["state"] != session.delete("omniauth.state"))
+ fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected"))
+ elsif error
+ fail!(error, CallbackError.new(request.params["error"], request.params["error_description"].presence || request.params["error_reason"].presence, request.params["error_uri"]))
+ else
+ self.access_token = build_access_token
+ self.access_token = access_token.refresh! if access_token.expired?
+ super
+ end
+ rescue ::OAuth2::Error, CallbackError => e
+ fail!(:invalid_credentials, e)
+ rescue ::Timeout::Error, ::Errno::ETIMEDOUT => e
+ fail!(:timeout, e)
+ rescue ::SocketError => e
+ fail!(:failed_to_connect, e)
+ # Monkey patch #2:
+ #
+ # Also catch errors from Faraday.
+ # See https://github.com/omniauth/omniauth-oauth2/pull/129
+ # and https://github.com/oauth-xx/oauth2/issues/152
+ #
+ # This can be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/340933
rescue ::Faraday::TimeoutError, ::Faraday::ConnectionFailed => e
fail!(:timeout, e)
end
diff --git a/doc/api/dependencies.md b/doc/api/dependencies.md
index c85a3521aed..cebb1ad5cef 100644
--- a/doc/api/dependencies.md
+++ b/doc/api/dependencies.md
@@ -11,6 +11,9 @@ This API is in an alpha stage and considered unstable.
The response payload may be subject to change or breakage
across GitLab releases.
+> - Introduced in GitLab 12.1.
+> - Pagination introduced in 14.4.
+
Every call to this endpoint requires authentication. To perform this call, user should be authorized to read repository.
To see vulnerabilities in response, user should be authorized to read
[Project Security Dashboard](../user/application_security/security_dashboard/index.md#project-security-dashboard).
@@ -60,3 +63,10 @@ Example response:
}
]
```
+
+## Dependencies pagination
+
+By default, `GET` requests return 20 results at a time because the API results
+are paginated.
+
+Read more on [pagination](index.md#pagination).
diff --git a/doc/user/profile/account/two_factor_authentication.md b/doc/user/profile/account/two_factor_authentication.md
index 597170540ab..d0160e64339 100644
--- a/doc/user/profile/account/two_factor_authentication.md
+++ b/doc/user/profile/account/two_factor_authentication.md
@@ -64,6 +64,7 @@ To enable 2FA:
1. **In GitLab:**
1. Enter the six-digit pin number from the entry on your device into the **Pin
code** field.
+ 1. Enter your current password.
1. Select **Submit**.
If the pin you entered was correct, a message displays indicating that
@@ -354,7 +355,8 @@ If you ever need to disable 2FA:
1. Sign in to your GitLab account.
1. Go to your [**User settings**](../index.md#access-your-user-settings).
1. Go to **Account**.
-1. Click **Disable**, under **Two-Factor Authentication**.
+1. Select **Manage two-factor authentication**.
+1. Under **Two-Factor Authentication**, enter your current password and select **Disable**.
This clears all your two-factor authentication registrations, including mobile
applications and U2F / WebAuthn devices.
@@ -449,7 +451,7 @@ To regenerate 2FA recovery codes, you need access to a desktop browser:
1. Go to your [**User settings**](../index.md#access-your-user-settings).
1. Select **Account > Two-Factor Authentication (2FA)**.
1. If you've already configured 2FA, click **Manage two-factor authentication**.
-1. In the **Register Two-Factor Authenticator** pane, click **Regenerate recovery codes**.
+1. In the **Register Two-Factor Authenticator** pane, enter your current password and select **Regenerate recovery codes**.
NOTE:
If you regenerate 2FA recovery codes, save them. You can't use any previously created 2FA codes.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 70a16069eb0..30dc6fc2176 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -37971,6 +37971,9 @@ msgstr ""
msgid "Your commit email is used for web based operations, such as edits and merges."
msgstr ""
+msgid "Your current password is required to register a two-factor authenticator app."
+msgstr ""
+
msgid "Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}."
msgstr ""
diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
index 59eb33f4bc6..2fbc529037c 100644
--- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb
+++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
@@ -10,6 +10,27 @@ RSpec.describe Profiles::TwoFactorAuthsController do
allow(subject).to receive(:current_user).and_return(user)
end
+ shared_examples 'user must enter a valid current password' do
+ let(:current_password) { '123' }
+
+ it 'requires the current password', :aggregate_failures do
+ go
+
+ expect(response).to redirect_to(profile_two_factor_auth_path)
+ expect(flash[:alert]).to eq(_('You must provide a valid current password'))
+ end
+
+ context 'when the user is on the last sign in attempt' do
+ it do
+ user.update!(failed_attempts: User.maximum_attempts.pred)
+
+ go
+
+ expect(user.reload).to be_access_locked
+ end
+ end
+ end
+
describe 'GET show' do
let(:user) { create(:user) }
@@ -39,9 +60,10 @@ RSpec.describe Profiles::TwoFactorAuthsController do
describe 'POST create' do
let(:user) { create(:user) }
let(:pin) { 'pin-code' }
+ let(:current_password) { user.password }
def go
- post :create, params: { pin_code: pin }
+ post :create, params: { pin_code: pin, current_password: current_password }
end
context 'with valid pin' do
@@ -99,28 +121,38 @@ RSpec.describe Profiles::TwoFactorAuthsController do
expect(response).to render_template(:show)
end
end
+
+ it_behaves_like 'user must enter a valid current password'
end
describe 'POST codes' do
let(:user) { create(:user, :two_factor) }
+ let(:current_password) { user.password }
+
it 'presents plaintext codes for the user to save' do
expect(user).to receive(:generate_otp_backup_codes!).and_return(%w(a b c))
- post :codes
+ post :codes, params: { current_password: current_password }
expect(assigns[:codes]).to match_array %w(a b c)
end
it 'persists the generated codes' do
- post :codes
+ post :codes, params: { current_password: current_password }
user.reload
expect(user.otp_backup_codes).not_to be_empty
end
+
+ it_behaves_like 'user must enter a valid current password' do
+ let(:go) { post :codes, params: { current_password: current_password } }
+ end
end
describe 'DELETE destroy' do
- subject { delete :destroy }
+ subject { delete :destroy, params: { current_password: current_password } }
+
+ let(:current_password) { user.password }
context 'for a user that has 2FA enabled' do
let(:user) { create(:user, :two_factor) }
@@ -143,6 +175,10 @@ RSpec.describe Profiles::TwoFactorAuthsController do
expect(flash[:notice])
.to eq _('Two-factor authentication has been disabled successfully!')
end
+
+ it_behaves_like 'user must enter a valid current password' do
+ let(:go) { delete :destroy, params: { current_password: current_password } }
+ end
end
context 'for a user that does not have 2FA enabled' do
diff --git a/spec/features/profiles/two_factor_auths_spec.rb b/spec/features/profiles/two_factor_auths_spec.rb
new file mode 100644
index 00000000000..e1feca5031a
--- /dev/null
+++ b/spec/features/profiles/two_factor_auths_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Two factor auths' do
+ context 'when signed in' do
+ before do
+ allow(Gitlab).to receive(:com?) { true }
+ end
+
+ context 'when user has two-factor authentication disabled' do
+ let(:user) { create(:user ) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'requires the current password to set up two factor authentication', :js do
+ visit profile_two_factor_auth_path
+
+ register_2fa(user.reload.current_otp, '123')
+
+ expect(page).to have_content('You must provide a valid current password')
+
+ register_2fa(user.reload.current_otp, user.password)
+
+ expect(page).to have_content('Please copy, download, or print your recovery codes before proceeding.')
+
+ click_button 'Copy codes'
+ click_link 'Proceed'
+
+ expect(page).to have_content('Status: Enabled')
+ end
+ end
+
+ context 'when user has two-factor authentication enabled' do
+ let(:user) { create(:user, :two_factor) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'requires the current_password to disable two-factor authentication', :js do
+ visit profile_two_factor_auth_path
+
+ fill_in 'current_password', with: '123'
+
+ click_button 'Disable two-factor authentication'
+
+ page.accept_alert
+
+ expect(page).to have_content('You must provide a valid current password')
+
+ fill_in 'current_password', with: user.password
+
+ click_button 'Disable two-factor authentication'
+
+ page.accept_alert
+
+ expect(page).to have_content('Two-factor authentication has been disabled successfully!')
+ expect(page).to have_content('Enable two-factor authentication')
+ end
+
+ it 'requires the current_password to regernate recovery codes', :js do
+ visit profile_two_factor_auth_path
+
+ fill_in 'current_password', with: '123'
+
+ click_button 'Regenerate recovery codes'
+
+ expect(page).to have_content('You must provide a valid current password')
+
+ fill_in 'current_password', with: user.password
+
+ click_button 'Regenerate recovery codes'
+
+ expect(page).to have_content('Please copy, download, or print your recovery codes before proceeding.')
+ end
+ end
+
+ def register_2fa(pin, password)
+ fill_in 'pin_code', with: pin
+ fill_in 'current_password', with: password
+
+ click_button 'Register with two-factor app'
+ end
+ end
+end
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
index 6c38d5d8b24..5597bc82309 100644
--- a/spec/features/users/login_spec.rb
+++ b/spec/features/users/login_spec.rb
@@ -796,6 +796,7 @@ RSpec.describe 'Login' do
expect(current_path).to eq(profile_two_factor_auth_path)
fill_in 'pin_code', with: user.reload.current_otp
+ fill_in 'current_password', with: user.password
click_button 'Register with two-factor app'
click_button 'Copy codes'
diff --git a/spec/frontend/authentication/two_factor_auth/components/__snapshots__/manage_two_factor_form_spec.js.snap b/spec/frontend/authentication/two_factor_auth/components/__snapshots__/manage_two_factor_form_spec.js.snap
new file mode 100644
index 00000000000..3fe0e570a54
--- /dev/null
+++ b/spec/frontend/authentication/two_factor_auth/components/__snapshots__/manage_two_factor_form_spec.js.snap
@@ -0,0 +1,99 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ManageTwoFactorForm Disable button renders the component correctly 1`] = `
+VueWrapper {
+ "_emitted": Object {},
+ "_emittedByOrder": Array [],
+ "isFunctionalComponent": undefined,
+}
+`;
+
+exports[`ManageTwoFactorForm Disable button renders the component correctly 2`] = `
+<form
+ action="#"
+ class="gl-display-inline-block"
+ method="post"
+>
+ <input
+ data-testid="test-2fa-method-field"
+ name="_method"
+ type="hidden"
+ />
+
+ <input
+ name="authenticity_token"
+ type="hidden"
+ />
+
+ <div
+ class="form-group gl-form-group"
+ id="__BVID__15"
+ role="group"
+ >
+ <label
+ class="d-block col-form-label"
+ for="current-password"
+ id="__BVID__15__BV_label_"
+ >
+ Current password
+ </label>
+ <div
+ class="bv-no-focus-ring"
+ >
+ <input
+ aria-required="true"
+ class="gl-form-input form-control"
+ data-qa-selector="current_password_field"
+ id="current-password"
+ name="current_password"
+ required="required"
+ type="password"
+ />
+ <!---->
+ <!---->
+ <!---->
+ </div>
+ </div>
+
+ <button
+ class="btn btn-danger gl-mr-3 gl-display-inline-block btn-danger btn-md gl-button"
+ data-confirm="Are you sure? This will invalidate your registered applications and U2F devices."
+ data-form-action="2fa_auth_path"
+ data-form-method="2fa_auth_method"
+ data-testid="test-2fa-disable-button"
+ type="submit"
+ >
+ <!---->
+
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+
+ Disable two-factor authentication
+
+ </span>
+ </button>
+
+ <button
+ class="btn gl-display-inline-block btn-default btn-md gl-button"
+ data-form-action="2fa_codes_path"
+ data-form-method="2fa_codes_method"
+ data-testid="test-2fa-regenerate-codes-button"
+ type="submit"
+ >
+ <!---->
+
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+
+ Regenerate recovery codes
+
+ </span>
+ </button>
+</form>
+`;
diff --git a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js
new file mode 100644
index 00000000000..384579c6876
--- /dev/null
+++ b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js
@@ -0,0 +1,98 @@
+import { within } from '@testing-library/dom';
+import { mount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import ManageTwoFactorForm, {
+ i18n,
+} from '~/authentication/two_factor_auth/components/manage_two_factor_form.vue';
+
+describe('ManageTwoFactorForm', () => {
+ let wrapper;
+
+ const createComponent = (options = {}) => {
+ wrapper = extendedWrapper(
+ mount(ManageTwoFactorForm, {
+ provide: {
+ webauthnEnabled: options?.webauthnEnabled || false,
+ profileTwoFactorAuthPath: '2fa_auth_path',
+ profileTwoFactorAuthMethod: '2fa_auth_method',
+ codesProfileTwoFactorAuthPath: '2fa_codes_path',
+ codesProfileTwoFactorAuthMethod: '2fa_codes_method',
+ },
+ }),
+ );
+ };
+
+ const queryByText = (text, options) => within(wrapper.element).queryByText(text, options);
+ const queryByLabelText = (text, options) =>
+ within(wrapper.element).queryByLabelText(text, options);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('Current password field', () => {
+ it('renders the current password field', () => {
+ expect(queryByLabelText(i18n.currentPassword).tagName).toEqual('INPUT');
+ });
+ });
+
+ describe('Disable button', () => {
+ it('renders the component correctly', () => {
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('has the right confirm text', () => {
+ expect(wrapper.findByTestId('test-2fa-disable-button').element.dataset.confirm).toEqual(
+ i18n.confirm,
+ );
+ });
+
+ describe('when webauthnEnabled', () => {
+ beforeEach(() => {
+ createComponent({
+ webauthnEnabled: true,
+ });
+ });
+
+ it('has the right confirm text', () => {
+ expect(wrapper.findByTestId('test-2fa-disable-button').element.dataset.confirm).toEqual(
+ i18n.confirmWebAuthn,
+ );
+ });
+ });
+
+ it('modifies the form action and method when submitted through the button', async () => {
+ const form = wrapper.find('form');
+ const disableButton = wrapper.findByTestId('test-2fa-disable-button').element;
+ const methodInput = wrapper.findByTestId('test-2fa-method-field').element;
+
+ form.trigger('submit', { submitter: disableButton });
+
+ await wrapper.vm.$nextTick();
+
+ expect(form.element.getAttribute('action')).toEqual('2fa_auth_path');
+ expect(methodInput.getAttribute('value')).toEqual('2fa_auth_method');
+ });
+ });
+
+ describe('Regenerate recovery codes button', () => {
+ it('renders the button', () => {
+ expect(queryByText(i18n.regenerateRecoveryCodes)).toEqual(expect.any(HTMLElement));
+ });
+
+ it('modifies the form action and method when submitted through the button', async () => {
+ const form = wrapper.find('form');
+ const regenerateCodesButton = wrapper.findByTestId('test-2fa-regenerate-codes-button')
+ .element;
+ const methodInput = wrapper.findByTestId('test-2fa-method-field').element;
+
+ form.trigger('submit', { submitter: regenerateCodesButton });
+
+ await wrapper.vm.$nextTick();
+
+ expect(form.element.getAttribute('action')).toEqual('2fa_codes_path');
+ expect(methodInput.getAttribute('value')).toEqual('2fa_codes_method');
+ });
+ });
+});
diff --git a/spec/helpers/external_link_helper_spec.rb b/spec/helpers/external_link_helper_spec.rb
index f5bb0568824..b746cb04ab3 100644
--- a/spec/helpers/external_link_helper_spec.rb
+++ b/spec/helpers/external_link_helper_spec.rb
@@ -13,8 +13,14 @@ RSpec.describe ExternalLinkHelper do
it 'allows options when creating external link with icon' do
link = external_link('https://gitlab.com', 'https://gitlab.com', { "data-foo": "bar", class: "externalLink" }).to_s
-
expect(link).to start_with('<a target="_blank" rel="noopener noreferrer" data-foo="bar" class="externalLink" href="https://gitlab.com">https://gitlab.com')
expect(link).to include('data-testid="external-link-icon"')
end
+
+ it 'sanitizes and returns external link with icon' do
+ link = external_link('sanitized link content', 'javascript:alert()').to_s
+ expect(link).not_to include('href="javascript:alert()"')
+ expect(link).to start_with('<a target="_blank" rel="noopener noreferrer">sanitized link content')
+ expect(link).to include('data-testid="external-link-icon"')
+ end
end
diff --git a/spec/helpers/icons_helper_spec.rb b/spec/helpers/icons_helper_spec.rb
index 4784d0aff26..af2957d72c7 100644
--- a/spec/helpers/icons_helper_spec.rb
+++ b/spec/helpers/icons_helper_spec.rb
@@ -35,22 +35,22 @@ RSpec.describe IconsHelper do
it 'returns svg icon html with DEFAULT_ICON_SIZE' do
expect(sprite_icon(icon_name).to_s)
- .to eq "<svg class=\"s#{IconsHelper::DEFAULT_ICON_SIZE}\" data-testid=\"#{icon_name}-icon\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>"
+ .to eq "<svg class=\"s#{IconsHelper::DEFAULT_ICON_SIZE}\" data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>"
end
it 'returns svg icon html without size class' do
expect(sprite_icon(icon_name, size: nil).to_s)
- .to eq "<svg data-testid=\"#{icon_name}-icon\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>"
+ .to eq "<svg data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>"
end
it 'returns svg icon html + size classes' do
expect(sprite_icon(icon_name, size: 72).to_s)
- .to eq "<svg class=\"s72\" data-testid=\"#{icon_name}-icon\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>"
+ .to eq "<svg class=\"s72\" data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>"
end
it 'returns svg icon html + size classes + additional class' do
expect(sprite_icon(icon_name, size: 72, css_class: 'icon-danger').to_s)
- .to eq "<svg class=\"s72 icon-danger\" data-testid=\"#{icon_name}-icon\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>"
+ .to eq "<svg class=\"s72 icon-danger\" data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>"
end
describe 'non existing icon' do
diff --git a/spec/initializers/100_patch_omniauth_oauth2_spec.rb b/spec/initializers/100_patch_omniauth_oauth2_spec.rb
new file mode 100644
index 00000000000..0c436e4ef45
--- /dev/null
+++ b/spec/initializers/100_patch_omniauth_oauth2_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'OmniAuth::Strategies::OAuth2', type: :strategy do
+ let(:strategy) { [OmniAuth::Strategies::OAuth2] }
+
+ it 'verifies the gem version' do
+ current_version = OmniAuth::OAuth2::VERSION
+ expected_version = '1.7.1'
+
+ expect(current_version).to eq(expected_version), <<~EOF
+ New version #{current_version} of the `omniauth-oauth2` gem detected!
+
+ Please check if the monkey patches in `config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb`
+ are still needed, and either update/remove them, or bump the version in this spec.
+
+ EOF
+ end
+
+ context 'when a custom error message is passed from an OAuth2 provider' do
+ let(:message) { 'Please go to https://evil.com' }
+ let(:state) { 'secret' }
+ let(:callback_path) { '/users/auth/oauth2/callback' }
+ let(:params) { { state: state, error: 'evil_key', error_description: message } }
+ let(:error) { last_request.env['omniauth.error'] }
+
+ before do
+ env('rack.session', { 'omniauth.state' => state })
+ end
+
+ it 'returns the custom error message if the state is valid' do
+ get callback_path, **params
+
+ expect(error.message).to eq("evil_key | #{message}")
+ end
+
+ it 'returns the custom `error_reason` message if the `error_description` is blank' do
+ get callback_path, **params.merge(error_description: ' ', error_reason: 'custom reason')
+
+ expect(error.message).to eq('evil_key | custom reason')
+ end
+
+ it 'returns a CSRF error if the state is invalid' do
+ get callback_path, **params.merge(state: 'invalid')
+
+ expect(error.message).to eq('csrf_detected | CSRF detected')
+ end
+
+ it 'returns a CSRF error if the state is missing' do
+ get callback_path, **params.without(:state)
+
+ expect(error.message).to eq('csrf_detected | CSRF detected')
+ end
+ end
+end