diff options
18 files changed, 676 insertions, 66 deletions
diff --git a/app/assets/javascripts/authentication/mount_2fa.js b/app/assets/javascripts/authentication/mount_2fa.js index 52ed67b8c7b..ebdcd1e074d 100644 --- a/app/assets/javascripts/authentication/mount_2fa.js +++ b/app/assets/javascripts/authentication/mount_2fa.js @@ -1,12 +1,12 @@ import $ from 'jquery'; import initU2F from './u2f'; import U2FRegister from './u2f/register'; -import initWebauthn from './webauthn'; +import initWebauthnAuthentication from './webauthn'; import WebAuthnRegister from './webauthn/register'; export const mount2faAuthentication = () => { if (gon.webauthn) { - initWebauthn(); + initWebauthnAuthentication(); } else { initU2F(); } diff --git a/app/assets/javascripts/authentication/webauthn/components/registration.vue b/app/assets/javascripts/authentication/webauthn/components/registration.vue new file mode 100644 index 00000000000..1cc57046562 --- /dev/null +++ b/app/assets/javascripts/authentication/webauthn/components/registration.vue @@ -0,0 +1,226 @@ +<script> +import { + GlAlert, + GlButton, + GlForm, + GlFormInput, + GlFormGroup, + GlLink, + GlLoadingIcon, + GlSprintf, +} from '@gitlab/ui'; +import { + I18N_BUTTON_REGISTER, + I18N_BUTTON_SETUP, + I18N_BUTTON_TRY_AGAIN, + I18N_DEVICE_NAME, + I18N_DEVICE_NAME_DESCRIPTION, + I18N_DEVICE_NAME_PLACEHOLDER, + I18N_ERROR_HTTP, + I18N_ERROR_UNSUPPORTED_BROWSER, + I18N_INFO_TEXT, + I18N_NOTICE, + I18N_PASSWORD, + I18N_PASSWORD_DESCRIPTION, + I18N_STATUS_SUCCESS, + I18N_STATUS_WAITING, + STATE_ERROR, + STATE_READY, + STATE_SUCCESS, + STATE_UNSUPPORTED, + STATE_WAITING, + WEBAUTHN_DOCUMENTATION_PATH, +} from '~/authentication/webauthn/constants'; +import WebAuthnError from '~/authentication/webauthn/error'; +import { + FLOW_REGISTER, + convertCreateParams, + convertCreateResponse, + isHTTPS, + supported, +} from '~/authentication/webauthn/util'; +import csrf from '~/lib/utils/csrf'; + +export default { + name: 'WebAuthnRegistration', + components: { + GlAlert, + GlButton, + GlForm, + GlFormInput, + GlFormGroup, + GlLink, + GlLoadingIcon, + GlSprintf, + }, + I18N_BUTTON_REGISTER, + I18N_BUTTON_SETUP, + I18N_BUTTON_TRY_AGAIN, + I18N_DEVICE_NAME, + I18N_DEVICE_NAME_DESCRIPTION, + I18N_DEVICE_NAME_PLACEHOLDER, + I18N_ERROR_HTTP, + I18N_ERROR_UNSUPPORTED_BROWSER, + I18N_INFO_TEXT, + I18N_NOTICE, + I18N_PASSWORD, + I18N_PASSWORD_DESCRIPTION, + I18N_STATUS_SUCCESS, + I18N_STATUS_WAITING, + STATE_ERROR, + STATE_READY, + STATE_SUCCESS, + STATE_UNSUPPORTED, + STATE_WAITING, + WEBAUTHN_DOCUMENTATION_PATH, + inject: ['initialError', 'passwordRequired', 'targetPath'], + data() { + return { + csrfToken: csrf.token, + form: { deviceName: '', password: '' }, + state: STATE_UNSUPPORTED, + errorMessage: this.initialError, + credentials: null, + }; + }, + computed: { + disabled() { + const isEmptyDeviceName = this.form.deviceName.trim() === ''; + const isEmptyPassword = this.form.password.trim() === ''; + + if (this.passwordRequired === false) { + return isEmptyDeviceName; + } + + return isEmptyDeviceName || isEmptyPassword; + }, + }, + created() { + if (this.errorMessage) { + this.state = STATE_ERROR; + return; + } + + if (isHTTPS() && supported()) { + this.state = STATE_READY; + return; + } + + this.errorMessage = isHTTPS() ? I18N_ERROR_UNSUPPORTED_BROWSER : I18N_ERROR_HTTP; + }, + methods: { + isCurrentState(state) { + return this.state === state; + }, + async onRegister() { + this.state = STATE_WAITING; + + try { + const credentials = await navigator.credentials.create({ + publicKey: convertCreateParams(gon.webauthn.options), + }); + + this.credentials = JSON.stringify(convertCreateResponse(credentials)); + this.state = STATE_SUCCESS; + } catch (error) { + this.errorMessage = new WebAuthnError(error, FLOW_REGISTER).message(); + this.state = STATE_ERROR; + } + }, + }, +}; +</script> + +<template> + <div> + <template v-if="isCurrentState($options.STATE_UNSUPPORTED)"> + <gl-alert variant="danger" :dismissible="false">{{ errorMessage }}</gl-alert> + </template> + + <template v-else-if="isCurrentState($options.STATE_READY)"> + <div class="row"> + <div class="col-md-5"> + <gl-button variant="confirm" @click="onRegister">{{ + $options.I18N_BUTTON_SETUP + }}</gl-button> + </div> + <div class="col-md-7"> + <p>{{ $options.I18N_INFO_TEXT }}</p> + </div> + </div> + </template> + + <template v-else-if="isCurrentState($options.STATE_WAITING)"> + <gl-alert :dismissible="false"> + {{ $options.I18N_STATUS_WAITING }} + <gl-loading-icon /> + </gl-alert> + </template> + + <template v-else-if="isCurrentState($options.STATE_SUCCESS)"> + <p>{{ $options.I18N_STATUS_SUCCESS }}</p> + <gl-alert :dismissible="false" class="gl-mb-5"> + <gl-sprintf :message="$options.I18N_NOTICE"> + <template #link="{ content }"> + <gl-link :href="$options.WEBAUTHN_DOCUMENTATION_PATH" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + + <div class="row"> + <gl-form method="post" :action="targetPath" class="col-md-9" data-testid="create-webauthn"> + <gl-form-group + v-if="passwordRequired" + :description="$options.I18N_PASSWORD_DESCRIPTION" + :label="$options.I18N_PASSWORD" + label-for="webauthn-registration-current-password" + > + <gl-form-input + id="webauthn-registration-current-password" + v-model="form.password" + name="current_password" + type="password" + autocomplete="current-password" + data-testid="current-password-input" + /> + </gl-form-group> + + <gl-form-group + :description="$options.I18N_DEVICE_NAME_DESCRIPTION" + :label="$options.I18N_DEVICE_NAME" + label-for="device-name" + > + <gl-form-input + id="device-name" + v-model="form.deviceName" + name="device_registration[name]" + :placeholder="$options.I18N_DEVICE_NAME_PLACEHOLDER" + data-testid="device-name-input" + /> + </gl-form-group> + + <input type="hidden" name="device_registration[device_response]" :value="credentials" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + + <gl-button type="submit" :disabled="disabled" variant="confirm">{{ + $options.I18N_BUTTON_REGISTER + }}</gl-button> + </gl-form> + </div> + </template> + + <template v-else-if="isCurrentState($options.STATE_ERROR)"> + <gl-alert + variant="danger" + :dismissible="false" + class="gl-mb-5" + :secondary-button-text="$options.I18N_BUTTON_TRY_AGAIN" + @secondaryAction="onRegister" + > + {{ errorMessage }} + </gl-alert> + </template> + </div> +</template> diff --git a/app/assets/javascripts/authentication/webauthn/constants.js b/app/assets/javascripts/authentication/webauthn/constants.js new file mode 100644 index 00000000000..6646cb2eb3f --- /dev/null +++ b/app/assets/javascripts/authentication/webauthn/constants.js @@ -0,0 +1,44 @@ +import { __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const I18N_BUTTON_REGISTER = __('Register device'); +export const I18N_BUTTON_SETUP = __('Set up new device'); +export const I18N_BUTTON_TRY_AGAIN = __('Try again?'); +export const I18N_DEVICE_NAME = __('Device name'); +export const I18N_DEVICE_NAME_DESCRIPTION = __( + 'Excluding USB security keys, you should include the browser name together with the device name.', +); +export const I18N_DEVICE_NAME_PLACEHOLDER = __('Macbook Touch ID on Edge'); +export const I18N_ERROR_HTTP = __( + 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.', +); +export const I18N_ERROR_UNSUPPORTED_BROWSER = __( + "Your browser doesn't support WebAuthn. Please use a supported browser, e.g. Chrome (67+) or Firefox (60+).", +); +export const I18N_INFO_TEXT = __( + 'Your device needs to be set up. Plug it in (if needed) and click the button on the left.', +); +export const I18N_NOTICE = __( + 'You must save your recovery codes after you first register a two-factor authenticator, so you do not lose access to your account. %{linkStart}See the documentation on managing your WebAuthn device for more information.%{linkEnd}', +); +export const I18N_PASSWORD = __('Current password'); +export const I18N_PASSWORD_DESCRIPTION = __( + 'Your current password is required to register a new device.', +); +export const I18N_STATUS_SUCCESS = __( + 'Your device was successfully set up! Give it a name and register it with the GitLab server.', +); +export const I18N_STATUS_WAITING = __( + 'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.', +); + +export const STATE_ERROR = 'error'; +export const STATE_READY = 'ready'; +export const STATE_SUCCESS = 'success'; +export const STATE_UNSUPPORTED = 'unsupported'; +export const STATE_WAITING = 'waiting'; + +export const WEBAUTHN_DOCUMENTATION_PATH = helpPagePath( + 'user/profile/account/two_factor_authentication', + { anchor: 'set-up-a-webauthn-device' }, +); diff --git a/app/assets/javascripts/authentication/webauthn/index.js b/app/assets/javascripts/authentication/webauthn/index.js index bbf694c7698..e9c20ce7795 100644 --- a/app/assets/javascripts/authentication/webauthn/index.js +++ b/app/assets/javascripts/authentication/webauthn/index.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import WebAuthnAuthenticate from './authenticate'; +import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate'; export default () => { const webauthnAuthenticate = new WebAuthnAuthenticate( diff --git a/app/assets/javascripts/authentication/webauthn/registration.js b/app/assets/javascripts/authentication/webauthn/registration.js new file mode 100644 index 00000000000..67906a24857 --- /dev/null +++ b/app/assets/javascripts/authentication/webauthn/registration.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import WebAuthnRegistration from '~/authentication/webauthn/components/registration.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +export const initWebAuthnRegistration = () => { + const el = document.querySelector('#js-device-registration'); + + if (!el) { + return null; + } + + const { initialError, passwordRequired, targetPath } = el.dataset; + + return new Vue({ + el, + name: 'WebAuthnRegistrationRoot', + provide: { initialError, passwordRequired: parseBoolean(passwordRequired), targetPath }, + render(h) { + return h(WebAuthnRegistration); + }, + }); +}; 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 96c4d0e0670..ea6bca644ed 100644 --- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js +++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js @@ -1,4 +1,5 @@ import { mount2faRegistration } from '~/authentication/mount_2fa'; +import { initWebAuthnRegistration } from '~/authentication/webauthn/registration'; import { initRecoveryCodes, initManageTwoFactorForm } from '~/authentication/two_factor_auth'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -15,6 +16,7 @@ if (skippable) { } mount2faRegistration(); +initWebAuthnRegistration(); initRecoveryCodes(); diff --git a/app/helpers/device_registration_helper.rb b/app/helpers/device_registration_helper.rb new file mode 100644 index 00000000000..bbdcab76bf5 --- /dev/null +++ b/app/helpers/device_registration_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DeviceRegistrationHelper + def device_registration_data(current_password_required:, target_path:, webauthn_error:) + { + initial_error: webauthn_error && webauthn_error[:message], + target_path: target_path, + password_required: current_password_required.to_s + } + end +end diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb index 8e79a750793..601381f1c65 100644 --- a/app/models/oauth_access_token.rb +++ b/app/models/oauth_access_token.rb @@ -4,6 +4,8 @@ class OauthAccessToken < Doorkeeper::AccessToken belongs_to :resource_owner, class_name: 'User' belongs_to :application, class_name: 'Doorkeeper::Application' + validates :expires_in, presence: true + alias_attribute :user, :resource_owner scope :latest_per_application, -> { select('distinct on(application_id) *').order(application_id: :desc, created_at: :desc) } diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml index d6fe20e48bf..dc4511a8159 100644 --- a/app/views/authentication/_register.html.haml +++ b/app/views/authentication/_register.html.haml @@ -1,47 +1,50 @@ -#js-register-token-2fa +- if Feature.enabled?(:webauthn) && Feature.enabled?(:webauthn_without_totp) + #js-device-registration{ data: device_registration_data(current_password_required: current_password_required?, target_path: target_path, webauthn_error: @webauthn_error) } +- else + #js-register-token-2fa --# haml-lint:disable InlineJavaScript -%script#js-register-2fa-message{ type: "text/template" } - %p <%= message %> + -# haml-lint:disable InlineJavaScript + %script#js-register-2fa-message{ type: "text/template" } + %p <%= message %> --# haml-lint:disable InlineJavaScript -%script#js-register-token-2fa-setup{ type: "text/template" } - - if current_user.two_factor_otp_enabled? - .row.gl-mb-3 - .col-md-5 - = render Pajamas::ButtonComponent.new(variant: :confirm, - button_options: { id: 'js-setup-token-2fa-device' }) do - = _("Set up new device") - .col-md-7 - %p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.") - - else - .row.gl-mb-3 - .col-md-4 - = render Pajamas::ButtonComponent.new(variant: :confirm, - disabled: true, - button_options: { id: 'js-setup-token-2fa-device' }) do - = _("Set up new device") - .col-md-8 - %p= _("You need to register a two-factor authentication app before you can set up a device.") + -# haml-lint:disable InlineJavaScript + %script#js-register-token-2fa-setup{ type: "text/template" } + - if current_user.two_factor_otp_enabled? + .row.gl-mb-3 + .col-md-5 + = render Pajamas::ButtonComponent.new(variant: :confirm, + button_options: { id: 'js-setup-token-2fa-device' }) do + = _("Set up new device") + .col-md-7 + %p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.") + - else + .row.gl-mb-3 + .col-md-4 + = render Pajamas::ButtonComponent.new(variant: :confirm, + disabled: true, + button_options: { id: 'js-setup-token-2fa-device' }) do + = _("Set up new device") + .col-md-8 + %p= _("You need to register a two-factor authentication app before you can set up a device.") --# haml-lint:disable InlineJavaScript -%script#js-register-token-2fa-error{ type: "text/template" } - %div - %p - %span <%= error_message %> (<%= error_name %>) - = render Pajamas::ButtonComponent.new(button_options: { id: 'js-token-2fa-try-again' }) do - = _("Try again?") + -# haml-lint:disable InlineJavaScript + %script#js-register-token-2fa-error{ type: "text/template" } + %div + %p + %span <%= error_message %> (<%= error_name %>) + = render Pajamas::ButtonComponent.new(button_options: { id: 'js-token-2fa-try-again' }) do + = _("Try again?") --# haml-lint:disable InlineJavaScript -%script#js-register-token-2fa-registered{ type: "text/template" } - .row.gl-mb-3 - .col-md-12 - %p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.") - = form_tag(target_path, method: :post) do - .row.gl-mb-3 - .col-md-3 - = text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name") - .col-md-3 - = hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response" - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do - = _("Register device") + -# haml-lint:disable InlineJavaScript + %script#js-register-token-2fa-registered{ type: "text/template" } + .row.gl-mb-3 + .col-md-12 + %p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.") + = form_tag(target_path, method: :post) do + .row.gl-mb-3 + .col-md-3 + = text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name") + .col-md-3 + = hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response" + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do + = _("Register device") diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index d24c5431f53..918b2767c4d 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -121,17 +121,3 @@ Doorkeeper.configure do # We might want to disable this in the future, see https://gitlab.com/gitlab-org/gitlab/-/issues/323615 skip_client_authentication_for_password_grant true end - -module Doorkeeper - class AccessToken - # Doorkeeper OAuth Token refresh uses expires_in of refresh token for new token - # https://github.com/doorkeeper-gem/doorkeeper/pull/1366 - # This override ensures that tokens with expires_in: nil do not create new - # tokens with expires_in: nil during refresh flow. - # Can be removed after https://gitlab.com/gitlab-org/gitlab/-/issues/386094 is - # closed - def expires_in - super || 2.hours - end - end -end diff --git a/db/post_migrate/20230223014251_validate_not_null_constraint_on_oauth_access_tokens_expires_in.rb b/db/post_migrate/20230223014251_validate_not_null_constraint_on_oauth_access_tokens_expires_in.rb new file mode 100644 index 00000000000..b5085d24ab1 --- /dev/null +++ b/db/post_migrate/20230223014251_validate_not_null_constraint_on_oauth_access_tokens_expires_in.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ValidateNotNullConstraintOnOauthAccessTokensExpiresIn < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + def up + validate_not_null_constraint :oauth_access_tokens, :expires_in + end + + def down + # no-op + end +end diff --git a/db/schema_migrations/20230223014251 b/db/schema_migrations/20230223014251 new file mode 100644 index 00000000000..7613e540112 --- /dev/null +++ b/db/schema_migrations/20230223014251 @@ -0,0 +1 @@ +1d43fc6bfb88caf86d02b83c944c143bc87142a49f3fe1ec4c54e29c960060c5
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d6a51b7a5f7..d195789caa3 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -18808,7 +18808,8 @@ CREATE TABLE oauth_access_tokens ( expires_in integer DEFAULT 7200, revoked_at timestamp without time zone, created_at timestamp without time zone NOT NULL, - scopes character varying + scopes character varying, + CONSTRAINT check_70f294ef54 CHECK ((expires_in IS NOT NULL)) ); CREATE SEQUENCE oauth_access_tokens_id_seq @@ -26184,9 +26185,6 @@ ALTER TABLE ONLY chat_teams ALTER TABLE vulnerability_scanners ADD CONSTRAINT check_37608c9db5 CHECK ((char_length(vendor) <= 255)) NOT VALID; -ALTER TABLE oauth_access_tokens - ADD CONSTRAINT check_70f294ef54 CHECK ((expires_in IS NOT NULL)) NOT VALID; - ALTER TABLE sprints ADD CONSTRAINT check_ccd8a1eae0 CHECK ((start_date IS NOT NULL)) NOT VALID; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b1c9c318e54..900c7682c21 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14530,6 +14530,9 @@ msgstr "" msgid "Developer" msgstr "" +msgid "Device name" +msgstr "" + msgid "Devices (optional)" msgstr "" @@ -16892,6 +16895,9 @@ msgstr "" msgid "Exceptions" msgstr "" +msgid "Excluding USB security keys, you should include the browser name together with the device name." +msgstr "" + msgid "Excluding merge commits. Limited to %{limit} commits." msgstr "" @@ -25817,6 +25823,9 @@ msgstr "" msgid "MRDiff|Show full file" msgstr "" +msgid "Macbook Touch ID on Edge" +msgstr "" + msgid "Machine Learning Experiment Tracking is in Incubating Phase" msgstr "" @@ -49509,6 +49518,9 @@ msgstr "" msgid "You must provide your current password in order to change it." msgstr "" +msgid "You must save your recovery codes after you first register a two-factor authenticator, so you do not lose access to your account. %{linkStart}See the documentation on managing your WebAuthn device for more information.%{linkEnd}" +msgstr "" + msgid "You must sign in to search for specific projects." msgstr "" @@ -49841,6 +49853,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 new device." +msgstr "" + msgid "Your current password is required to register a two-factor authenticator app." msgstr "" diff --git a/spec/frontend/authentication/webauthn/components/registration_spec.js b/spec/frontend/authentication/webauthn/components/registration_spec.js new file mode 100644 index 00000000000..56185c59b5a --- /dev/null +++ b/spec/frontend/authentication/webauthn/components/registration_spec.js @@ -0,0 +1,249 @@ +import { nextTick } from 'vue'; +import { GlAlert, GlButton, GlForm, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import Registration from '~/authentication/webauthn/components/registration.vue'; +import { + I18N_BUTTON_REGISTER, + I18N_BUTTON_SETUP, + I18N_BUTTON_TRY_AGAIN, + I18N_ERROR_HTTP, + I18N_ERROR_UNSUPPORTED_BROWSER, + I18N_INFO_TEXT, + I18N_STATUS_SUCCESS, + I18N_STATUS_WAITING, + STATE_ERROR, + STATE_READY, + STATE_SUCCESS, + STATE_UNSUPPORTED, + STATE_WAITING, +} from '~/authentication/webauthn/constants'; +import * as WebAuthnUtils from '~/authentication/webauthn/util'; + +const csrfToken = 'mock-csrf-token'; +jest.mock('~/lib/utils/csrf', () => ({ token: csrfToken })); +jest.mock('~/authentication/webauthn/util'); + +describe('Registration', () => { + const initialError = null; + const passwordRequired = true; + const targetPath = '/-/profile/two_factor_auth/create_webauthn'; + let wrapper; + + const createComponent = (provide = {}) => { + wrapper = shallowMountExtended(Registration, { + provide: { initialError, passwordRequired, targetPath, ...provide }, + }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + describe(`when ${STATE_UNSUPPORTED} state`, () => { + it('shows an error if using unsecure scheme (HTTP)', () => { + WebAuthnUtils.isHTTPS.mockReturnValue(false); + WebAuthnUtils.supported.mockReturnValue(true); + createComponent(); + + const alert = wrapper.findComponent(GlAlert); + expect(alert.props('variant')).toBe('danger'); + expect(alert.text()).toBe(I18N_ERROR_HTTP); + }); + + it('shows an error if using unsupported browser', () => { + WebAuthnUtils.isHTTPS.mockReturnValue(true); + WebAuthnUtils.supported.mockReturnValue(false); + createComponent(); + + const alert = wrapper.findComponent(GlAlert); + expect(alert.props('variant')).toBe('danger'); + expect(alert.text()).toBe(I18N_ERROR_UNSUPPORTED_BROWSER); + }); + }); + + describe('when scheme or browser are supported', () => { + const mockCreate = jest.fn(); + + const clickSetupDeviceButton = () => { + findButton().vm.$emit('click'); + return nextTick(); + }; + + const setupDevice = () => { + clickSetupDeviceButton(); + return waitForPromises(); + }; + + beforeEach(() => { + WebAuthnUtils.isHTTPS.mockReturnValue(true); + WebAuthnUtils.supported.mockReturnValue(true); + global.navigator.credentials = { create: mockCreate }; + gon.webauthn = { options: {} }; + }); + + afterEach(() => { + global.navigator.credentials = undefined; + }); + + describe(`when ${STATE_READY} state`, () => { + it('shows button and explanation text', () => { + createComponent(); + + expect(findButton().text()).toBe(I18N_BUTTON_SETUP); + expect(wrapper.text()).toContain(I18N_INFO_TEXT); + }); + }); + + describe(`when ${STATE_WAITING} state`, () => { + it('shows loading icon and message after pressing the button', async () => { + createComponent(); + + await clickSetupDeviceButton(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.text()).toContain(I18N_STATUS_WAITING); + }); + }); + + describe(`when ${STATE_SUCCESS} state`, () => { + const credentials = 1; + + const findCurrentPasswordInput = () => wrapper.findByTestId('current-password-input'); + const findDeviceNameInput = () => wrapper.findByTestId('device-name-input'); + + beforeEach(() => { + mockCreate.mockResolvedValueOnce(true); + WebAuthnUtils.convertCreateResponse.mockReturnValue(credentials); + }); + + describe('registration form', () => { + it('has correct action', async () => { + createComponent(); + + await setupDevice(); + + expect(wrapper.findComponent(GlForm).attributes('action')).toBe(targetPath); + }); + + describe('when password is required', () => { + it('shows device name and password fields', async () => { + createComponent(); + + await setupDevice(); + + expect(wrapper.text()).toContain(I18N_STATUS_SUCCESS); + + // Visible inputs + expect(findCurrentPasswordInput().attributes('name')).toBe('current_password'); + expect(findDeviceNameInput().attributes('name')).toBe('device_registration[name]'); + + // Hidden inputs + expect( + wrapper + .find('input[name="device_registration[device_response]"]') + .attributes('value'), + ).toBe(`${credentials}`); + expect(wrapper.find('input[name=authenticity_token]').attributes('value')).toBe( + csrfToken, + ); + + expect(findButton().text()).toBe(I18N_BUTTON_REGISTER); + }); + + it('enables the register device button when device name and password are filled', async () => { + createComponent(); + + await setupDevice(); + + expect(findButton().props('disabled')).toBe(true); + + // Visible inputs + findCurrentPasswordInput().vm.$emit('input', 'my current password'); + findDeviceNameInput().vm.$emit('input', 'my device name'); + await nextTick(); + + expect(findButton().props('disabled')).toBe(false); + }); + }); + + describe('when password is not required', () => { + it('shows a device name field', async () => { + createComponent({ passwordRequired: false }); + + await setupDevice(); + + expect(wrapper.text()).toContain(I18N_STATUS_SUCCESS); + + // Visible inputs + expect(findCurrentPasswordInput().exists()).toBe(false); + expect(findDeviceNameInput().attributes('name')).toBe('device_registration[name]'); + + // Hidden inputs + expect( + wrapper + .find('input[name="device_registration[device_response]"]') + .attributes('value'), + ).toBe(`${credentials}`); + expect(wrapper.find('input[name=authenticity_token]').attributes('value')).toBe( + csrfToken, + ); + + expect(findButton().text()).toBe(I18N_BUTTON_REGISTER); + }); + + it('enables the register device button when device name is filled', async () => { + createComponent({ passwordRequired: false }); + + await setupDevice(); + + expect(findButton().props('disabled')).toBe(true); + + findDeviceNameInput().vm.$emit('input', 'my device name'); + await nextTick(); + + expect(findButton().props('disabled')).toBe(false); + }); + }); + }); + }); + + describe(`when ${STATE_ERROR} state`, () => { + it('shows an initial error message and a retry button', async () => { + const myError = 'my error'; + createComponent({ initialError: myError }); + + const alert = wrapper.findComponent(GlAlert); + expect(alert.props()).toMatchObject({ + variant: 'danger', + secondaryButtonText: I18N_BUTTON_TRY_AGAIN, + }); + expect(alert.text()).toContain(myError); + }); + + it('shows an error message and a retry button', async () => { + createComponent(); + mockCreate.mockRejectedValueOnce(new Error()); + + await setupDevice(); + + expect(wrapper.findComponent(GlAlert).props()).toMatchObject({ + variant: 'danger', + secondaryButtonText: I18N_BUTTON_TRY_AGAIN, + }); + }); + + it('recovers after an error (error to success state)', async () => { + createComponent(); + mockCreate.mockRejectedValueOnce(new Error()).mockResolvedValueOnce(true); + + await setupDevice(); + + expect(wrapper.findComponent(GlAlert).props('variant')).toBe('danger'); + + wrapper.findComponent(GlAlert).vm.$emit('secondaryAction'); + await waitForPromises(); + + expect(wrapper.findComponent(GlAlert).props('variant')).toBe('info'); + }); + }); + }); +}); diff --git a/spec/frontend/fixtures/webauthn.rb b/spec/frontend/fixtures/webauthn.rb index c6e9b41b584..ed6180118f0 100644 --- a/spec/frontend/fixtures/webauthn.rb +++ b/spec/frontend/fixtures/webauthn.rb @@ -32,6 +32,7 @@ RSpec.context 'WebAuthn' do allow_next_instance_of(Profiles::TwoFactorAuthsController) do |instance| allow(instance).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares') end + stub_feature_flags(webauthn_without_totp: false) end it 'webauthn/register.html' do diff --git a/spec/helpers/device_registration_helper_spec.rb b/spec/helpers/device_registration_helper_spec.rb new file mode 100644 index 00000000000..a8222cddca9 --- /dev/null +++ b/spec/helpers/device_registration_helper_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe DeviceRegistrationHelper, feature_category: :authentication_and_authorization do + describe "#device_registration_data" do + it "returns a hash with device registration properties without initial error" do + device_registration_data = helper.device_registration_data( + current_password_required: false, + target_path: "/my/path", + webauthn_error: nil + ) + + expect(device_registration_data).to eq( + { + initial_error: nil, + target_path: "/my/path", + password_required: "false" + }) + end + + it "returns a hash with device registration properties with initial error" do + device_registration_data = helper.device_registration_data( + current_password_required: true, + target_path: "/my/path", + webauthn_error: { message: "my error" } + ) + + expect(device_registration_data).to eq( + { + initial_error: "my error", + target_path: "/my/path", + password_required: "true" + }) + end + end +end diff --git a/spec/models/oauth_access_token_spec.rb b/spec/models/oauth_access_token_spec.rb index fc53d926dd6..5fa590eab58 100644 --- a/spec/models/oauth_access_token_spec.rb +++ b/spec/models/oauth_access_token_spec.rb @@ -59,7 +59,7 @@ RSpec.describe OauthAccessToken do it 'uses the expires_in value' do token = OauthAccessToken.new(expires_in: 1.minute) - expect(token.expires_in).to eq 1.minute + expect(token).to be_valid end end @@ -67,7 +67,7 @@ RSpec.describe OauthAccessToken do it 'uses default value' do token = OauthAccessToken.new(expires_in: nil) - expect(token.expires_in).to eq 2.hours + expect(token).to be_invalid end end end |