Commit 800f821d authored by Luke Duncalfe's avatar Luke Duncalfe Committed by Ash McKenzie

Fix 2FA setup for users with no password

parent e60ba66f
...@@ -24,6 +24,7 @@ export default { ...@@ -24,6 +24,7 @@ export default {
}, },
inject: [ inject: [
'webauthnEnabled', 'webauthnEnabled',
'isCurrentPasswordRequired',
'profileTwoFactorAuthPath', 'profileTwoFactorAuthPath',
'profileTwoFactorAuthMethod', 'profileTwoFactorAuthMethod',
'codesProfileTwoFactorAuthPath', 'codesProfileTwoFactorAuthPath',
...@@ -64,7 +65,11 @@ export default { ...@@ -64,7 +65,11 @@ export default {
<input type="hidden" name="_method" data-testid="test-2fa-method-field" :value="method" /> <input type="hidden" name="_method" data-testid="test-2fa-method-field" :value="method" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
<gl-form-group :label="$options.i18n.currentPassword" label-for="current-password"> <gl-form-group
v-if="isCurrentPasswordRequired"
:label="$options.i18n.currentPassword"
label-for="current-password"
>
<gl-form-input <gl-form-input
id="current-password" id="current-password"
type="password" type="password"
......
import Vue from 'vue'; import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { updateHistory, removeParams } from '~/lib/utils/url_utility'; import { updateHistory, removeParams } from '~/lib/utils/url_utility';
import ManageTwoFactorForm from './components/manage_two_factor_form.vue'; import ManageTwoFactorForm from './components/manage_two_factor_form.vue';
import RecoveryCodes from './components/recovery_codes.vue'; import RecoveryCodes from './components/recovery_codes.vue';
...@@ -13,16 +14,20 @@ export const initManageTwoFactorForm = () => { ...@@ -13,16 +14,20 @@ export const initManageTwoFactorForm = () => {
const { const {
webauthnEnabled = false, webauthnEnabled = false,
currentPasswordRequired,
profileTwoFactorAuthPath = '', profileTwoFactorAuthPath = '',
profileTwoFactorAuthMethod = '', profileTwoFactorAuthMethod = '',
codesProfileTwoFactorAuthPath = '', codesProfileTwoFactorAuthPath = '',
codesProfileTwoFactorAuthMethod = '', codesProfileTwoFactorAuthMethod = '',
} = el.dataset; } = el.dataset;
const isCurrentPasswordRequired = parseBoolean(currentPasswordRequired);
return new Vue({ return new Vue({
el, el,
provide: { provide: {
webauthnEnabled, webauthnEnabled,
isCurrentPasswordRequired,
profileTwoFactorAuthPath, profileTwoFactorAuthPath,
profileTwoFactorAuthMethod, profileTwoFactorAuthMethod,
codesProfileTwoFactorAuthPath, codesProfileTwoFactorAuthPath,
......
...@@ -3,7 +3,9 @@ ...@@ -3,7 +3,9 @@
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_two_factor_requirement skip_before_action :check_two_factor_requirement
before_action :ensure_verified_primary_email, only: [:show, :create] before_action :ensure_verified_primary_email, only: [:show, :create]
before_action :validate_current_password, only: [:create, :codes, :destroy] before_action :validate_current_password, only: [:create, :codes, :destroy], if: :current_password_required?
helper_method :current_password_required?
before_action do before_action do
push_frontend_feature_flag(:webauthn) push_frontend_feature_flag(:webauthn)
...@@ -144,6 +146,10 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -144,6 +146,10 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
redirect_to profile_two_factor_auth_path, alert: _('You must provide a valid current password') redirect_to profile_two_factor_auth_path, alert: _('You must provide a valid current password')
end end
def current_password_required?
!current_user.password_automatically_set?
end
def build_qr_code def build_qr_code
uri = current_user.otp_provisioning_uri(account_string, issuer: issuer_host) uri = current_user.otp_provisioning_uri(account_string, issuer: issuer_host)
RQRCode.render_qrcode(uri, :svg, level: :m, unit: 3) RQRCode.render_qrcode(uri, :svg, level: :m, unit: 3)
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +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.") = _("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 %p
= _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.') = _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.')
.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' } } .js-manage-two-factor-form{ data: { webauthn_enabled: webauthn_enabled, current_password_required: current_password_required?.to_s, 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 - else
%p %p
...@@ -47,11 +47,12 @@ ...@@ -47,11 +47,12 @@
.form-group .form-group
= label_tag :pin_code, _('Pin code'), class: "label-bold" = 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' } = text_field_tag :pin_code, nil, class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' }
.form-group - if current_password_required?
= label_tag :current_password, _('Current password'), class: 'label-bold' .form-group
= password_field_tag :current_password, nil, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } = label_tag :current_password, _('Current password'), class: 'label-bold'
%p.form-text.text-muted = password_field_tag :current_password, nil, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
= _('Your current password is required to register a two-factor authenticator app.') %p.form-text.text-muted
= _('Your current password is required to register a two-factor authenticator app.')
.gl-mt-3 .gl-mt-3
= submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'register_2fa_app_button' } = submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'register_2fa_app_button' }
......
...@@ -31,11 +31,12 @@ RSpec.describe Profiles::TwoFactorAuthsController do ...@@ -31,11 +31,12 @@ RSpec.describe Profiles::TwoFactorAuthsController do
shared_examples 'user must enter a valid current password' do shared_examples 'user must enter a valid current password' do
let(:current_password) { '123' } let(:current_password) { '123' }
let(:redirect_path) { profile_two_factor_auth_path }
it 'requires the current password', :aggregate_failures do it 'requires the current password', :aggregate_failures do
go go
expect(response).to redirect_to(profile_two_factor_auth_path) expect(response).to redirect_to(redirect_path)
expect(flash[:alert]).to eq(_('You must provide a valid current password')) expect(flash[:alert]).to eq(_('You must provide a valid current password'))
end end
...@@ -48,6 +49,19 @@ RSpec.describe Profiles::TwoFactorAuthsController do ...@@ -48,6 +49,19 @@ RSpec.describe Profiles::TwoFactorAuthsController do
expect(user.reload).to be_access_locked expect(user.reload).to be_access_locked
end end
end end
context 'when user authenticates with an external service' do
before do
allow(user).to receive(:password_automatically_set?).and_return(true)
end
it 'does not require the current password', :aggregate_failures do
go
expect(response).not_to redirect_to(redirect_path)
expect(flash[:alert]).to be_nil
end
end
end end
describe 'GET show' do describe 'GET show' do
...@@ -188,7 +202,9 @@ RSpec.describe Profiles::TwoFactorAuthsController do ...@@ -188,7 +202,9 @@ RSpec.describe Profiles::TwoFactorAuthsController do
end end
describe 'DELETE destroy' do describe 'DELETE destroy' do
subject { delete :destroy, params: { current_password: current_password } } def go
delete :destroy, params: { current_password: current_password }
end
let(:current_password) { user.password } let(:current_password) { user.password }
...@@ -196,40 +212,38 @@ RSpec.describe Profiles::TwoFactorAuthsController do ...@@ -196,40 +212,38 @@ RSpec.describe Profiles::TwoFactorAuthsController do
let_it_be_with_reload(:user) { create(:user, :two_factor) } let_it_be_with_reload(:user) { create(:user, :two_factor) }
it 'disables two factor' do it 'disables two factor' do
subject go
expect(user.reload.two_factor_enabled?).to eq(false) expect(user.reload.two_factor_enabled?).to eq(false)
end end
it 'redirects to profile_account_path' do it 'redirects to profile_account_path' do
subject go
expect(response).to redirect_to(profile_account_path) expect(response).to redirect_to(profile_account_path)
end end
it 'displays a notice on success' do it 'displays a notice on success' do
subject go
expect(flash[:notice]) expect(flash[:notice])
.to eq _('Two-factor authentication has been disabled successfully!') .to eq _('Two-factor authentication has been disabled successfully!')
end end
it_behaves_like 'user must enter a valid current password' do it_behaves_like 'user must enter a valid current password'
let(:go) { delete :destroy, params: { current_password: current_password } }
end
end end
context 'for a user that does not have 2FA enabled' do context 'for a user that does not have 2FA enabled' do
let_it_be_with_reload(:user) { create(:user) } let_it_be_with_reload(:user) { create(:user) }
it 'redirects to profile_account_path' do it 'redirects to profile_account_path' do
subject go
expect(response).to redirect_to(profile_account_path) expect(response).to redirect_to(profile_account_path)
end end
it 'displays an alert on failure' do it 'displays an alert on failure' do
subject go
expect(flash[:alert]) expect(flash[:alert])
.to eq _('Two-factor authentication is not enabled for this user') .to eq _('Two-factor authentication is not enabled for this user')
......
...@@ -5,20 +5,16 @@ require 'spec_helper' ...@@ -5,20 +5,16 @@ require 'spec_helper'
RSpec.describe 'Two factor auths' do RSpec.describe 'Two factor auths' do
context 'when signed in' do context 'when signed in' do
before do before do
allow(Gitlab).to receive(:com?) { true } sign_in(user)
end end
context 'when user has two-factor authentication disabled' do context 'when user has two-factor authentication disabled' do
let(:user) { create(:user ) } let_it_be(:user) { create(:user ) }
before do
sign_in(user)
end
it 'requires the current password to set up two factor authentication', :js do it 'requires the current password to set up two factor authentication', :js do
visit profile_two_factor_auth_path visit profile_two_factor_auth_path
register_2fa(user.reload.current_otp, '123') register_2fa(user.current_otp, '123')
expect(page).to have_content('You must provide a valid current password') expect(page).to have_content('You must provide a valid current password')
...@@ -31,14 +27,28 @@ RSpec.describe 'Two factor auths' do ...@@ -31,14 +27,28 @@ RSpec.describe 'Two factor auths' do
expect(page).to have_content('Status: Enabled') expect(page).to have_content('Status: Enabled')
end end
end
context 'when user has two-factor authentication enabled' do context 'when user authenticates with an external service' do
let(:user) { create(:user, :two_factor) } let_it_be(:user) { create(:omniauth_user) }
it 'does not require the current password to set up two factor authentication', :js do
visit profile_two_factor_auth_path
before do fill_in 'pin_code', with: user.current_otp
sign_in(user) click_button 'Register with two-factor app'
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 end
end
context 'when user has two-factor authentication enabled' do
let_it_be(:user) { create(:user, :two_factor) }
it 'requires the current_password to disable two-factor authentication', :js do it 'requires the current_password to disable two-factor authentication', :js do
visit profile_two_factor_auth_path visit profile_two_factor_auth_path
...@@ -61,7 +71,7 @@ RSpec.describe 'Two factor auths' do ...@@ -61,7 +71,7 @@ RSpec.describe 'Two factor auths' do
expect(page).to have_content('Enable two-factor authentication') expect(page).to have_content('Enable two-factor authentication')
end end
it 'requires the current_password to regernate recovery codes', :js do it 'requires the current_password to regenerate recovery codes', :js do
visit profile_two_factor_auth_path visit profile_two_factor_auth_path
fill_in 'current_password', with: '123' fill_in 'current_password', with: '123'
...@@ -76,6 +86,29 @@ RSpec.describe 'Two factor auths' do ...@@ -76,6 +86,29 @@ RSpec.describe 'Two factor auths' do
expect(page).to have_content('Please copy, download, or print your recovery codes before proceeding.') expect(page).to have_content('Please copy, download, or print your recovery codes before proceeding.')
end end
context 'when user authenticates with an external service' do
let_it_be(:user) { create(:omniauth_user, :two_factor) }
it 'does not require the current_password to disable two-factor authentication', :js do
visit profile_two_factor_auth_path
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 'does not require the current_password to regenerate recovery codes', :js do
visit profile_two_factor_auth_path
click_button 'Regenerate recovery codes'
expect(page).to have_content('Please copy, download, or print your recovery codes before proceeding.')
end
end
end end
def register_2fa(pin, password) def register_2fa(pin, password)
......
// 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>
`;
import { within } from '@testing-library/dom'; import { within } from '@testing-library/dom';
import { GlForm } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ManageTwoFactorForm, { import ManageTwoFactorForm, {
i18n, i18n,
} from '~/authentication/two_factor_auth/components/manage_two_factor_form.vue'; } from '~/authentication/two_factor_auth/components/manage_two_factor_form.vue';
const defaultProvide = {
profileTwoFactorAuthPath: '2fa_auth_path',
profileTwoFactorAuthMethod: '2fa_auth_method',
codesProfileTwoFactorAuthPath: '2fa_codes_path',
codesProfileTwoFactorAuthMethod: '2fa_codes_method',
};
describe('ManageTwoFactorForm', () => { describe('ManageTwoFactorForm', () => {
let wrapper; let wrapper;
...@@ -12,11 +20,9 @@ describe('ManageTwoFactorForm', () => { ...@@ -12,11 +20,9 @@ describe('ManageTwoFactorForm', () => {
wrapper = extendedWrapper( wrapper = extendedWrapper(
mount(ManageTwoFactorForm, { mount(ManageTwoFactorForm, {
provide: { provide: {
webauthnEnabled: options?.webauthnEnabled || false, ...defaultProvide,
profileTwoFactorAuthPath: '2fa_auth_path', webauthnEnabled: options?.webauthnEnabled ?? false,
profileTwoFactorAuthMethod: '2fa_auth_method', isCurrentPasswordRequired: options?.currentPasswordRequired ?? true,
codesProfileTwoFactorAuthPath: '2fa_codes_path',
codesProfileTwoFactorAuthMethod: '2fa_codes_method',
}, },
}), }),
); );
...@@ -26,6 +32,11 @@ describe('ManageTwoFactorForm', () => { ...@@ -26,6 +32,11 @@ describe('ManageTwoFactorForm', () => {
const queryByLabelText = (text, options) => const queryByLabelText = (text, options) =>
within(wrapper.element).queryByLabelText(text, options); within(wrapper.element).queryByLabelText(text, options);
const findForm = () => wrapper.findComponent(GlForm);
const findMethodInput = () => wrapper.findByTestId('test-2fa-method-field');
const findDisableButton = () => wrapper.findByTestId('test-2fa-disable-button');
const findRegenerateCodesButton = () => wrapper.findByTestId('test-2fa-regenerate-codes-button');
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
}); });
...@@ -36,16 +47,30 @@ describe('ManageTwoFactorForm', () => { ...@@ -36,16 +47,30 @@ describe('ManageTwoFactorForm', () => {
}); });
}); });
describe('when current password is not required', () => {
beforeEach(() => {
createComponent({
currentPasswordRequired: false,
});
});
it('does not render the current password field', () => {
expect(queryByLabelText(i18n.currentPassword)).toBe(null);
});
});
describe('Disable button', () => { describe('Disable button', () => {
it('renders the component correctly', () => { it('renders the component with correct attributes', () => {
expect(wrapper).toMatchSnapshot(); expect(findDisableButton().exists()).toBe(true);
expect(wrapper.element).toMatchSnapshot(); expect(findDisableButton().attributes()).toMatchObject({
'data-confirm': i18n.confirm,
'data-form-action': defaultProvide.profileTwoFactorAuthPath,
'data-form-method': defaultProvide.profileTwoFactorAuthMethod,
});
}); });
it('has the right confirm text', () => { it('has the right confirm text', () => {
expect(wrapper.findByTestId('test-2fa-disable-button').element.dataset.confirm).toEqual( expect(findDisableButton().attributes('data-confirm')).toBe(i18n.confirm);
i18n.confirm,
);
}); });
describe('when webauthnEnabled', () => { describe('when webauthnEnabled', () => {
...@@ -56,23 +81,19 @@ describe('ManageTwoFactorForm', () => { ...@@ -56,23 +81,19 @@ describe('ManageTwoFactorForm', () => {
}); });
it('has the right confirm text', () => { it('has the right confirm text', () => {
expect(wrapper.findByTestId('test-2fa-disable-button').element.dataset.confirm).toEqual( expect(findDisableButton().attributes('data-confirm')).toBe(i18n.confirmWebAuthn);
i18n.confirmWebAuthn,
);
}); });
}); });
it('modifies the form action and method when submitted through the button', async () => { it('modifies the form action and method when submitted through the button', async () => {
const form = wrapper.find('form'); const form = findForm();
const disableButton = wrapper.findByTestId('test-2fa-disable-button').element; const disableButton = findDisableButton().element;
const methodInput = wrapper.findByTestId('test-2fa-method-field').element; const methodInput = findMethodInput();
form.trigger('submit', { submitter: disableButton }); await form.vm.$emit('submit', { submitter: disableButton });
await wrapper.vm.$nextTick(); expect(form.attributes('action')).toBe(defaultProvide.profileTwoFactorAuthPath);
expect(methodInput.attributes('value')).toBe(defaultProvide.profileTwoFactorAuthMethod);
expect(form.element.getAttribute('action')).toEqual('2fa_auth_path');
expect(methodInput.getAttribute('value')).toEqual('2fa_auth_method');
}); });
}); });
...@@ -82,17 +103,14 @@ describe('ManageTwoFactorForm', () => { ...@@ -82,17 +103,14 @@ describe('ManageTwoFactorForm', () => {
}); });
it('modifies the form action and method when submitted through the button', async () => { it('modifies the form action and method when submitted through the button', async () => {
const form = wrapper.find('form'); const form = findForm();
const regenerateCodesButton = wrapper.findByTestId('test-2fa-regenerate-codes-button') const regenerateCodesButton = findRegenerateCodesButton().element;
.element; const methodInput = findMethodInput();
const methodInput = wrapper.findByTestId('test-2fa-method-field').element;
form.trigger('submit', { submitter: regenerateCodesButton });
await wrapper.vm.$nextTick(); await form.vm.$emit('submit', { submitter: regenerateCodesButton });
expect(form.element.getAttribute('action')).toEqual('2fa_codes_path'); expect(form.attributes('action')).toBe(defaultProvide.codesProfileTwoFactorAuthPath);
expect(methodInput.getAttribute('value')).toEqual('2fa_codes_method'); expect(methodInput.attributes('value')).toBe(defaultProvide.codesProfileTwoFactorAuthMethod);
}); });
}); });
}); });
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