Commit 086303a7 authored by Magdalena Frankiewicz's avatar Magdalena Frankiewicz

Require password confirmation when user changes their primary email

Do not require confirmation when password is automatically set,
do not require for API, LDAP, SCIM

Changelog: security
parent 053178ce
......@@ -2,6 +2,7 @@ import $ from 'jquery';
import '~/profile/gl_crop';
import Profile from '~/profile/profile';
import initSearchSettings from '~/search_settings';
import initPasswordPrompt from './password_prompt';
// eslint-disable-next-line func-names
$(document).on('input.ssh_key', '#key_key', function () {
......@@ -19,3 +20,4 @@ $(document).on('input.ssh_key', '#key_key', function () {
new Profile(); // eslint-disable-line no-new
initSearchSettings();
initPasswordPrompt();
import { __, s__ } from '~/locale';
export const I18N_PASSWORD_PROMPT_TITLE = s__('PasswordPrompt|Confirm password to continue');
export const I18N_PASSWORD_PROMPT_FORM_LABEL = s__(
'PasswordPrompt|Please enter your password to confirm',
);
export const I18N_PASSWORD_PROMPT_ERROR_MESSAGE = s__('PasswordPrompt|Password is required');
export const I18N_PASSWORD_PROMPT_CONFIRM_BUTTON = s__('PasswordPrompt|Confirm password');
export const I18N_PASSWORD_PROMPT_CANCEL_BUTTON = __('Cancel');
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import PasswordPromptModal from './password_prompt_modal.vue';
Vue.use(Translate);
const emailFieldSelector = '#user_email';
const editFormSelector = '.js-password-prompt-form';
const passwordPromptFieldSelector = '.js-password-prompt-field';
const passwordPromptBtnSelector = '.js-password-prompt-btn';
const passwordPromptModalId = 'password-prompt-modal';
const getEmailValue = () => document.querySelector(emailFieldSelector).value.trim();
const passwordPromptButton = document.querySelector(passwordPromptBtnSelector);
const field = document.querySelector(passwordPromptFieldSelector);
const form = document.querySelector(editFormSelector);
const handleConfirmPassword = (pw) => {
// update the validation_password field
field.value = pw;
// submit the form
form.submit();
};
export default () => {
const passwordPromptModalEl = document.getElementById(passwordPromptModalId);
if (passwordPromptModalEl && field) {
return new Vue({
el: passwordPromptModalEl,
data() {
return {
initialEmail: '',
};
},
mounted() {
this.initialEmail = getEmailValue();
passwordPromptButton.addEventListener('click', this.handleSettingsUpdate);
},
methods: {
handleSettingsUpdate(ev) {
const email = getEmailValue();
if (email !== this.initialEmail) {
ev.preventDefault();
this.$root.$emit('bv::show::modal', passwordPromptModalId, passwordPromptBtnSelector);
}
},
},
render(createElement) {
return createElement(PasswordPromptModal, {
props: { handleConfirmPassword },
});
},
});
}
return null;
};
<script>
import { GlModal, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import {
I18N_PASSWORD_PROMPT_TITLE,
I18N_PASSWORD_PROMPT_FORM_LABEL,
I18N_PASSWORD_PROMPT_ERROR_MESSAGE,
I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
} from './constants';
export default {
components: {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
},
props: {
handleConfirmPassword: {
type: Function,
required: true,
},
},
data() {
return {
passwordCheck: '',
};
},
computed: {
isValid() {
return Boolean(this.passwordCheck.length);
},
primaryProps() {
return {
text: I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.isValid }],
};
},
},
methods: {
onConfirmPassword() {
this.handleConfirmPassword(this.passwordCheck);
},
},
cancelProps: {
text: I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
},
i18n: {
title: I18N_PASSWORD_PROMPT_TITLE,
formLabel: I18N_PASSWORD_PROMPT_FORM_LABEL,
errorMessage: I18N_PASSWORD_PROMPT_ERROR_MESSAGE,
},
};
</script>
<template>
<gl-modal
data-testid="password-prompt-modal"
modal-id="password-prompt-modal"
:title="$options.i18n.title"
:action-primary="primaryProps"
:action-cancel="$options.cancelProps"
@primary="onConfirmPassword"
>
<gl-form @submit.prevent="onConfirmPassword">
<gl-form-group
:label="$options.i18n.formLabel"
label-for="password-prompt-confirmation"
:invalid-feedback="$options.i18n.errorMessage"
:state="isValid"
>
<gl-form-input
id="password-prompt-confirmation"
v-model="passwordCheck"
name="password-confirmation"
type="password"
data-testid="password-prompt-field"
/>
</gl-form-group>
</gl-form>
</gl-modal>
</template>
......@@ -18,7 +18,7 @@ class ProfilesController < Profiles::ApplicationController
def update
respond_to do |format|
result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute
result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute(check_password: true)
if result[:status] == :success
message = s_("Profiles|Profile was successfully updated")
......@@ -129,6 +129,7 @@ class ProfilesController < Profiles::ApplicationController
:job_title,
:pronouns,
:pronunciation,
:validation_password,
status: [:emoji, :message, :availability]
]
end
......
......@@ -5,15 +5,18 @@ module Users
include NewUserNotifier
attr_reader :user, :identity_params
ATTRS_REQUIRING_PASSWORD_CHECK = %w[email].freeze
def initialize(current_user, params = {})
@current_user = current_user
@validation_password = params.delete(:validation_password)
@user = params.delete(:user)
@status_params = params.delete(:status)
@identity_params = params.slice(*identity_attributes)
@params = params.dup
end
def execute(validate: true, &block)
def execute(validate: true, check_password: false, &block)
yield(@user) if block_given?
user_exists = @user.persisted?
......@@ -21,6 +24,11 @@ module Users
discard_read_only_attributes
assign_attributes
if check_password && require_password_check? && !@user.valid_password?(@validation_password)
return error(s_("Profiles|Invalid password"))
end
assign_identity
build_canonical_email
......@@ -32,8 +40,8 @@ module Users
end
end
def execute!(*args, &block)
result = execute(*args, &block)
def execute!(*args, **kargs, &block)
result = execute(*args, **kargs, &block)
raise ActiveRecord::RecordInvalid, @user unless result[:status] == :success
......@@ -42,6 +50,14 @@ module Users
private
def require_password_check?
return false unless @user.persisted?
return false if @user.password_automatically_set?
changes = @user.changed
ATTRS_REQUIRING_PASSWORD_CHECK.any? { |param| changes.include?(param) }
end
def build_canonical_email
return unless @user.email_changed?
......
......@@ -3,8 +3,11 @@
- email_change_disabled = local_assigns.fetch(:email_change_disabled, nil)
- read_only_help_text = readonly ? s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } : user_email_help_text(@user)
- help_text = email_change_disabled ? s_("Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO.") % { group_name: @user.managing_group.name } : read_only_help_text
- password_automatically_set = @user.password_automatically_set?
= form.text_field :email, required: true, class: 'input-lg gl-form-input', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled
- unless password_automatically_set
= hidden_field_tag 'user[validation_password]', :validation_password, class: 'js-password-prompt-field', help: s_("Profiles|Enter your password to confirm the email change")
= form.select :public_email, options_for_select(@user.public_verified_emails, selected: @user.public_email),
{ help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") },
control_class: 'select2 input-lg', disabled: email_change_disabled
......
......@@ -5,7 +5,7 @@
- availability = availability_values
- custom_emoji = show_status_emoji?(@user.status)
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
= form_errors(@user)
.row.js-search-settings-section
......@@ -124,9 +124,11 @@
.help-block
= s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information")
%hr
= f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3'
= f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3 js-password-prompt-btn'
= link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-default btn-cancel'
#password-prompt-modal
.modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } }
.modal-dialog
.modal-content
......
......@@ -1085,7 +1085,6 @@ module API
attrs = declared_params(include_missing: false)
service = ::UserPreferences::UpdateService.new(current_user, attrs).execute
if service.success?
present preferences, with: Entities::UserPreferences
else
......
......@@ -24481,6 +24481,18 @@ msgstr ""
msgid "Password was successfully updated. Please sign in again."
msgstr ""
msgid "PasswordPrompt|Confirm password"
msgstr ""
msgid "PasswordPrompt|Confirm password to continue"
msgstr ""
msgid "PasswordPrompt|Password is required"
msgstr ""
msgid "PasswordPrompt|Please enter your password to confirm"
msgstr ""
msgid "Passwords should be unique and not used for any other sites or services."
msgstr ""
......@@ -25927,6 +25939,9 @@ msgstr ""
msgid "Profiles|Enter your name, so people you know can recognize you"
msgstr ""
msgid "Profiles|Enter your password to confirm the email change"
msgstr ""
msgid "Profiles|Enter your pronouns to let people know how to refer to you"
msgstr ""
......
......@@ -3,7 +3,8 @@
require('spec_helper')
RSpec.describe ProfilesController, :request_store do
let(:user) { create(:user) }
let(:password) { 'longsecret987!' }
let(:user) { create(:user, password: password) }
describe 'POST update' do
it 'does not update password' do
......@@ -23,7 +24,7 @@ RSpec.describe ProfilesController, :request_store do
sign_in(user)
put :update,
params: { user: { email: "john@gmail.com", name: "John" } }
params: { user: { email: "john@gmail.com", name: "John", validation_password: password } }
user.reload
......
......@@ -139,6 +139,8 @@ FactoryBot.define do
end
factory :omniauth_user do
password_automatically_set { true }
transient do
extern_uid { '123456' }
provider { 'ldapmain' }
......
......@@ -121,7 +121,7 @@ RSpec.describe 'Admin Mode Login' do
end
context 'when logging in via omniauth' do
let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml')}
let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml', password_automatically_set: false)}
let(:mock_saml_response) do
File.read('spec/fixtures/authentication/saml_response.xml')
end
......
......@@ -19,6 +19,17 @@ RSpec.describe 'User edit profile' do
wait_for_requests if respond_to?(:wait_for_requests)
end
def update_user_email
fill_in 'user_email', with: 'new-email@example.com'
click_button 'Update profile settings'
end
def confirm_password(password)
fill_in 'password-confirmation', with: password
click_button 'Confirm password'
wait_for_requests if respond_to?(:wait_for_requests)
end
def visit_user
visit user_path(user)
wait_for_requests
......@@ -88,16 +99,42 @@ RSpec.describe 'User edit profile' do
expect(page).to have_content('Website url is not a valid URL')
end
describe 'when I change my email' do
describe 'when I change my email', :js do
before do
user.send_reset_password_instructions
end
it 'will prompt to confirm my password' do
expect(user.reset_password_token?).to be true
update_user_email
expect(page).to have_selector('[data-testid="password-prompt-modal"]')
end
context 'when prompted to confirm password' do
before do
update_user_email
end
it 'with the correct password successfully updates' do
confirm_password(user.password)
expect(page).to have_text("Profile was successfully updated")
end
it 'with the incorrect password fails to update' do
confirm_password("Fake password")
expect(page).to have_text("Invalid password")
end
end
it 'clears the reset password token' do
expect(user.reset_password_token?).to be true
fill_in 'user_email', with: 'new-email@example.com'
submit_settings
update_user_email
confirm_password(user.password)
user.reload
expect(user.confirmation_token).not_to be_nil
......
......@@ -874,7 +874,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do
end
end
context 'when the user does not have an email configured' do
context 'when the user does not have an email configured', :js do
let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml', email: 'temp-email-for-oauth-user@gitlab.localhost') }
before do
......
import { GlModal } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
} from '~/pages/profiles/password_prompt/constants';
import PasswordPromptModal from '~/pages/profiles/password_prompt/password_prompt_modal.vue';
const createComponent = ({ props }) => {
return shallowMountExtended(PasswordPromptModal, {
propsData: {
...props,
},
});
};
describe('Password prompt modal', () => {
let wrapper;
const mockPassword = 'not+fake+shady+password';
const mockEvent = { preventDefault: jest.fn() };
const handleConfirmPasswordSpy = jest.fn();
const findField = () => wrapper.findByTestId('password-prompt-field');
const findModal = () => wrapper.findComponent(GlModal);
const findConfirmBtn = () => findModal().props('actionPrimary');
const findConfirmBtnDisabledState = () =>
findModal().props('actionPrimary').attributes[2].disabled;
const findCancelBtn = () => findModal().props('actionCancel');
const submitModal = () => findModal().vm.$emit('primary', mockEvent);
const setPassword = (newPw) => findField().vm.$emit('input', newPw);
beforeEach(() => {
wrapper = createComponent({
props: {
handleConfirmPassword: handleConfirmPasswordSpy,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders the password field', () => {
expect(findField().exists()).toBe(true);
});
it('renders the confirm button', () => {
expect(findConfirmBtn().text).toEqual(I18N_PASSWORD_PROMPT_CONFIRM_BUTTON);
});
it('renders the cancel button', () => {
expect(findCancelBtn().text).toEqual(I18N_PASSWORD_PROMPT_CANCEL_BUTTON);
});
describe('confirm button', () => {
describe('with a valid password', () => {
it('calls the `handleConfirmPassword` method when clicked', async () => {
setPassword(mockPassword);
submitModal();
await wrapper.vm.$nextTick();
expect(handleConfirmPasswordSpy).toHaveBeenCalledTimes(1);
expect(handleConfirmPasswordSpy).toHaveBeenCalledWith(mockPassword);
});
it('enables the confirm button', async () => {
setPassword(mockPassword);
expect(findConfirmBtnDisabledState()).toBe(true);
await wrapper.vm.$nextTick();
expect(findConfirmBtnDisabledState()).toBe(false);
});
});
it('without a valid password is disabled', async () => {
setPassword('');
expect(findConfirmBtnDisabledState()).toBe(true);
await wrapper.vm.$nextTick();
expect(findConfirmBtnDisabledState()).toBe(true);
});
});
});
......@@ -3,7 +3,8 @@
require 'spec_helper'
RSpec.describe Users::UpdateService do
let(:user) { create(:user) }
let(:password) { 'longsecret987!' }
let(:user) { create(:user, password: password, password_confirmation: password) }
describe '#execute' do
it 'updates time preferences' do
......@@ -18,7 +19,7 @@ RSpec.describe Users::UpdateService do
it 'returns an error result when record cannot be updated' do
result = {}
expect do
result = update_user(user, { email: 'invalid' })
result = update_user(user, { email: 'invalid', validation_password: password })
end.not_to change { user.reload.email }
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Email is invalid')
......@@ -65,7 +66,7 @@ RSpec.describe Users::UpdateService do
context 'updating canonical email' do
context 'if email was changed' do
subject do
update_user(user, email: 'user+extrastuff@example.com')
update_user(user, email: 'user+extrastuff@example.com', validation_password: password)
end
it 'calls canonicalize_email' do
......@@ -75,15 +76,68 @@ RSpec.describe Users::UpdateService do
subject
end
context 'when check_password is true' do
def update_user(user, opts)
described_class.new(user, opts.merge(user: user)).execute(check_password: true)
end
it 'returns error if no password confirmation was passed', :aggregate_failures do
result = {}
expect do
result = update_user(user, { email: 'example@example.com' })
end.not_to change { user.reload.unconfirmed_email }
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid password')
end
it 'returns error if wrong password confirmation was passed', :aggregate_failures do
result = {}
expect do
result = update_user(user, { email: 'example@example.com', validation_password: 'wrongpassword' })
end.not_to change { user.reload.unconfirmed_email }
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid password')
end
it 'does not require password if it was automatically set', :aggregate_failures do
user.update!(password_automatically_set: true)
result = {}
expect do
result = update_user(user, { email: 'example@example.com' })
end.to change { user.reload.unconfirmed_email }
expect(result[:status]).to eq(:success)
end
it 'does not require a password if the attribute changed does not require it' do
result = {}
expect do
result = update_user(user, { job_title: 'supreme leader of the universe' })
end.to change { user.reload.job_title }
expect(result[:status]).to eq(:success)
end
end
end
context 'if email was NOT changed' do
subject do
update_user(user, job_title: 'supreme leader of the universe')
context 'when check_password is left to false' do
it 'does not require a password check', :aggregate_failures do
result = {}
expect do
result = update_user(user, { email: 'example@example.com' })
end.to change { user.reload.unconfirmed_email }
expect(result[:status]).to eq(:success)
end
end
context 'if email was NOT changed' do
it 'skips update canonicalize email service call' do
expect { subject }.not_to change { user.user_canonical_email }
expect do
update_user(user, job_title: 'supreme leader of the universe')
end.not_to change { user.user_canonical_email }
end
end
end
......@@ -106,7 +160,7 @@ RSpec.describe Users::UpdateService do
it 'raises an error when record cannot be updated' do
expect do
update_user(user, email: 'invalid')
update_user(user, email: 'invalid', validation_password: password)
end.to raise_error(ActiveRecord::RecordInvalid)
end
......
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